App/Sources/EclipseScreen.swift
331 lines · 18204 bytes
1import ActivityKit
2import SkyEngine
3import SkyRenderer
4import SwiftUI
5
6/// Map of a total or annular eclipse's path, with timings at greatest eclipse and for the observer.
7struct EclipseScreen: View {
8 var date: Date
9 var observer: Observer
10 var placeName: String
11 @State private var path: EclipsePath?
12 @State private var local: LocalEclipse?
13 @State private var timeline: EclipseTimeline?
14 @State private var loaded = false
15 @State private var location = EclipseLocation()
16 /// Time zone at the site, from Apple Maps; times are also given in UTC, and only in UTC when this is unknown.
17 @State private var zone: TimeZone?
18 @State private var city: String?
19 @State private var day: Day?
20 @State private var activityNote: String?
21 @AppStorage(SettingsKey.locationMode) private var locationMode = "current"
22 @Environment(\.dismiss) private var dismiss
23
24 /// The latest precise fix, or the place the screen was opened for (a chosen city, or the last coarse fix).
25 private var site: Observer { locationMode == "city" ? observer : location.fix?.observer ?? observer }
26
27 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
28 private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
29
30 private enum Day: Identifiable {
31 case live, rehearsal
32 var id: Self { self }
33 }
34
35 var body: some View {
36 NavigationStack {
37 ScrollView {
38 VStack(alignment: .leading, spacing: 16) {
39 if let path {
40 EclipseMap(path: path, observer: site, ink: ink, accent: accent)
41 .aspectRatio(1.6, contentMode: .fit)
42 .accessibilityLabel("Map of the path of the eclipse")
43 facts(path)
44 } else if loaded {
45 Text("This eclipse is partial everywhere, so there is no path to map.").font(.footnote).dimmed(0.7)
46 } else {
47 ProgressView().tint(ink).frame(maxWidth: .infinity, minHeight: 200)
48 }
49 if loaded { here }
50 if let timeline {
51 SunDiagram(timeline: timeline, ink: ink, accent: accent)
52 dayTools(timeline)
53 }
54 }
55 .padding(20)
56 .readableWidth()
57 }
58 .background(Color.black.ignoresSafeArea())
59 .foregroundStyle(ink)
60 .navigationTitle(path.map { "\($0.kind) solar eclipse" } ?? "Solar eclipse")
61 .navigationBarTitleDisplayMode(.inline)
62 .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
63 .fullScreenCover(item: $day) { d in
64 EclipseDayScreen(date: date, observer: observer, placeName: placeName, zone: zone, rehearsal: d == .rehearsal, location: location)
65 }
66 }
67 .task {
68 let date = date
69 path = await Task.detached { EclipsePath.compute(near: date) }.value
70 loaded = true
71 }
72 .task(id: site) {
73 let date = date, site = site, accuracy = locationMode == "city" ? nil : location.fix?.accuracy
74 (local, timeline) = await Task.detached {
75 let l = LocalEclipse.at(site, near: date)
76 return (l, l.flatMap { try? EclipseTimeline($0, observer: site, accuracy: accuracy) })
77 }.value
78 }
79 .task(id: [(site.latitude * 10).rounded(), (site.longitude * 10).rounded()]) {
80 if let s = await EclipseLocation.site(at: site) { (zone, city) = s }
81 }
82 .onAppear { if locationMode != "city" { location.start() } }
83 .onDisappear { if locationMode != "city" { location.stop() } }
84 }
85
86 private func facts(_ p: EclipsePath) -> some View {
87 let middle = p.points.min { abs($0.date.timeIntervalSince(p.peak)) < abs($1.date.timeIntervalSince(p.peak)) }
88 var rows: [(String, String)] = [
89 ("Date", p.peak.formatted(Date.FormatStyle(date: .long, time: .omitted, timeZone: .gmt))),
90 ("Greatest eclipse", "\(p.peak.formatted(Date.FormatStyle(date: .omitted, time: .shortened, timeZone: .gmt))) UTC at \(latLon(p.greatest))"),
91 ]
92 if let d = p.centralDuration { rows.append(("\(p.kind == "Total" ? "Totality" : "Annularity") there", minutes(d))) }
93 if let m = middle { rows.append(("Path width there", "\(Int(m.widthKm.rounded())) km")) }
94 return Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
95 ForEach(rows, id: \.0) { r in
96 GridRow { Text(r.0).dimmed(0.7); Text(r.1).monospacedDigit() }.font(.subheadline)
97 }
98 }
99 }
100
101 @ViewBuilder
102 private var here: some View {
103 VStack(alignment: .leading, spacing: 6) {
104 Text(placeLabel).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
105 if let l = local {
106 let central = l.kind == "Total" ? "Totality" : "Annular phase"
107 let zones = [zone, .gmt].compactMap { $0 }
108 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
109 GridRow {
110 Text("")
111 if let zone { Text("Local, \(utcOffset(zone, at: l.peak))").dimmed(0.6) }
112 Text("UTC").dimmed(0.6)
113 }
114 .font(.caption)
115 GridRow { Text("Begins").dimmed(0.7); ForEach(zones, id: \.self) { Text(time(l.partialBegin, $0)) } }
116 if let b = l.centralBegin, let e = l.centralEnd {
117 GridRow { Text("\(central) begins").dimmed(0.7); ForEach(zones, id: \.self) { Text(time(b, $0)) } }
118 GridRow { Text("\(central) ends").dimmed(0.7); ForEach(zones, id: \.self) { Text(time(e, $0)) } }
119 }
120 GridRow { Text("Maximum").dimmed(0.7); ForEach(zones, id: \.self) { Text(time(l.peak, $0)) } }
121 GridRow { Text("Ends").dimmed(0.7); ForEach(zones, id: \.self) { Text(time(l.partialEnd, $0)) } }
122 }
123 .font(.subheadline.monospacedDigit())
124 if let b = l.centralBegin, let e = l.centralEnd {
125 Text("\(central) lasts \(minutes(e.timeIntervalSince(b))).").font(.subheadline)
126 }
127 Text("\(Int((l.obscuration * 100).rounded()))% of the Sun covered at maximum.").font(.subheadline)
128 if zone == nil {
129 Text("Local time at this site needs a connection to look up, so times are in UTC.").font(.footnote).dimmed(0.7)
130 }
131 if l.peakAltitude < 10 {
132 Text("The Sun is low at maximum (\(Int(l.peakAltitude.rounded()))°). Find a clear horizon.").font(.footnote).dimmed(0.7)
133 }
134 } else {
135 Text("Not visible from here.").font(.subheadline).dimmed(0.7)
136 }
137 Text("Never look at the Sun without certified eclipse glasses, except during totality itself.")
138 .font(.footnote).foregroundStyle(accent).padding(.top, 6)
139 }
140 }
141
142 private func dayTools(_ t: EclipseTimeline) -> some View {
143 VStack(alignment: .leading, spacing: 10) {
144 Text("Eclipse day").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
145 if t.eclipse.partialEnd > .now {
146 Button("Start the countdown") { day = .live }
147 Button("Show contact times on the Lock Screen") { scheduleActivity(t) }
148 }
149 Button("Rehearse from three minutes before \(t.eclipse.kind == "Total" ? "totality" : "maximum")") { day = .rehearsal }
150 if let activityNote { Text(activityNote).font(.footnote).dimmed(0.7) }
151 Text("The countdown speaks each cue and taps at the contacts while Moonrise is open, and keeps the screen on.")
152 .font(.footnote).dimmed(0.7)
153 }
154 .tint(accent)
155 }
156
157 /// Adds a Live Activity that appears 30 minutes before first contact, or now if that has passed.
158 private func scheduleActivity(_ t: EclipseTimeline) {
159 let e = t.eclipse
160 let attributes = EclipseActivityAttributes(
161 kind: e.kind, site: locationMode == "city" ? placeName : city ?? "your location",
162 c1: e.partialBegin, c2: e.centralBegin, peak: e.peak, c3: e.centralEnd, c4: e.partialEnd, timeZone: zone?.identifier
163 )
164 let content = ActivityContent(state: EclipseActivityAttributes.ContentState(), staleDate: e.partialEnd)
165 let start = e.partialBegin.addingTimeInterval(-1800)
166 let clock = { (d: Date, date: Date.FormatStyle.DateStyle) in
167 d.formatted(Date.FormatStyle(date: date, time: .shortened, timeZone: zone ?? .gmt)) + (zone == nil ? " UTC" : "")
168 }
169 Task {
170 for a in Activity<EclipseActivityAttributes>.activities { await a.end(nil, dismissalPolicy: .immediate) }
171 do {
172 if start > .now {
173 let alert = AlertConfiguration(title: "\(e.kind) eclipse today", body: "First contact at \(clock(e.partialBegin, .omitted)).", sound: .default)
174 _ = try Activity.request(attributes: attributes, content: content, style: .standard, alertConfiguration: alert, start: start)
175 activityNote = "It will appear on the Lock Screen on \(clock(start, .abbreviated)), 30 minutes before first contact."
176 } else {
177 _ = try Activity.request(attributes: attributes, content: content)
178 activityNote = "Contact times are on the Lock Screen."
179 }
180 } catch {
181 activityNote = "Could not add it to the Lock Screen: \(error.localizedDescription)"
182 }
183 }
184 }
185
186 private var placeLabel: String {
187 if locationMode != "city", let fix = location.fix { return "From your location, ±\(Int(fix.accuracy.rounded())) m" }
188 return "From \(placeName)"
189 }
190
191 /// To the nearest second in `zone`.
192 private func time(_ d: Date, _ zone: TimeZone) -> String {
193 Date(timeIntervalSinceReferenceDate: d.timeIntervalSinceReferenceDate.rounded()).formatted(Date.FormatStyle(date: .omitted, time: .standard, timeZone: zone))
194 }
195
196 private func minutes(_ s: TimeInterval) -> String { "\(Int(s) / 60) m \(Int(s) % 60) s" }
197
198 private func latLon(_ p: (lat: Double, lon: Double)) -> String {
199 String(format: "%.1f°%@, %.1f°%@", abs(p.lat), p.lat >= 0 ? "N" : "S", abs(p.lon), p.lon >= 0 ? "E" : "W")
200 }
201}
202
203/// Equirectangular map around the path: coastlines, the band between the limits, and the centre line.
204struct EclipseMap: View {
205 var path: EclipsePath
206 var observer: Observer
207 var ink: Color
208 var accent: Color
209
210 var body: some View {
211 Canvas { context, size in
212 let centers = path.points.map(\.center)
213 let lats = centers.map(\.lat), lons = centers.map(\.lon)
214 let latRange = (lats.min()! - 8)...(lats.max()! + 8)
215 let lonRange = (lons.min()! - 10)...(lons.max()! + 10)
216 func pt(_ lat: Double, _ lon: Double) -> CGPoint {
217 CGPoint(x: (lon - lonRange.lowerBound) / (lonRange.upperBound - lonRange.lowerBound) * size.width,
218 y: (latRange.upperBound - lat) / (latRange.upperBound - latRange.lowerBound) * size.height)
219 }
220
221 context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(ink.opacity(0.04)))
222 var grid = Path()
223 for lat in stride(from: -80.0, through: 80, by: 10) where latRange.contains(lat) {
224 grid.move(to: pt(lat, lonRange.lowerBound)); grid.addLine(to: pt(lat, lonRange.upperBound))
225 }
226 for lon in stride(from: -180.0, through: 180, by: 10) where lonRange.contains(lon) {
227 grid.move(to: pt(latRange.lowerBound, lon)); grid.addLine(to: pt(latRange.upperBound, lon))
228 }
229 context.stroke(grid, with: .color(ink.opacity(0.08)), lineWidth: 0.5)
230
231 var coast = Path()
232 for line in Coastline.lines {
233 var pen = false
234 for p in line {
235 let q = pt(p.lat, p.lon)
236 if pen { coast.addLine(to: q) } else { coast.move(to: q); pen = true }
237 }
238 }
239 context.stroke(coast, with: .color(ink.opacity(0.55)), lineWidth: 0.8)
240
241 let limits = path.points.compactMap { p in p.north.flatMap { n in p.south.map { (n, $0) } } }
242 if limits.count > 1 {
243 var band = Path()
244 band.move(to: pt(limits[0].0.lat, limits[0].0.lon))
245 for l in limits.dropFirst() { band.addLine(to: pt(l.0.lat, l.0.lon)) }
246 for l in limits.reversed() { band.addLine(to: pt(l.1.lat, l.1.lon)) }
247 band.closeSubpath()
248 context.fill(band, with: .color(accent.opacity(0.4)))
249 }
250 var center = Path()
251 center.addLines(centers.map { pt($0.lat, $0.lon) })
252 context.stroke(center, with: .color(accent), lineWidth: 1.2)
253
254 let g = pt(path.greatest.lat, path.greatest.lon)
255 context.stroke(Path(ellipseIn: CGRect(x: g.x - 5, y: g.y - 5, width: 10, height: 10)), with: .color(ink), lineWidth: 1.5)
256 if latRange.contains(observer.latitude), lonRange.contains(observer.longitude) {
257 let o = pt(observer.latitude, observer.longitude)
258 context.fill(Path(ellipseIn: CGRect(x: o.x - 4, y: o.y - 4, width: 8, height: 8)), with: .color(ink))
259 context.draw(Text("You").font(.caption2).foregroundStyle(ink), at: CGPoint(x: o.x, y: o.y - 11))
260 }
261 }
262 .overlay(alignment: .bottomLeading) {
263 Text("Shaded: where the eclipse is \(path.kind.lowercased()). Ring: greatest eclipse.")
264 .font(.caption2).dimmed(0.7).padding(6)
265 }
266 }
267}
268
269/// The Sun's path across the sky from first to last contact: altitude against compass bearing,
270/// with a line every fist width (about 10° at arm's length) above the horizon.
271struct SunDiagram: View {
272 var timeline: EclipseTimeline
273 var ink: Color
274 var accent: Color
275
276 var body: some View {
277 let path = (try? timeline.sunPath(every: 300)) ?? []
278 let key = timeline.contacts.first { $0.name == (timeline.eclipse.kind == "Total" ? "C2" : "Max") } ?? timeline.contacts[0]
279 VStack(alignment: .leading, spacing: 8) {
280 Canvas { context, size in draw(path, context: &context, size: size) }
281 .frame(height: 180)
282 .accessibilityHidden(true)
283 Text("At \(timeline.eclipse.kind == "Total" ? "totality" : "maximum") the Sun is \(Int(key.sun.altitude.rounded()))° up, about \(Int((key.sun.altitude / 10).rounded())) fists above the horizon at arm's length, bearing \(Int(key.sun.azimuth.rounded()))° \(compass(key.sun.azimuth)). Check that no trees or buildings will be in the way.")
284 .font(.footnote).dimmed(0.8)
285 }
286 }
287
288 private func draw(_ samples: [(date: Date, sun: Horizontal)], context: inout GraphicsContext, size: CGSize) {
289 guard let first = samples.first else { return }
290 // Azimuths unwrapped so a path through north stays continuous.
291 func unwrap(_ a: Double) -> Double { first.sun.azimuth + remainder(a - first.sun.azimuth, 360) }
292 let az = samples.map { unwrap($0.sun.azimuth) }
293 let mid = (az.min()! + az.max()!) / 2, half = max(45, (az.max()! - az.min()!) / 2 + 20)
294 let top = min(90, ((samples.map(\.sun.altitude).max() ?? 0) / 10).rounded(.up) * 10 + 10)
295 let base = size.height - 16
296 func pt(_ alt: Double, _ azimuth: Double) -> CGPoint {
297 CGPoint(x: (azimuth - (mid - half)) / (2 * half) * size.width, y: base - max(alt, 0) / top * (base - 8))
298 }
299
300 var grid = Path()
301 for alt in stride(from: 10.0, through: top, by: 10) {
302 grid.move(to: pt(alt, mid - half)); grid.addLine(to: pt(alt, mid + half))
303 }
304 context.stroke(grid, with: .color(ink.opacity(0.12)), style: StrokeStyle(lineWidth: 0.5, dash: [2, 3]))
305 for alt in stride(from: 20.0, through: top, by: 20) {
306 context.draw(Text("\(Int(alt))°").font(.system(size: 9)).foregroundStyle(ink.opacity(0.5)), at: pt(alt, mid - half), anchor: .bottomLeading)
307 }
308 var horizon = Path()
309 horizon.move(to: CGPoint(x: 0, y: base)); horizon.addLine(to: CGPoint(x: size.width, y: base))
310 context.stroke(horizon, with: .color(ink.opacity(0.6)), lineWidth: 1)
311 for bearing in stride(from: ((mid - half) / 45).rounded(.up) * 45, through: mid + half, by: 45) {
312 context.draw(Text(compass(bearing)).font(.system(size: 10)).foregroundStyle(ink.opacity(0.7)), at: CGPoint(x: pt(0, bearing).x, y: base + 8))
313 }
314
315 var arc = Path()
316 arc.addLines(zip(samples, az).map { pt($0.0.sun.altitude, $0.1) })
317 context.stroke(arc, with: .color(ink.opacity(0.5)), lineWidth: 1)
318 for c in timeline.contacts where c.name == "C1" || c.name == "C4" || c.name == (timeline.eclipse.kind == "Total" ? "C2" : "Max") {
319 let p = pt(c.sun.altitude, unwrap(c.sun.azimuth))
320 let key = c.name == "C2" || c.name == "Max"
321 context.fill(Path(ellipseIn: CGRect(x: p.x - 4, y: p.y - 4, width: 8, height: 8)), with: .color(key ? accent : ink))
322 context.draw(Text(c.name == "C2" ? "Totality" : c.name).font(.system(size: 10)).foregroundStyle(ink), at: CGPoint(x: p.x, y: p.y - 11))
323 }
324 }
325
326 private func compass(_ azimuth: Double) -> String {
327 let points = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"]
328 let a = (azimuth.truncatingRemainder(dividingBy: 360) + 360).truncatingRemainder(dividingBy: 360)
329 return points[Int(((a + 11.25) / 22.5).rounded(.down)) % 16]
330 }
331}