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/LiveSection.swift

main
moonrise/App/Sources/LiveSection.swift history · blame · raw

138 lines · 7128 bytes

7 symbols in this file
  1import ActivityKit
  2import Feeds
  3import SkyEngine
  4import SwiftUI
  5
  6/// A mission being covered live: where it is between Earth and the Moon, its phase, and a Lock Screen follow.
  7/// Shown only while the backend reports an event. In debug builds, the user default "liveReplayAt"
  8/// (ISO 8601) replays a past event at that moment.
  9struct LiveSection: View {
 10    var ink: Color
 11    var accent: Color
 12    @State private var coverage: LiveCoverage?
 13    @State private var following = false
 14
 15    private var replayAt: Date? {
 16        #if DEBUG
 17        UserDefaults.standard.string(forKey: "liveReplayAt").flatMap { try? Date($0, strategy: .iso8601) }
 18        #else
 19        nil
 20        #endif
 21    }
 22
 23    var body: some View {
 24        // A VStack rather than a Group: an empty Group has no view for .task to attach to.
 25        VStack(alignment: .leading, spacing: 0) {
 26            if let c = coverage {
 27                VStack(alignment: .leading, spacing: 10) {
 28                    HStack {
 29                        Text(replayAt == nil ? "LIVE" : "REPLAY").font(.caption2.weight(.bold)).tracking(0.8)
 30                            .padding(.horizontal, 6).padding(.vertical, 2)
 31                            .background(accent, in: RoundedRectangle(cornerRadius: 3))
 32                        Text(c.event.title).font(.title3.weight(.medium))
 33                    }
 34                    EarthMoonMap(coverage: c, ink: ink, accent: accent)
 35                        .aspectRatio(1, contentMode: .fit)
 36                        .accessibilityLabel("Map of \(c.event.title) between Earth and the Moon")
 37                    facts(c)
 38                    if replayAt == nil {
 39                        Button(following ? "Following on the Lock Screen" : "Follow on the Lock Screen") { follow(c) }
 40                            .disabled(following)
 41                            .font(.subheadline.weight(.medium))
 42                    }
 43                    Text("Trajectory: NASA/JPL Horizons, every 30 minutes.").font(.caption2).dimmed(0.5)
 44                }
 45                .padding(.bottom, 24)
 46            }
 47        }
 48        .task {
 49            while !Task.isCancelled {
 50                if let apiBase {
 51                    do {
 52                        let c = try await LiveCoverage.fetch(base: apiBase, at: replayAt)
 53                        if let c {
 54                            coverage = c
 55                            await update(c)
 56                        }
 57                    } catch {
 58                        log.error("live: \(error.localizedDescription, privacy: .public)")
 59                    }
 60                }
 61                try? await Task.sleep(for: .seconds(600))
 62            }
 63        }
 64    }
 65
 66    private func facts(_ c: LiveCoverage) -> some View {
 67        let moon = SkyEngine.geocentricMoonEcliptic(at: c.at)
 68        let fromMoon = c.points.last.map { p in ((p.position - moon) * (p.position - moon)).sum().squareRoot() }
 69        let elapsed = c.at.timeIntervalSince(c.event.launch)
 70        return Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
 71            GridRow { Text("Mission time").dimmed(0.7); Text("\(Int(elapsed) / 86_400) d \(Int(elapsed) % 86_400 / 3600) h \(Int(elapsed) % 3600 / 60) m") }
 72            if let p = c.phase.current { GridRow { Text("Phase").dimmed(0.7); Text(p.label) } }
 73            if let n = c.phase.next { GridRow { Text("Next").dimmed(0.7); Text("\(n.label), \(n.at.formatted(date: .abbreviated, time: .shortened))") } }
 74            if let d = c.distanceKm { GridRow { Text("From Earth's centre").dimmed(0.7); Text("\(Int(d).formatted()) km") } }
 75            if let d = fromMoon { GridRow { Text("From the Moon's centre").dimmed(0.7); Text("\(Int(d).formatted()) km") } }
 76        }
 77        .font(.subheadline.monospacedDigit())
 78    }
 79
 80    private func state(_ c: LiveCoverage) -> EventActivityAttributes.ContentState {
 81        EventActivityAttributes.ContentState(phase: c.phase.current?.label ?? "", next: c.phase.next?.label, nextAt: c.phase.next?.at, distanceKm: c.distanceKm ?? 0)
 82    }
 83
 84    private func follow(_ c: LiveCoverage) {
 85        let attributes = EventActivityAttributes(eventID: c.event.id, title: c.event.title, launch: c.event.launch)
 86        _ = try? Activity.request(attributes: attributes, content: .init(state: state(c), staleDate: .now.addingTimeInterval(1800)))
 87        following = true
 88    }
 89
 90    /// Updates a followed activity while the app runs; ends it after the event.
 91    private func update(_ c: LiveCoverage) async {
 92        for a in Activity<EventActivityAttributes>.activities where a.attributes.eventID == c.event.id {
 93            following = true
 94            if c.at > c.event.end {
 95                await a.end(nil, dismissalPolicy: .default)
 96            } else {
 97                await a.update(.init(state: state(c), staleDate: .now.addingTimeInterval(1800)))
 98            }
 99        }
100    }
101}
102
103/// Earth at the centre, the Moon's orbit and position, and the trajectory so far, on the ecliptic plane.
104struct EarthMoonMap: View {
105    var coverage: LiveCoverage
106    var ink: Color
107    var accent: Color
108
109    var body: some View {
110        Canvas { context, size in
111            let moon = SkyEngine.geocentricMoonEcliptic(at: coverage.at)
112            let moonDistance = (moon * moon).sum().squareRoot()
113            let farthest = coverage.points.map { ($0.position * $0.position).sum().squareRoot() }.max() ?? 0
114            let scale = min(size.width, size.height) / 2 / (max(farthest, moonDistance) * 1.08)
115            let c = CGPoint(x: size.width / 2, y: size.height / 2)
116            func pt(_ v: SIMD3<Double>) -> CGPoint { CGPoint(x: c.x + v.x * scale, y: c.y - v.y * scale) }
117
118            let r = moonDistance * scale
119            context.stroke(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)), with: .color(ink.opacity(0.2)), style: StrokeStyle(lineWidth: 0.5, dash: [3, 4]))
120            context.fill(Path(ellipseIn: CGRect(x: c.x - 5, y: c.y - 5, width: 10, height: 10)), with: .color(Color(red: 0.35, green: 0.6, blue: 0.95)))
121            context.draw(Text("Earth").font(.caption2).foregroundStyle(ink.opacity(0.7)), at: CGPoint(x: c.x, y: c.y + 14))
122
123            let m = pt(moon)
124            context.fill(Path(ellipseIn: CGRect(x: m.x - 4, y: m.y - 4, width: 8, height: 8)), with: .color(ink))
125            context.draw(Text("Moon").font(.caption2).foregroundStyle(ink.opacity(0.7)), at: CGPoint(x: m.x, y: m.y + 13))
126
127            if let first = coverage.points.first, let last = coverage.points.last {
128                var path = Path()
129                path.move(to: pt(first.position))
130                for p in coverage.points.dropFirst() { path.addLine(to: pt(p.position)) }
131                context.stroke(path, with: .color(accent.opacity(0.8)), lineWidth: 1.2)
132                let q = pt(last.position)
133                context.fill(Path(ellipseIn: CGRect(x: q.x - 4, y: q.y - 4, width: 8, height: 8)), with: .color(accent))
134                context.draw(Text(coverage.event.title).font(.caption2).foregroundStyle(accent), at: CGPoint(x: q.x + 7, y: q.y), anchor: .leading)
135            }
136        }
137    }
138}