krz/moonrise

Night-sky companion for iPhone, Apple Watch and Vision Pro: what's up tonight, the Moon, planets, launches and missions, and a Library of the solar system. SwiftUI app with a Cloudflare Workers backend. astronomy cloudflare-workers ios night-sky space swiftui visionos watchos

App/Sources/SkyScreen.swift

6c4b28384d65e09486e0ad62f2ac9d10401cc80c
moonrise/App/Sources/SkyScreen.swift history · blame · raw

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