App/Sources/LiveSection.swift
138 lines · 7128 bytes
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}