Commit 632d913c59
Verified · cmc
Layout: unified · split
App/Shared/EclipseActivity.swift added +19
| @@ -0,0 +1,19 @@ | ||
| 1 | import ActivityKit | |
| 2 | import 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. | |
| 6 | struct 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 @@ | ||
| 1 | import AVFoundation | |
| 2 | import CoreHaptics | |
| 3 | import SkyEngine | |
| 4 | import 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. | |
| 9 | struct 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 | |
| 194 | final 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 { | ||
| 16 | 16 | |
| 17 | 17 | private(set) var fix: Fix? |
| 18 | 18 | private let manager = CLLocationManager() |
| 19 | /// Screens using the fixes; updates stop when the last one goes. | |
| 20 | private var users = 0 | |
| 19 | 21 | |
| 20 | 22 | override init() { |
| 21 | 23 | super.init() |
| @@ -25,12 +27,15 @@ final class EclipseLocation: NSObject, CLLocationManagerDelegate { | ||
| 25 | 27 | } |
| 26 | 28 | |
| 27 | 29 | func start() { |
| 30 | users += 1 | |
| 31 | guard users == 1 else { return } | |
| 28 | 32 | manager.requestWhenInUseAuthorization() |
| 29 | 33 | manager.startUpdatingLocation() |
| 30 | 34 | } |
| 31 | 35 | |
| 32 | 36 | func stop() { |
| 33 | manager.stopUpdatingLocation() | |
| 37 | users -= 1 | |
| 38 | if users == 0 { manager.stopUpdatingLocation() } | |
| 34 | 39 | } |
| 35 | 40 | |
| 36 | 41 | nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { |
| @@ -46,10 +51,11 @@ final class EclipseLocation: NSObject, CLLocationManagerDelegate { | ||
| 46 | 51 | Task { @MainActor in log.error("Eclipse location failed: \(error.localizedDescription, privacy: .public)") } |
| 47 | 52 | } |
| 48 | 53 | |
| 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) | |
| 53 | 59 | } |
| 54 | 60 | } |
| 55 | 61 | |
App/Sources/EclipseScreen.swift +132 −4
| @@ -1,3 +1,4 @@ | ||
| 1 | import ActivityKit | |
| 1 | 2 | import SkyEngine |
| 2 | 3 | import SkyRenderer |
| 3 | 4 | import SwiftUI |
| @@ -9,10 +10,14 @@ struct EclipseScreen: View { | ||
| 9 | 10 | var placeName: String |
| 10 | 11 | @State private var path: EclipsePath? |
| 11 | 12 | @State private var local: LocalEclipse? |
| 13 | @State private var timeline: EclipseTimeline? | |
| 12 | 14 | @State private var loaded = false |
| 13 | 15 | @State private var location = EclipseLocation() |
| 14 | 16 | /// Time zone at the site, from Apple Maps; times are also given in UTC, and only in UTC when this is unknown. |
| 15 | 17 | @State private var zone: TimeZone? |
| 18 | @State private var city: String? | |
| 19 | @State private var day: Day? | |
| 20 | @State private var activityNote: String? | |
| 16 | 21 | @AppStorage(SettingsKey.locationMode) private var locationMode = "current" |
| 17 | 22 | @Environment(\.dismiss) private var dismiss |
| 18 | 23 | |
| @@ -22,6 +27,11 @@ struct EclipseScreen: View { | ||
| 22 | 27 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) |
| 23 | 28 | private let accent = Color(red: 0.88, green: 0.24, blue: 0.19) |
| 24 | 29 | |
| 30 | private enum Day: Identifiable { | |
| 31 | case live, rehearsal | |
| 32 | var id: Self { self } | |
| 33 | } | |
| 34 | ||
| 25 | 35 | var body: some View { |
| 26 | 36 | NavigationStack { |
| 27 | 37 | ScrollView { |
| @@ -37,6 +47,10 @@ struct EclipseScreen: View { | ||
| 37 | 47 | ProgressView().tint(ink).frame(maxWidth: .infinity, minHeight: 200) |
| 38 | 48 | } |
| 39 | 49 | if loaded { here } |
| 50 | if let timeline { | |
| 51 | SunDiagram(timeline: timeline, ink: ink, accent: accent) | |
| 52 | dayTools(timeline) | |
| 53 | } | |
| 40 | 54 | } |
| 41 | 55 | .padding(20) |
| 42 | 56 | .readableWidth() |
| @@ -46,6 +60,9 @@ struct EclipseScreen: View { | ||
| 46 | 60 | .navigationTitle(path.map { "\($0.kind) solar eclipse" } ?? "Solar eclipse") |
| 47 | 61 | .navigationBarTitleDisplayMode(.inline) |
| 48 | 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 | } | |
| 49 | 66 | } |
| 50 | 67 | .task { |
| 51 | 68 | let date = date |
| @@ -53,14 +70,17 @@ struct EclipseScreen: View { | ||
| 53 | 70 | loaded = true |
| 54 | 71 | } |
| 55 | 72 | .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 | |
| 58 | 78 | } |
| 59 | 79 | .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 } | |
| 61 | 81 | } |
| 62 | 82 | .onAppear { if locationMode != "city" { location.start() } } |
| 63 | .onDisappear { location.stop() } | |
| 83 | .onDisappear { if locationMode != "city" { location.stop() } } | |
| 64 | 84 | } |
| 65 | 85 | |
| 66 | 86 | private func facts(_ p: EclipsePath) -> some View { |
| @@ -119,6 +139,50 @@ struct EclipseScreen: View { | ||
| 119 | 139 | } |
| 120 | 140 | } |
| 121 | 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 | ||
| 122 | 186 | private var placeLabel: String { |
| 123 | 187 | if locationMode != "city", let fix = location.fix { return "From your location, ±\(Int(fix.accuracy.rounded())) m" } |
| 124 | 188 | return "From \(placeName)" |
| @@ -201,3 +265,67 @@ struct EclipseMap: View { | ||
| 201 | 265 | } |
| 202 | 266 | } |
| 203 | 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. | |
| 271 | struct 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 @@ | ||
| 1 | import ActivityKit | |
| 2 | import SwiftUI | |
| 3 | import WidgetKit | |
| 4 | ||
| 5 | struct 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 { | ||
| 12 | 12 | #if os(iOS) |
| 13 | 13 | LaunchLiveActivity() |
| 14 | 14 | EventLiveActivity() |
| 15 | EclipseLiveActivity() | |
| 15 | 16 | #endif |
| 16 | 17 | } |
| 17 | 18 | } |
Packages/MoonriseKit/Sources/SkyEngine/EclipseTimeline.swift added +122
| @@ -0,0 +1,122 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// One site's contacts, the Sun's position at each, and the spoken cue script for eclipse day. | |
| 4 | public 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import SkyEngine | |
| 4 | ||
| 5 | private let peak2027 = Date(timeIntervalSince1970: 1_817_201_220) | |
| 6 | private let tarifa = Observer(latitude: 36.0143, longitude: -5.6044) | |
| 7 | ||
| 8 | private func timeline(_ observer: Observer, accuracy: Double?) throws -> EclipseTimeline { | |
| 9 | try EclipseTimeline(try #require(LocalEclipse.at(observer, near: peak2027)), observer: observer, accuracy: accuracy) | |
| 10 | } | |
| 11 | ||
| 12 | private 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 | } | |