App/Sources/SkyScreen.swift
426 lines · 20938 bytes
1import Library
2import ObservingLog
3import SkyEngine
4import SkyRenderer
5import SwiftUI
6
7struct SkyScreen: View {
8 @State private var model = SkyModel()
9 @State private var pointer = MotionPointer()
10 @State private var azimuth = 180.0
11 @State private var altitude = 45.0
12 @State private var fieldOfView = 100.0
13 @AppStorage(SettingsKey.nightMode) private var nightMode = "auto"
14 @AppStorage(SettingsKey.skyCulture) private var skyCulture = "western"
15 @AppStorage(SettingsKey.showDeepSky) private var showDeepSky = true
16 @State private var showSettings = false
17 @State private var showEvents = false
18 @State private var showLog = false
19 @State private var logEntry: Observation?
20 @AppStorage(SettingsKey.onboarded) private var onboarded = false
21 @AppStorage(SettingsKey.nightActive) private var nightActive = false
22 @State private var showToday = false
23 @State private var showYear = false
24 @State private var showTours = false
25 @State private var tour: Tour?
26 @State private var tourStep = 0
27 @State private var turning: Task<Void, Never>?
28 @Environment(\.scenePhase) private var scenePhase
29 @State private var libraryPage: LibraryPage?
30 @State private var selection: SkyObject?
31 @State private var details: BodyDetails?
32 @GestureState private var drag: CGSize = .zero
33 @GestureState private var zoom: CGFloat = 1
34 private let stats = DrawStats()
35
36 private var night: Bool { nightMode == "on" || (nightMode == "auto" && model.sunAltitude < -12) }
37 private var style: SkyStyle { night ? .night : SkyStyle() }
38 private var ink: Color { night ? SkyStyle.nightRed : Color(red: 0.95, green: 0.95, blue: 0.93) }
39
40 var body: some View {
41 GeometryReader { geo in
42 ZStack {
43 style.background.ignoresSafeArea()
44 if let scene = model.scene {
45 let view = current(width: geo.size.width)
46 SkyView(scene: scene, centerAzimuth: view.azimuth, centerAltitude: view.altitude, fieldOfView: view.fieldOfView, style: style, culture: Catalog.skyCultures.first { $0.id == skyCulture }, highlight: tour.flatMap { highlight($0.steps[tourStep].target) }, showDeepSky: showDeepSky) { [stats] d in
47 stats.record(d)
48 }
49 .ignoresSafeArea()
50 .gesture(panGesture(width: geo.size.width).simultaneously(with: zoomGesture))
51 .onTapGesture(coordinateSpace: .local) { location in
52 let p = Projection(centerAzimuth: view.azimuth, centerAltitude: view.altitude, fieldOfView: view.fieldOfView, size: geo.size)
53 select(scene.object(at: location, projection: p, deepSky: showDeepSky))
54 }
55 .accessibilityElement()
56 .accessibilityLabel(skyDescription(scene, azimuth: view.azimuth))
57 .accessibilityHint("Swipe up or down to turn. Planets and the Moon are listed in the Tonight summary.")
58 .accessibilityAdjustableAction { direction in
59 azimuth = (azimuth + (direction == .increment ? 45 : -45) + 360).truncatingRemainder(dividingBy: 360)
60 }
61 }
62 VStack(spacing: 0) {
63 header
64 if pointer.active {
65 Button { pointer.stop() } label: {
66 Label("Following your phone · Stop", systemImage: "iphone.gen3.radiowaves.left.and.right")
67 .font(.caption.weight(.medium))
68 .padding(.horizontal, 12).padding(.vertical, 8)
69 .glassEffect(in: .capsule)
70 }
71 .buttonStyle(.plain)
72 .padding(.top, 4)
73 }
74 if let selection { card(for: selection).padding(.horizontal, 16).padding(.top, 8) }
75 Spacer()
76 if let tour {
77 TourCard(tour: tour, step: $tourStep, belowHorizon: (tourTarget(tour.steps[tourStep].target)?.altitude ?? 0) < 0,
78 ink: ink, background: style.ground, onOpen: { libraryPage = $0 }, onEnd: { self.tour = nil })
79 } else if model.historicalYear == nil {
80 TodayBar(model: model, ink: ink, background: style.ground) { showToday = true }
81 }
82 }
83 }
84 }
85 .foregroundStyle(ink)
86 .environment(\.nightMode, night)
87 .sheet(isPresented: $showLog) {
88 LogScreen { newObservation("") }.preferredColorScheme(.dark).tint(.blue)
89 }
90 .sheet(item: $logEntry) { o in
91 ObservationEditor(observation: o) {}.preferredColorScheme(.dark).tint(.blue)
92 }
93 .sheet(isPresented: $showEvents) {
94 EventsScreen(observer: model.observer, placeName: model.placeName)
95 .preferredColorScheme(.dark)
96 }
97 .sheet(isPresented: $showTours) {
98 TourPicker { tour = $0; tourStep = 0; aim(at: $0.steps[0].target) }.preferredColorScheme(.dark)
99 }
100 .onChange(of: tourStep) { if let tour { aim(at: tour.steps[tourStep].target) } }
101 .sheet(isPresented: $showYear) {
102 YearSheet(year: $model.historicalYear, south: model.observer.latitude < 0).preferredColorScheme(.dark)
103 }
104 .sheet(isPresented: $showSettings, onDismiss: rescheduleSummary) {
105 SettingsScreen { model.applyLocationSettings() }
106 .preferredColorScheme(.dark)
107 .tint(.blue)
108 }
109 .sheet(item: $libraryPage) { page in
110 NavigationStack { LibraryPageView(page: page) }
111 }
112 .sheet(isPresented: $showToday) {
113 TodaySheet(model: model, ink: ink, background: style.ground)
114 .environment(\.nightMode, night)
115 .presentationDetents([.medium, .large])
116 }
117 .onChange(of: model.offsetHours) { select(selection) }
118 .onChange(of: onboarded) { if onboarded { model.start() } }
119 .onChange(of: night, initial: true) { nightActive = night }
120 .onChange(of: model.observer) { rescheduleSummary() }
121 .onChange(of: scenePhase) { if scenePhase == .active { rescheduleSummary() } }
122 .task {
123 if onboarded { model.start() }
124 while !Task.isCancelled {
125 try? await Task.sleep(for: .seconds(30))
126 model.refresh()
127 }
128 }
129 }
130
131 /// Where a tour target is in the current scene; constellations use the mean direction of their stars.
132 private func tourTarget(_ t: Tour.Target) -> Horizontal? {
133 guard let scene = model.scene else { return nil }
134 if let hr = t.hr { return scene.starsByHR[hr] }
135 if let b = t.body { return scene.bodies.first { $0.body.rawValue == b }?.position }
136 guard let c = t.constellation, let figure = Catalog.constellations.first(where: { $0.id == c }) else { return nil }
137 let positions = Set(figure.lines.joined()).compactMap { scene.starsByHR[$0] }
138 guard !positions.isEmpty else { return nil }
139 var v = SIMD3<Double>.zero
140 for h in positions {
141 let alt = h.altitude * .pi / 180, az = h.azimuth * .pi / 180
142 v += SIMD3(cos(alt) * sin(az), cos(alt) * cos(az), sin(alt))
143 }
144 var az = atan2(v.x, v.y) * 180 / .pi
145 if az < 0 { az += 360 }
146 return Horizontal(altitude: asin(v.z / (v * v).sum().squareRoot()) * 180 / .pi, azimuth: az)
147 }
148
149 private func highlight(_ t: Tour.Target) -> SkyHighlight? {
150 if let c = t.constellation { return .constellation(c) }
151 if let hr = t.hr { return .star(hr) }
152 if let b = t.body.flatMap(SkyBody.init(rawValue:)) { return .body(b) }
153 return nil
154 }
155
156 /// Pans the view to a tour target over a second, the short way round, keeping it at least
157 /// 10° up so the horizon stays in view. The Canvas redraws each frame, so the turn is stepped by hand.
158 private func aim(at t: Tour.Target) {
159 guard let h = tourTarget(t) else { return }
160 pointer.stop()
161 turning?.cancel()
162 let from = (az: azimuth, alt: altitude, fov: fieldOfView)
163 let dAz = (h.azimuth - from.az + 540).truncatingRemainder(dividingBy: 360) - 180
164 let to = (alt: max(h.altitude, 10), fov: 80.0)
165 turning = Task {
166 let frames = 60
167 for f in 1...frames {
168 try? await Task.sleep(for: .milliseconds(16))
169 if Task.isCancelled { return }
170 let x = Double(f) / Double(frames)
171 let e = x < 0.5 ? 4 * x * x * x : 1 - pow(-2 * x + 2, 3) / 2
172 azimuth = (from.az + dAz * e + 360).truncatingRemainder(dividingBy: 360)
173 altitude = from.alt + (to.alt - from.alt) * e
174 fieldOfView = from.fov + (to.fov - from.fov) * e
175 }
176 }
177 }
178
179 private func rescheduleSummary() {
180 Task { await DailySummaryNotifications.reschedule(model) }
181 }
182
183 private func select(_ object: SkyObject?) {
184 selection = object
185 details = nil
186 guard case .body(let body) = object else { return }
187 Task { details = await model.details(of: body) }
188 }
189
190 /// The view direction with any in-progress gestures applied.
191 private func current(width: CGFloat) -> (azimuth: Double, altitude: Double, fieldOfView: Double) {
192 let fov = min(max(fieldOfView / Double(zoom), 30), 140)
193 if pointer.active { return (pointer.direction.azimuth, pointer.direction.altitude, fov) }
194 let degreesPerPoint = fov / Double(max(width, 1))
195 let az = (azimuth - Double(drag.width) * degreesPerPoint).truncatingRemainder(dividingBy: 360)
196 let alt = min(max(altitude + Double(drag.height) * degreesPerPoint, 0), 90)
197 return ((az + 360).truncatingRemainder(dividingBy: 360), alt, fov)
198 }
199
200 private func panGesture(width: CGFloat) -> some Gesture {
201 DragGesture(minimumDistance: 6)
202 .onChanged { _ in
203 turning?.cancel()
204 if pointer.active {
205 azimuth = pointer.direction.azimuth
206 altitude = min(max(pointer.direction.altitude, 0), 90)
207 pointer.stop()
208 }
209 }
210 .updating($drag) { value, state, _ in state = value.translation }
211 .onEnded { value in
212 let degreesPerPoint = fieldOfView / Double(max(width, 1))
213 azimuth = ((azimuth - Double(value.translation.width) * degreesPerPoint).truncatingRemainder(dividingBy: 360) + 360).truncatingRemainder(dividingBy: 360)
214 altitude = min(max(altitude + Double(value.translation.height) * degreesPerPoint, 0), 90)
215 }
216 }
217
218 private var zoomGesture: some Gesture {
219 MagnifyGesture()
220 .updating($zoom) { value, state, _ in state = value.magnification }
221 .onEnded { value in fieldOfView = min(max(fieldOfView / Double(value.magnification), 30), 140) }
222 }
223
224 private var header: some View {
225 HStack(alignment: .top) {
226 VStack(alignment: .leading, spacing: 4) {
227 if let y = model.historicalYear {
228 Text("Sky in \(YearSheet.label(y))").font(.title2.weight(.medium))
229 if let p = model.poleStar {
230 Text(String(format: "Pole star: %@, %.1f° from the pole", p.name, p.degrees)).font(.caption2).dimmed(0.8)
231 }
232 Button("Back to today") { model.historicalYear = nil }.font(.caption.weight(.medium))
233 } else {
234 Text(model.offsetHours == 0 ? "Tonight" : "Sky at \(model.displayedDate.formatted(date: .omitted, time: .shortened))")
235 .font(.title2.weight(.medium))
236 }
237 Text(model.placeName)
238 .font(.caption2).dimmed(0.7)
239 #if DEBUG
240 TimelineView(.periodic(from: .now, by: 1)) { _ in
241 Text(stats.summary(build: model.lastBuild))
242 .font(.caption2.monospacedDigit()).dimmed(0.7)
243 }
244 #endif
245 }
246 Spacer()
247 Menu {
248 Section("Tonight") {
249 Button { showTours = true } label: { Label("Guided tours", systemImage: "figure.walk") }
250 Button { showEvents = true } label: { Label("Events and eclipses", systemImage: "calendar") }
251 if pointer.available {
252 Button { pointer.active ? pointer.stop() : pointer.start() } label: {
253 Label(pointer.active ? "Stop following the phone" : "Point the phone at the sky", systemImage: "iphone.gen3.radiowaves.left.and.right")
254 }
255 }
256 }
257 Section("Explore") {
258 Button { showYear = true } label: { Label("Sky in another year", systemImage: "hourglass") }
259 Button { showLog = true } label: { Label("Observing log", systemImage: "book.closed") }
260 }
261 Section {
262 Button { nightMode = night ? "off" : "on" } label: {
263 Label(night ? "Turn off night mode" : "Night mode (red light)", systemImage: night ? "eye.slash" : "eye")
264 }
265 Button { showSettings = true } label: { Label("Settings", systemImage: "gearshape") }
266 }
267 } label: {
268 Label("Explore", systemImage: "sparkle.magnifyingglass")
269 .font(.subheadline.weight(.medium))
270 .padding(.horizontal, 14)
271 .frame(minHeight: 44)
272 .glassEffect(in: .capsule)
273 }
274 .accessibilityHint("Tours, events, night mode, settings and more")
275 }
276 .padding(.horizontal, 20)
277 .padding(.top, 8)
278 }
279
280 @ViewBuilder
281 private func card(for object: SkyObject) -> some View {
282 VStack(alignment: .leading, spacing: 8) {
283 HStack {
284 Text(title(for: object)).font(.title3.weight(.medium))
285 Spacer()
286 Button { selection = nil } label: { Image(systemName: "xmark").frame(width: 32, height: 32) }
287 .accessibilityLabel("Close")
288 }
289 switch object {
290 case .body:
291 if let d = details { bodyFacts(d) } else { ProgressView().tint(ink) }
292 case .constellation(let id):
293 Text("Constellation · \(id)").font(.footnote).dimmed(0.7)
294 case .deepSky(let m):
295 if let d = model.scene?.deepSky.first(where: { $0.object.messier == m }) { deepSkyFacts(d) }
296 }
297 HStack(spacing: 20) {
298 Button { logEntry = newObservation(title(for: object)) } label: {
299 Label("Log", systemImage: "square.and.pencil").font(.footnote.weight(.medium))
300 }
301 .buttonStyle(.plain)
302 .frame(minHeight: 44)
303 if let page = libraryPage(for: object) {
304 Button { libraryPage = page } label: {
305 Label("Read more", systemImage: "book").font(.footnote.weight(.medium))
306 }
307 .buttonStyle(.plain)
308 .frame(minHeight: 44)
309 }
310 }
311 }
312 .padding(16)
313 .background(style.background.opacity(0.92), in: .rect(cornerRadius: 12))
314 .overlay(RoundedRectangle(cornerRadius: 12).stroke(ink.opacity(0.25), lineWidth: 0.5))
315 }
316
317 private func newObservation(_ object: String) -> Observation {
318 Observation(object: object, date: model.displayedDate, place: model.placeName == "Your location" ? "" : model.placeName,
319 latitude: model.observer.latitude, longitude: model.observer.longitude)
320 }
321
322 private func libraryPage(for object: SkyObject) -> LibraryPage? {
323 switch object {
324 case .body(let b): Library.page(body: b.rawValue)
325 case .constellation(let id): Library.page(constellation: id)
326 case .deepSky(let m): Catalog.deepSky.first { $0.messier == m }.flatMap { Library.page(id: $0.page) }
327 }
328 }
329
330 private func title(for object: SkyObject) -> String {
331 switch object {
332 case .body(let b): b.rawValue.capitalized
333 case .constellation(let id): constellationNames[id] ?? id
334 case .deepSky(let m): "M\(m)"
335 }
336 }
337
338 private func bodyFacts(_ d: BodyDetails) -> some View {
339 let time: (Date?) -> String = { $0?.formatted(date: .omitted, time: .shortened) ?? "–" }
340 let distance = d.body == .moon
341 ? "\(Int((d.distanceAU * 149_597_870.7).rounded()).formatted()) km"
342 : String(format: "%.2f AU", d.distanceAU)
343 let rows: [(String, String)] = [
344 ("Altitude", String(format: "%.0f°", d.position.altitude)),
345 ("Direction", String(format: "%.0f° %@", d.position.azimuth, compass(d.position.azimuth))),
346 ("Magnitude", String(format: "%.1f", d.magnitude)),
347 (d.body == .moon ? "Illuminated" : "Phase", String(format: "%.0f%%", d.phaseFraction * 100)),
348 ("Distance", distance),
349 ("Next rise", time(d.rise)),
350 ("Next set", time(d.set)),
351 ]
352 return Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 4) {
353 ForEach(rows, id: \.0) { row in
354 GridRow {
355 Text(row.0).dimmed(0.7)
356 Text(row.1).monospacedDigit()
357 }
358 .font(.footnote)
359 }
360 }
361 }
362
363 private func deepSkyFacts(_ d: PlacedDeepSky) -> some View {
364 let o = d.object
365 var rows: [(String, String)] = [
366 ("Altitude", String(format: "%.0f°", d.position.altitude)),
367 ("Direction", String(format: "%.0f° %@", d.position.azimuth, compass(d.position.azimuth))),
368 ]
369 if let m = o.magnitude { rows.append(("Magnitude", String(format: "%.1f", m))) }
370 if let a = o.majorAxis {
371 let b = o.minorAxis.map { String(format: " × %.0f", $0) } ?? ""
372 rows.append(("Size", String(format: "%.0f%@′", a, b)))
373 }
374 return VStack(alignment: .leading, spacing: 8) {
375 if let page = Library.page(id: o.page) { Text(page.subtitle).font(.footnote).dimmed(0.7) }
376 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 4) {
377 ForEach(rows, id: \.0) { row in
378 GridRow {
379 Text(row.0).dimmed(0.7)
380 Text(row.1).monospacedDigit()
381 }
382 .font(.footnote)
383 }
384 }
385 }
386 }
387
388 /// VoiceOver summary: which way the view faces and what bright bodies are up.
389 private func skyDescription(_ scene: SkyScene, azimuth: Double) -> String {
390 let up = scene.bodies.filter { $0.position.altitude > 0 && $0.magnitude <= 6 }.map { b -> String in
391 let name = b.body.rawValue.capitalized
392 return "\(name), \(Int(b.position.altitude.rounded())) degrees up in the \(compass(b.position.azimuth))"
393 }
394 let facing = "Sky facing \(compass(azimuth))."
395 return up.isEmpty ? facing + " No planets or Moon above the horizon." : facing + " Above the horizon: " + up.joined(separator: "; ") + "."
396 }
397
398 private func compass(_ azimuth: Double) -> String {
399 let points = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
400 return points[Int(((azimuth + 22.5) / 45).rounded(.down)) % 8]
401 }
402}
403
404/// Draw timings for the debug readout. Written from the Canvas closure, read once a second.
405final class DrawStats: @unchecked Sendable {
406 private let lock = NSLock()
407 private var samples: [Duration] = []
408
409 func record(_ d: Duration) {
410 lock.withLock {
411 samples.append(d)
412 if samples.count > 120 { samples.removeFirst(samples.count - 120) }
413 }
414 }
415
416 func summary(build: Duration) -> String {
417 let s = lock.withLock { samples }
418 let ms = s.map { Double($0.components.attoseconds) / 1e15 + Double($0.components.seconds) * 1000 }.sorted()
419 guard let median = ms.isEmpty ? nil : ms[ms.count / 2], let worst = ms.last else { return "draw –" }
420 let b = Double(build.components.attoseconds) / 1e15 + Double(build.components.seconds) * 1000
421 return String(format: "draw %.1f ms median, %.1f ms max · scene %.0f ms", median, worst, b)
422 }
423}
424
425/// SkyEngine's Body, named so it isn't shadowed by View.Body.
426private typealias SkyBody = Body