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

Commit b50c42a904

b50c42a90488f58376206452ab725daea85dbd24

parent: dda3950a2e

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-19 21:51 UTC

Eclipse day: cue script, countdown, rehearsal, sun diagram, Live Activity

- EclipseTimeline (SkyEngine): contacts with the Sun's altitude and
  azimuth, and the spoken cue script. Glasses-off cue at C2 only with
  computed totality of at least 30 s and a fix better than 100 m, worded
  to wait for the last bead; glasses-on at C3 - 10 s; partial and annular
  sites keep glasses on throughout. Shadow-band and Baily's beads lines.
- Eclipse-day screen: countdown to the next contact, cues spoken with
  AVSpeechSynthesizer and Core Haptics while in the foreground, idle
  timer off. Rehearsal runs it on a clock shifted to C2 - 3 min.
- Sun path diagram from C1 to C4 with bearing and altitude in fists.
- Contact-time Live Activity scheduled for C1 - 30 min with the iOS 26
  start date; timer text only, no updates needed.

Layout: unified · split

App/Shared/EclipseActivity.swift added +19
@@ -0,0 +1,19 @@
1import ActivityKit
2import Foundation
3
4/// Live Activity for eclipse contacts at one site. Everything is fixed at the start and drawn with
5/// timer text, so it counts down correctly without updates. Shared by the app and the widget extension.
6struct EclipseActivityAttributes: ActivityAttributes {
7 struct ContentState: Codable, Hashable {}
8
9 /// "Total", "Annular" or "Partial".
10 var kind: String
11 var site: String
12 var c1: Date
13 var c2: Date?
14 var peak: Date
15 var c3: Date?
16 var c4: Date
17 /// Time zone identifier at the site; clock times are in UTC without one.
18 var timeZone: String?
19}
App/Sources/EclipseDayScreen.swift added +229
@@ -0,0 +1,229 @@
1import AVFoundation
2import CoreHaptics
3import SkyEngine
4import SwiftUI
5
6/// Eclipse-day countdown: next contact, the cue script spoken with haptics, and the Sun's position.
7/// Runs in the foreground with the screen kept awake. Rehearsal runs the same code on a clock set
8/// to three minutes before totality.
9struct EclipseDayScreen: View {
10 var date: Date
11 var observer: Observer
12 var placeName: String
13 var zone: TimeZone?
14 var rehearsal: Bool
15 var location: EclipseLocation
16 @State private var timeline: EclipseTimeline?
17 /// Added to the real clock; set once in rehearsal.
18 @State private var shift: TimeInterval?
19 @State private var player = CuePlayer()
20 @State private var spoken: EclipseTimeline.Cue?
21 @AppStorage(SettingsKey.locationMode) private var locationMode = "current"
22 @Environment(\.dismiss) private var dismiss
23
24 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
25 private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
26
27 private var fix: EclipseLocation.Fix? { locationMode == "city" ? nil : location.fix }
28 private var site: Observer { fix?.observer ?? observer }
29
30 private func now() -> Date { Date.now.addingTimeInterval(shift ?? 0) }
31
32 var body: some View {
33 NavigationStack {
34 ScrollView {
35 VStack(alignment: .leading, spacing: 20) {
36 if let timeline {
37 if rehearsal {
38 Text("Rehearsal. The clock is running from three minutes before \(timeline.eclipse.kind == "Total" ? "totality" : "maximum").")
39 .font(.footnote.weight(.medium)).foregroundStyle(accent)
40 }
41 TimelineView(.periodic(from: .now, by: 0.1)) { _ in countdown(timeline, at: now()) }
42 contacts(timeline)
43 SunDiagram(timeline: timeline, ink: ink, accent: accent)
44 status(timeline)
45 } else {
46 ProgressView().tint(ink).frame(maxWidth: .infinity, minHeight: 200)
47 }
48 safety
49 }
50 .padding(20)
51 .readableWidth()
52 }
53 .background(Color.black.ignoresSafeArea())
54 .foregroundStyle(ink)
55 .navigationTitle(rehearsal ? "Rehearsal" : "Eclipse day")
56 .navigationBarTitleDisplayMode(.inline)
57 .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
58 }
59 .task(id: [site.latitude, site.longitude, site.height, (fix?.accuracy ?? .infinity) < EclipseTimeline.maximumAccuracy ? 1 : 0]) {
60 let date = date, site = site, accuracy = fix?.accuracy
61 let t = await Task.detached { LocalEclipse.at(site, near: date).flatMap { try? EclipseTimeline($0, observer: site, accuracy: accuracy) } }.value
62 timeline = t
63 if rehearsal, shift == nil, let t { shift = t.rehearsalStart.timeIntervalSinceNow }
64 }
65 .task {
66 // Plays each cue once, within 50 ms of its time; cues more than 2 s late (the app was away) are skipped.
67 var last = now()
68 while !Task.isCancelled {
69 try? await Task.sleep(for: .milliseconds(50))
70 let t = now()
71 for cue in timeline?.cues ?? [] where cue.date > last && cue.date <= t && t.timeIntervalSince(cue.date) < 2 {
72 player.play(cue)
73 spoken = cue
74 }
75 last = t
76 }
77 }
78 .onAppear {
79 UIApplication.shared.isIdleTimerDisabled = true
80 if locationMode != "city" { location.start() }
81 player.start()
82 }
83 .onDisappear {
84 UIApplication.shared.isIdleTimerDisabled = false
85 if locationMode != "city" { location.stop() }
86 player.stop()
87 }
88 }
89
90 private func countdown(_ t: EclipseTimeline, at now: Date) -> some View {
91 let next = t.contacts.first { $0.date > now }
92 let inTotality = t.eclipse.centralBegin.map { $0 <= now } == true && t.eclipse.centralEnd.map { now < $0 } == true
93 return VStack(alignment: .leading, spacing: 8) {
94 Text(next.map { label($0.name, t) } ?? "The eclipse is over").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.7)
95 if let next {
96 Text(clock(next.date.timeIntervalSince(now)))
97 .font(.system(size: 64, weight: .medium).monospacedDigit())
98 .foregroundStyle(inTotality ? accent : ink)
99 .contentTransition(.numericText())
100 }
101 if let cue = spoken, now.timeIntervalSince(cue.date) < 20 {
102 Text(cue.text).font(.title3.weight(.medium)).foregroundStyle(cue.warning ? accent : ink)
103 }
104 let upcoming = t.cues.filter { $0.date > now && Int($0.text) == nil }.prefix(3)
105 if !upcoming.isEmpty {
106 VStack(alignment: .leading, spacing: 4) {
107 ForEach(upcoming, id: \.date) { c in
108 HStack(alignment: .firstTextBaseline) {
109 Text("in \(clock(c.date.timeIntervalSince(now)))").monospacedDigit().dimmed(0.6).frame(width: 90, alignment: .leading)
110 Text(c.text).foregroundStyle(c.warning ? accent : ink).lineLimit(2)
111 }
112 }
113 }
114 .font(.footnote)
115 }
116 }
117 .accessibilityElement(children: .combine)
118 }
119
120 private func label(_ contact: String, _ t: EclipseTimeline) -> String {
121 let central = t.eclipse.kind == "Total" ? "Totality" : "Annularity"
122 switch contact {
123 case "C1": return "First contact in"
124 case "C2": return "\(central) in"
125 case "Max": return "Maximum in"
126 case "C3": return "\(central) ends in"
127 default: return "Last contact in"
128 }
129 }
130
131 private func contacts(_ t: EclipseTimeline) -> some View {
132 let zones = [zone, .gmt].compactMap { $0 }
133 return Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
134 GridRow {
135 Text("")
136 if let zone { Text("Local, \(utcOffset(zone, at: t.eclipse.peak))").dimmed(0.6) }
137 Text("UTC").dimmed(0.6)
138 }
139 .font(.caption)
140 ForEach(t.contacts, id: \.name) { c in
141 GridRow {
142 Text(c.name).dimmed(0.7)
143 ForEach(zones, id: \.self) { z in
144 // A string: Text(_:format:) would replace the style's time zone with the environment's.
145 Text(Date(timeIntervalSinceReferenceDate: c.date.timeIntervalSinceReferenceDate.rounded()).formatted(Date.FormatStyle(date: .omitted, time: .standard, timeZone: z)))
146 }
147 }
148 }
149 }
150 .font(.subheadline.monospacedDigit())
151 }
152
153 private func status(_ t: EclipseTimeline) -> some View {
154 let position = fix.map { "Position from GPS, ±\(Int($0.accuracy.rounded())) m." } ?? "Position: \(placeName), not a GPS fix."
155 let off: String = switch t.glassesOffWithheld {
156 case nil: "The totality cue tells you to take your glasses off only once the last bead of sunlight has gone."
157 case .partial: "No totality here: keep your glasses on the whole time."
158 case .short: "Totality here is under \(Int(EclipseTimeline.minimumTotality)) s, so there is no glasses-off cue."
159 case .imprecise: locationMode == "city"
160 ? "There is no glasses-off cue for a chosen city. Choose Current location in Settings for one."
161 : "There is no glasses-off cue until the GPS fix is better than \(Int(EclipseTimeline.maximumAccuracy)) m."
162 case .sunDown: "The Sun is below the horizon at totality here."
163 }
164 return VStack(alignment: .leading, spacing: 6) {
165 Text(position)
166 Text(off)
167 Text("Times are good to about 2 s inside the path and less near its edges, where valleys on the Moon's limb shift the contacts.")
168 .dimmed(0.7)
169 }
170 .font(.footnote)
171 }
172
173 private var safety: some View {
174 VStack(alignment: .leading, spacing: 6) {
175 Text("Safety").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
176 Text("Look at the Sun only through eclipse glasses or a viewer made to ISO 12312-2, except during totality itself.")
177 Text("Cameras, binoculars and telescopes need their own solar filters. Never look through them with glasses alone.")
178 Text("Do not drive during totality. Keep the phone in the shade: in direct sun it can overheat and shut down.")
179 }
180 .font(.footnote)
181 .foregroundStyle(accent)
182 }
183
184 /// "4:39" or "1:02:15", rounded up to the second.
185 private func clock(_ seconds: TimeInterval) -> String {
186 let s = Int(max(0, seconds).rounded(.up))
187 return s >= 3600 ? String(format: "%d:%02d:%02d", s / 3600, s % 3600 / 60, s % 60) : String(format: "%d:%02d", s / 60, s % 60)
188 }
189}
190
191/// Speaks cues and plays their haptics. The audio session uses the playback category so speech
192/// sounds with the ring switch on silent.
193@MainActor
194final class CuePlayer {
195 private let speech = AVSpeechSynthesizer()
196 private var haptics: CHHapticEngine?
197
198 func start() {
199 try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .voicePrompt, options: [.duckOthers])
200 try? AVAudioSession.sharedInstance().setActive(true)
201 haptics = try? CHHapticEngine()
202 }
203
204 func stop() {
205 speech.stopSpeaking(at: .immediate)
206 haptics?.stop()
207 try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
208 }
209
210 func play(_ cue: EclipseTimeline.Cue) {
211 speech.stopSpeaking(at: .immediate)
212 let u = AVSpeechUtterance(string: cue.text)
213 u.voice = AVSpeechSynthesisVoice(language: "en-US")
214 speech.speak(u)
215
216 let events: [CHHapticEvent] = switch cue.haptic {
217 case .tap: [tap(at: 0)]
218 case .double: [tap(at: 0), tap(at: 0.15)]
219 case .long: [CHHapticEvent(eventType: .hapticContinuous, parameters: [.init(parameterID: .hapticIntensity, value: 1)], relativeTime: 0, duration: 0.8)]
220 }
221 guard let haptics, let pattern = try? CHHapticPattern(events: events, parameters: []) else { return }
222 try? haptics.start()
223 try? haptics.makePlayer(with: pattern).start(atTime: CHHapticTimeImmediate)
224 }
225
226 private func tap(at time: TimeInterval) -> CHHapticEvent {
227 CHHapticEvent(eventType: .hapticTransient, parameters: [.init(parameterID: .hapticIntensity, value: 0.8)], relativeTime: time)
228 }
229}
App/Sources/EclipseLocation.swift +11 −5
@@ -16,6 +16,8 @@ final class EclipseLocation: NSObject, CLLocationManagerDelegate {
1616
1717 private(set) var fix: Fix?
1818 private let manager = CLLocationManager()
19 /// Screens using the fixes; updates stop when the last one goes.
20 private var users = 0
1921
2022 override init() {
2123 super.init()
@@ -25,12 +27,15 @@ final class EclipseLocation: NSObject, CLLocationManagerDelegate {
2527 }
2628
2729 func start() {
30 users += 1
31 guard users == 1 else { return }
2832 manager.requestWhenInUseAuthorization()
2933 manager.startUpdatingLocation()
3034 }
3135
3236 func stop() {
33 manager.stopUpdatingLocation()
37 users -= 1
38 if users == 0 { manager.stopUpdatingLocation() }
3439 }
3540
3641 nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
@@ -46,10 +51,11 @@ final class EclipseLocation: NSObject, CLLocationManagerDelegate {
4651 Task { @MainActor in log.error("Eclipse location failed: \(error.localizedDescription, privacy: .public)") }
4752 }
4853
49 /// The time zone at `observer` from Apple Maps, or nil offline.
50 static func timeZone(at observer: Observer) async -> TimeZone? {
51 guard let request = MKReverseGeocodingRequest(location: CLLocation(latitude: observer.latitude, longitude: observer.longitude)) else { return nil }
52 return try? await request.mapItems.first?.timeZone
54 /// The time zone and town at `observer` from Apple Maps, or nil offline.
55 static func site(at observer: Observer) async -> (zone: TimeZone, city: String?)? {
56 guard let request = MKReverseGeocodingRequest(location: CLLocation(latitude: observer.latitude, longitude: observer.longitude)),
57 let item = try? await request.mapItems.first, let zone = item.timeZone else { return nil }
58 return (zone, item.addressRepresentations?.cityName)
5359 }
5460}
5561
App/Sources/EclipseScreen.swift +132 −4
@@ -1,3 +1,4 @@
1import ActivityKit
12import SkyEngine
23import SkyRenderer
34import SwiftUI
@@ -9,10 +10,14 @@ struct EclipseScreen: View {
910 var placeName: String
1011 @State private var path: EclipsePath?
1112 @State private var local: LocalEclipse?
13 @State private var timeline: EclipseTimeline?
1214 @State private var loaded = false
1315 @State private var location = EclipseLocation()
1416 /// Time zone at the site, from Apple Maps; times are also given in UTC, and only in UTC when this is unknown.
1517 @State private var zone: TimeZone?
18 @State private var city: String?
19 @State private var day: Day?
20 @State private var activityNote: String?
1621 @AppStorage(SettingsKey.locationMode) private var locationMode = "current"
1722 @Environment(\.dismiss) private var dismiss
1823
@@ -22,6 +27,11 @@ struct EclipseScreen: View {
2227 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
2328 private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
2429
30 private enum Day: Identifiable {
31 case live, rehearsal
32 var id: Self { self }
33 }
34
2535 var body: some View {
2636 NavigationStack {
2737 ScrollView {
@@ -37,6 +47,10 @@ struct EclipseScreen: View {
3747 ProgressView().tint(ink).frame(maxWidth: .infinity, minHeight: 200)
3848 }
3949 if loaded { here }
50 if let timeline {
51 SunDiagram(timeline: timeline, ink: ink, accent: accent)
52 dayTools(timeline)
53 }
4054 }
4155 .padding(20)
4256 .readableWidth()
@@ -46,6 +60,9 @@ struct EclipseScreen: View {
4660 .navigationTitle(path.map { "\($0.kind) solar eclipse" } ?? "Solar eclipse")
4761 .navigationBarTitleDisplayMode(.inline)
4862 .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 }
4966 }
5067 .task {
5168 let date = date
@@ -53,14 +70,17 @@ struct EclipseScreen: View {
5370 loaded = true
5471 }
5572 .task(id: site) {
56 let date = date, site = site
57 local = await Task.detached { LocalEclipse.at(site, near: date) }.value
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
5878 }
5979 .task(id: [(site.latitude * 10).rounded(), (site.longitude * 10).rounded()]) {
60 if let z = await EclipseLocation.timeZone(at: site) { zone = z }
80 if let s = await EclipseLocation.site(at: site) { (zone, city) = s }
6181 }
6282 .onAppear { if locationMode != "city" { location.start() } }
63 .onDisappear { location.stop() }
83 .onDisappear { if locationMode != "city" { location.stop() } }
6484 }
6585
6686 private func facts(_ p: EclipsePath) -> some View {
@@ -119,6 +139,50 @@ struct EclipseScreen: View {
119139 }
120140 }
121141
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
122186 private var placeLabel: String {
123187 if locationMode != "city", let fix = location.fix { return "From your location, ±\(Int(fix.accuracy.rounded())) m" }
124188 return "From \(placeName)"
@@ -201,3 +265,67 @@ struct EclipseMap: View {
201265 }
202266 }
203267}
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}
App/Widgets/EclipseLiveActivity.swift added +94
@@ -0,0 +1,94 @@
1import ActivityKit
2import SwiftUI
3import WidgetKit
4
5struct EclipseLiveActivity: Widget {
6 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
7 private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
8
9 var body: some WidgetConfiguration {
10 ActivityConfiguration(for: EclipseActivityAttributes.self) { context in
11 let a = context.attributes
12 VStack(alignment: .leading, spacing: 6) {
13 Text("\(a.kind) eclipse · \(a.site)").font(.system(size: 15, weight: .medium))
14 HStack(alignment: .firstTextBaseline) {
15 if let c2 = a.c2, let c3 = a.c3 {
16 VStack(alignment: .leading, spacing: 0) {
17 Text(a.kind == "Total" ? "Totality in" : "Annularity in").font(.system(size: 12)).opacity(0.7)
18 countdown(to: c2).font(.system(size: 34, weight: .medium).monospacedDigit())
19 }
20 Spacer()
21 VStack(alignment: .trailing, spacing: 0) {
22 Text("Lasts").font(.system(size: 12)).opacity(0.7)
23 Text(timerInterval: c2...c3, countsDown: true).font(.system(size: 34, weight: .medium).monospacedDigit())
24 .foregroundStyle(accent).multilineTextAlignment(.trailing)
25 }
26 } else {
27 VStack(alignment: .leading, spacing: 0) {
28 Text("Maximum in").font(.system(size: 12)).opacity(0.7)
29 countdown(to: a.peak).font(.system(size: 34, weight: .medium).monospacedDigit())
30 }
31 }
32 }
33 ProgressView(timerInterval: a.c1...a.c4, countsDown: false, label: { EmptyView() }, currentValueLabel: { EmptyView() })
34 .tint(ink)
35 HStack {
36 ForEach(contacts(a), id: \.0) { name, date in
37 VStack(alignment: .leading, spacing: 0) {
38 Text(name).font(.system(size: 10)).opacity(0.6)
39 Text(clock(date, a)).font(.system(size: 11).monospacedDigit())
40 }
41 if name != "C4" { Spacer(minLength: 4) }
42 }
43 }
44 Text(a.timeZone == nil ? "Times in UTC" : "Local time at the site").font(.system(size: 10)).opacity(0.6)
45 }
46 .padding(16)
47 .foregroundStyle(ink)
48 .activityBackgroundTint(.black)
49 } dynamicIsland: { context in
50 let a = context.attributes
51 let next = a.c2 ?? a.peak
52 return DynamicIsland {
53 DynamicIslandExpandedRegion(.leading) {
54 Text("\(a.kind) eclipse").font(.system(size: 14, weight: .medium))
55 }
56 DynamicIslandExpandedRegion(.trailing) {
57 countdown(to: next).font(.system(size: 14).monospacedDigit())
58 }
59 DynamicIslandExpandedRegion(.bottom) {
60 if let c2 = a.c2, let c3 = a.c3 {
61 HStack {
62 Text(a.site)
63 Spacer()
64 Text("\(clock(c2, a))–\(clock(c3, a))")
65 }
66 .font(.system(size: 12).monospacedDigit()).opacity(0.8)
67 } else {
68 Text("\(a.site) · maximum \(clock(a.peak, a))").font(.system(size: 12).monospacedDigit()).opacity(0.8)
69 }
70 }
71 } compactLeading: {
72 Text(a.c2 == nil ? "Max" : "C2").font(.system(size: 12, weight: .medium))
73 } compactTrailing: {
74 countdown(to: next).font(.system(size: 12).monospacedDigit()).frame(maxWidth: 64)
75 } minimal: {
76 Image(systemName: "sun.max")
77 }
78 }
79 }
80
81 /// Counts down to `date` and then stays at zero.
82 private func countdown(to date: Date) -> Text {
83 Text(timerInterval: date.addingTimeInterval(-86_400)...date, countsDown: true)
84 }
85
86 private func contacts(_ a: EclipseActivityAttributes) -> [(String, Date)] {
87 [("C1", a.c1), ("C2", a.c2), ("Max", a.c2 == nil ? a.peak : nil), ("C3", a.c3), ("C4", a.c4)].compactMap { n, d in d.map { (n, $0) } }
88 }
89
90 /// A string: Text(_:format:) would replace the style's time zone with the environment's.
91 private func clock(_ date: Date, _ a: EclipseActivityAttributes) -> String {
92 date.formatted(Date.FormatStyle(date: .omitted, time: .standard, timeZone: a.timeZone.flatMap(TimeZone.init(identifier:)) ?? .gmt))
93 }
94}
App/Widgets/MoonriseWidgets.swift +1
@@ -12,6 +12,7 @@ struct MoonriseWidgets: WidgetBundle {
1212 #if os(iOS)
1313 LaunchLiveActivity()
1414 EventLiveActivity()
15 EclipseLiveActivity()
1516 #endif
1617 }
1718}
Packages/MoonriseKit/Sources/SkyEngine/EclipseTimeline.swift added +122
@@ -0,0 +1,122 @@
1import Foundation
2
3/// One site's contacts, the Sun's position at each, and the spoken cue script for eclipse day.
4public struct EclipseTimeline: Sendable {
5 /// The glasses-off cue needs at least this much computed totality…
6 public static let minimumTotality: TimeInterval = 30
7 /// …and a location fix better than this, metres.
8 public static let maximumAccuracy = 100.0
9
10 public struct Contact: Sendable {
11 public var name: String
12 public var date: Date
13 public var sun: Horizontal
14 }
15
16 public enum Haptic: Sendable {
17 case tap, double, long
18 }
19
20 public struct Cue: Sendable, Equatable {
21 public var date: Date
22 public var text: String
23 public var haptic: Haptic
24 /// Shown in red.
25 public var warning: Bool
26 }
27
28 /// Why there is no glasses-off cue.
29 public enum Withheld: Sendable, Equatable {
30 /// No totality here.
31 case partial
32 /// Under `minimumTotality`.
33 case short
34 /// No fix, or one worse than `maximumAccuracy`.
35 case imprecise
36 /// The Sun is below the horizon at second contact.
37 case sunDown
38 }
39
40 public var eclipse: LocalEclipse
41 public var observer: Observer
42 /// C1, C2, Max, C3, C4; C2 and C3 only for a total or annular eclipse.
43 public var contacts: [Contact]
44 public var cues: [Cue]
45 public var glassesOffWithheld: Withheld?
46
47 /// `accuracy`: horizontal accuracy of the location fix in metres, or nil for a chosen place.
48 public init(_ eclipse: LocalEclipse, observer: Observer, accuracy: Double?) throws {
49 self.eclipse = eclipse
50 self.observer = observer
51 var named = [("C1", eclipse.partialBegin)]
52 if let c2 = eclipse.centralBegin { named.append(("C2", c2)) }
53 named.append(("Max", eclipse.peak))
54 if let c3 = eclipse.centralEnd { named.append(("C3", c3)) }
55 named.append(("C4", eclipse.partialEnd))
56 contacts = try named.map { Contact(name: $0.0, date: $0.1, sun: try SkyEngine.horizontal(.sun, at: $0.1, from: observer, refraction: true)) }
57
58 if eclipse.kind != "Total" {
59 glassesOffWithheld = .partial
60 } else if let b = eclipse.centralBegin, let e = eclipse.centralEnd, e.timeIntervalSince(b) < Self.minimumTotality {
61 glassesOffWithheld = .short
62 } else if accuracy.map({ $0 >= Self.maximumAccuracy }) ?? true {
63 glassesOffWithheld = .imprecise
64 } else if contacts[1].sun.altitude <= 0 {
65 glassesOffWithheld = .sunDown
66 }
67 cues = Self.script(eclipse, withheld: glassesOffWithheld).sorted { $0.date < $1.date }
68 }
69
70 static func script(_ e: LocalEclipse, withheld: Withheld?) -> [Cue] {
71 func cue(_ date: Date, _ text: String, _ haptic: Haptic = .tap, warning: Bool = false) -> Cue {
72 Cue(date: date, text: text, haptic: haptic, warning: warning)
73 }
74 var cues: [Cue] = []
75 guard e.kind == "Total", let c2 = e.centralBegin, let c3 = e.centralEnd else {
76 let annular = e.kind == "Annular"
77 cues.append(cue(e.partialBegin, "First contact. This eclipse is \(annular ? "annular" : "partial") here, so keep your glasses on the whole time.", warning: true))
78 if annular, let c2 = e.centralBegin, let c3 = e.centralEnd {
79 cues.append(cue(c2, "The ring of the Sun is complete. Glasses stay on.", .long))
80 cues.append(cue(c3, "The ring is breaking. Glasses stay on."))
81 } else {
82 cues.append(cue(e.peak.addingTimeInterval(-60), "One minute to maximum eclipse."))
83 cues.append(cue(e.peak, "Maximum eclipse, \(Int((e.obscuration * 100).rounded())) percent of the Sun covered. Glasses stay on.", .long))
84 }
85 cues.append(cue(e.partialEnd, "Last contact. The eclipse is over."))
86 return cues
87 }
88 cues.append(cue(e.partialBegin, "First contact. The Moon is starting to cover the Sun. Keep your glasses on whenever you look at it."))
89 if c2.timeIntervalSince(e.partialBegin) > 360 { cues.append(cue(c2.addingTimeInterval(-300), "Five minutes to totality.")) }
90 cues.append(cue(c2.addingTimeInterval(-120), "Two minutes. Lay out a white sheet and watch it for shadow bands."))
91 cues.append(cue(c2.addingTimeInterval(-60), "One minute to totality. Glasses stay on.", .double))
92 cues.append(cue(c2.addingTimeInterval(-15), "Watch for Baily's beads and the diamond ring, through your glasses.", .double))
93 for n in 1...5 { cues.append(cue(c2.addingTimeInterval(-Double(n)), "\(n)")) }
94 switch withheld {
95 case nil:
96 cues.append(cue(c2, "Totality. Wait until the last bead of sunlight has gone, then glasses off.", .long))
97 case .short:
98 cues.append(cue(c2, "Totality here is under \(Int(Self.minimumTotality)) seconds. Keep your glasses on.", .long, warning: true))
99 case .imprecise:
100 cues.append(cue(c2, "Totality is due, but your position is not precise enough to call it. Keep your glasses on.", .long, warning: true))
101 case .sunDown, .partial:
102 cues.append(cue(c2, "Keep your glasses on.", .long, warning: true))
103 }
104 let duration = c3.timeIntervalSince(c2)
105 if duration >= 60 { cues.append(cue(c3.addingTimeInterval(-30), "Thirty seconds of totality left.")) }
106 cues.append(cue(c3.addingTimeInterval(-10), "Glasses on.", .long, warning: true))
107 cues.append(cue(c3, "Third contact. Beads and the diamond ring on the other side, through your glasses.", .double))
108 cues.append(cue(c3.addingTimeInterval(30), "Watch the white sheet for shadow bands again."))
109 cues.append(cue(e.partialEnd, "Last contact. The eclipse is over."))
110 return cues
111 }
112
113 /// Where rehearsal mode sets its clock: three minutes before totality, or before maximum elsewhere.
114 public var rehearsalStart: Date { (eclipse.kind == "Total" ? eclipse.centralBegin ?? eclipse.peak : eclipse.peak).addingTimeInterval(-180) }
115
116 /// The Sun's position from first to last contact, every `step` seconds, ending at last contact.
117 public func sunPath(every step: TimeInterval) throws -> [(date: Date, sun: Horizontal)] {
118 var dates = Array(stride(from: eclipse.partialBegin, to: eclipse.partialEnd, by: step))
119 dates.append(eclipse.partialEnd)
120 return try dates.map { ($0, try SkyEngine.horizontal(.sun, at: $0, from: observer, refraction: true)) }
121 }
122}
Packages/MoonriseKit/Tests/SkyEngineTests/EclipseTimelineTests.swift added +84
@@ -0,0 +1,84 @@
1import Foundation
2import Testing
3@testable import SkyEngine
4
5private let peak2027 = Date(timeIntervalSince1970: 1_817_201_220)
6private let tarifa = Observer(latitude: 36.0143, longitude: -5.6044)
7
8private func timeline(_ observer: Observer, accuracy: Double?) throws -> EclipseTimeline {
9 try EclipseTimeline(try #require(LocalEclipse.at(observer, near: peak2027)), observer: observer, accuracy: accuracy)
10}
11
12private func offCues(_ t: EclipseTimeline) -> [EclipseTimeline.Cue] { t.cues.filter { $0.text.localizedCaseInsensitiveContains("glasses off") } }
13
14@Test func glassesOffOnlyAtSecondContactWithAPreciseFix() throws {
15 let t = try timeline(tarifa, accuracy: 5)
16 #expect(t.glassesOffWithheld == nil)
17 let off = offCues(t)
18 #expect(off.count == 1)
19 #expect(off.first?.date == t.eclipse.centralBegin)
20 #expect(off.first?.text.contains("last bead") == true)
21}
22
23@Test func glassesOnTenSecondsBeforeThirdContact() throws {
24 for accuracy in [5.0, 500, nil] {
25 let t = try timeline(tarifa, accuracy: accuracy)
26 let c3 = try #require(t.eclipse.centralEnd)
27 let on = t.cues.filter { $0.text.hasPrefix("Glasses on") }
28 #expect(on.map(\.date) == [c3.addingTimeInterval(-10)])
29 #expect(on.first?.warning == true)
30 }
31}
32
33@Test func noGlassesOffWithoutAPreciseFix() throws {
34 let coarse = try timeline(tarifa, accuracy: 100)
35 #expect(offCues(coarse).isEmpty)
36 #expect(coarse.glassesOffWithheld == .imprecise)
37 let city = try timeline(tarifa, accuracy: nil)
38 #expect(offCues(city).isEmpty)
39 #expect(city.glassesOffWithheld == .imprecise)
40}
41
42@Test func noGlassesOffForShortTotality() throws {
43 var e = try #require(LocalEclipse.at(tarifa, near: peak2027))
44 e.centralEnd = e.centralBegin?.addingTimeInterval(29.9)
45 let t = try EclipseTimeline(e, observer: tarifa, accuracy: 5)
46 #expect(offCues(t).isEmpty)
47 #expect(t.glassesOffWithheld == .short)
48}
49
50@Test func partialSiteKeepsGlassesOnThroughout() throws {
51 let seville = Observer(latitude: 37.3891, longitude: -5.9845) // 98% covered, no totality
52 let t = try timeline(seville, accuracy: 5)
53 #expect(t.eclipse.kind == "Partial")
54 #expect(offCues(t).isEmpty)
55 #expect(t.glassesOffWithheld == .partial)
56 #expect(t.cues.first?.text.contains("keep your glasses on the whole time") == true)
57}
58
59@Test func scriptHasShadowBandAndBeadCuesInOrder() throws {
60 let t = try timeline(tarifa, accuracy: 5)
61 let c2 = try #require(t.eclipse.centralBegin)
62 #expect(t.cues.contains { $0.date == c2.addingTimeInterval(-120) && $0.text.contains("shadow bands") })
63 #expect(t.cues.contains { $0.date == c2.addingTimeInterval(-15) && $0.text.contains("Baily's beads") })
64 #expect(t.cues.map(\.date) == t.cues.map(\.date).sorted())
65 #expect(t.cues.first?.date == t.eclipse.partialBegin && t.cues.last?.date == t.eclipse.partialEnd)
66}
67
68@Test func rehearsalStartsThreeMinutesBeforeTotalityOrMaximum() throws {
69 let t = try timeline(tarifa, accuracy: 5)
70 #expect(t.rehearsalStart == t.eclipse.centralBegin?.addingTimeInterval(-180))
71 let madrid = try timeline(Observer(latitude: 40.42, longitude: -3.70), accuracy: 5)
72 #expect(madrid.rehearsalStart == madrid.eclipse.peak.addingTimeInterval(-180))
73}
74
75@Test func sunAtEachContact() throws {
76 let t = try timeline(tarifa, accuracy: 5)
77 #expect(t.contacts.map(\.name) == ["C1", "C2", "Max", "C3", "C4"])
78 let c2 = t.contacts[1].sun
79 // Tarifa at totality: hour angle −56°, declination +17.8°, so about 38° up and just south of east.
80 #expect(abs(c2.altitude - 38) < 2)
81 #expect(c2.azimuth > 92 && c2.azimuth < 98)
82 let path = try t.sunPath(every: 300)
83 #expect(path.first?.date == t.eclipse.partialBegin && path.last?.date == t.eclipse.partialEnd)
84}