App/Sources/SkyModel.swift
207 lines · 9463 bytes
18 symbols in this file
1import CoreLocation
2import Feeds
3import Foundation
4import Library
5import Observation
6import SkyEngine
7import SkyRenderer
8
9/// Serializes Astronomy Engine calls, which are not thread-safe.
10actor Astronomer {
11 func scene(at date: Date, from observer: Observer) throws -> SkyScene {
12 try SkyScene(date: date, observer: observer)
13 }
14
15 func details(of body: Body, at date: Date, from observer: Observer) throws -> BodyDetails {
16 let h = try SkyEngine.horizontal(body, at: date, from: observer, refraction: true)
17 let eq = try SkyEngine.equatorial(body, at: date, from: observer)
18 let i = try SkyEngine.illumination(body, at: date)
19 let rs = SkyEngine.riseSet(body, after: date, from: observer)
20 return BodyDetails(body: body, position: h, distanceAU: eq.distance, magnitude: i.magnitude, phaseFraction: i.phaseFraction, rise: rs.rise, set: rs.set)
21 }
22
23 func tonight(at date: Date, from observer: Observer) throws -> Tonight {
24 try Tonight(at: date, from: observer)
25 }
26}
27
28struct BodyDetails: Sendable {
29 var body: Body
30 var position: Horizontal
31 var distanceAU: Double
32 var magnitude: Double
33 var phaseFraction: Double
34 var rise: Date?
35 var set: Date?
36}
37
38/// Current place, the displayed time and the scene computed for them.
39@MainActor
40@Observable
41final class SkyModel: NSObject, CLLocationManagerDelegate {
42 /// Used until a location fix arrives or when location is denied.
43 static let fallback = Observer(latitude: 41.8781, longitude: -87.6298)
44
45 private(set) var observer = SkyModel.fallback
46 private(set) var hasLocation = false
47 /// Name shown under the title: "Your location", a chosen city, or the fallback.
48 private(set) var placeName = "Chicago (location unavailable)"
49 /// Sun's altitude at the displayed time, for automatic night mode.
50 private(set) var sunAltitude = 0.0
51 private(set) var scene: SkyScene?
52 private(set) var lastBuild: Duration = .zero
53 private(set) var tonight: Tonight?
54 private(set) var spaceWeather: SpaceWeather?
55 private(set) var passes: [SatellitePass] = []
56 private var elements: [OrbitalElements] = []
57 private var elementsFetched: Date?
58 private var passesComputed: (at: Date, observer: Observer, elementsFetched: Date?)?
59 private var weatherFetched: Date?
60 /// Hours from now shown by the time scrubber.
61 var offsetHours = 0.0 { didSet { refresh() } }
62 /// Year shown instead of this one (astronomical numbering: 0 is 1 BCE), or nil for now.
63 var historicalYear: Int? { didSet { refresh(); poleStar = historicalYear.flatMap { _ in SkyModel.poleStar(at: displayedDate, south: observer.latitude < 0) } } }
64 /// Brightest-nearest star to the visible celestial pole in the historical year, and its distance from the pole.
65 private(set) var poleStar: (name: String, degrees: Double)?
66 var displayedDate: Date {
67 let d = Date.now.addingTimeInterval(offsetHours * 3600)
68 guard let y = historicalYear else { return d }
69 return SkyModel.date(d, inYear: y)
70 }
71
72 /// The same month, day and time in year `y` (Julian calendar before 1582, as Foundation's Gregorian calendar does).
73 static func date(_ d: Date, inYear y: Int) -> Date {
74 let calendar = Calendar(identifier: .gregorian)
75 var c = calendar.dateComponents([.month, .day, .hour, .minute, .second], from: d)
76 c.era = y > 0 ? 1 : 0
77 c.year = y > 0 ? y : 1 - y
78 return calendar.date(from: c) ?? d
79 }
80
81 /// The star brighter than magnitude 3.7 (so Thuban, 3.65, counts) closest to the north (or south) celestial pole at `date`.
82 static func poleStar(at date: Date, south: Bool) -> (name: String, degrees: Double)? {
83 let best = Catalog.stars.filter { $0.magnitude < 3.7 }.map { star -> (Star, Double) in
84 let dec = SkyEngine.equatorOfDate(j2000: star.position(at: date), at: date).dec
85 return (star, south ? 90 + dec : 90 - dec)
86 }.min { $0.1 < $1.1 }
87 guard let (star, d) = best else { return nil }
88 return (Library.page(hr: star.hr)?.title ?? formerPoleStars[star.hr] ?? "HR \(star.hr)", d)
89 }
90
91 /// Stars near the north celestial pole between 3000 BCE and 3000 CE that have no Library page.
92 static let formerPoleStars = [5291: "Thuban", 5563: "Kochab", 5735: "Pherkad", 8974: "Errai", 8162: "Alderamin"]
93
94 let astronomer = Astronomer()
95 private let manager = CLLocationManager()
96 private var building = false
97 private var pending = false
98
99 override init() {
100 super.init()
101 manager.delegate = self
102 manager.desiredAccuracy = kCLLocationAccuracyKilometer
103 }
104
105 func start() {
106 applyLocationSettings()
107 }
108
109 /// Uses the chosen city, or asks Core Location for the current position.
110 func applyLocationSettings() {
111 let d = UserDefaults.standard
112 if d.string(forKey: SettingsKey.locationMode) == "city" {
113 let lat = d.object(forKey: SettingsKey.cityLatitude) as? Double ?? Self.fallback.latitude
114 let lon = d.object(forKey: SettingsKey.cityLongitude) as? Double ?? Self.fallback.longitude
115 observer = Observer(latitude: lat, longitude: lon)
116 placeName = d.string(forKey: SettingsKey.cityName) ?? "Chicago"
117 hasLocation = true
118 } else {
119 if !hasLocation { placeName = "Chicago (location unavailable)" }
120 manager.requestWhenInUseAuthorization()
121 manager.requestLocation()
122 }
123 refresh()
124 }
125
126 /// Rebuilds the scene for the displayed time. Requests made during a build
127 /// collapse into one rebuild when it finishes.
128 func refresh() {
129 AuroraAlerts.shared.update(observer: observer)
130 guard !building else { pending = true; return }
131 building = true
132 let date = displayedDate, observer = observer, astronomer = astronomer
133 Task {
134 let clock = ContinuousClock()
135 var scene: SkyScene?
136 let start = clock.now
137 scene = try? await astronomer.scene(at: date, from: observer)
138 lastBuild = clock.now - start
139 if let scene { self.scene = scene }
140 if offsetHours == 0, historicalYear == nil { tonight = try? await astronomer.tonight(at: date, from: observer) }
141 sunAltitude = (try? SkyEngine.horizontal(.sun, at: date, from: observer, refraction: true).altitude) ?? 0
142 building = false
143 if pending { pending = false; refresh() }
144 }
145 }
146
147 private struct SatelliteList: Decodable { var satellites: [OrbitalElements] }
148
149 /// Orbital elements from the backend (refreshed hourly), then passes for the next three days.
150 func refreshPasses() async {
151 if elementsFetched.map({ Date.now.timeIntervalSince($0) > 3600 }) ?? true, let apiBase {
152 if let (data, _) = try? await URLSession.shared.data(from: apiBase.appending(path: "v1/satellites")),
153 let list = try? JSONDecoder().decode(SatelliteList.self, from: data), !list.satellites.isEmpty {
154 elements = list.satellites
155 elementsFetched = .now
156 }
157 }
158 // Reuse the last result for an hour unless the place or the orbits changed (about 1 s of work for 158 objects).
159 if let last = passesComputed, last.observer == observer, last.elementsFetched == elementsFetched,
160 Date.now.timeIntervalSince(last.at) < 3600 {
161 passes.removeAll { $0.set < .now }
162 return
163 }
164 let elements = elements, observer = observer
165 passes = await Task.detached {
166 elements.flatMap { SkyEngine.passes(of: $0, from: .now, hours: 72, observer: observer) }.sorted { $0.rise < $1.rise }
167 }.value
168 passesComputed = (.now, observer, elementsFetched)
169 }
170
171 /// SWPC asks for no more than once a minute; this checks every 10.
172 func refreshSpaceWeather() async {
173 if let weatherFetched, Date.now.timeIntervalSince(weatherFetched) < 600 { return }
174 do {
175 spaceWeather = try await SpaceWeather.fetch()
176 weatherFetched = .now
177 } catch {
178 log.error("Space weather failed: \(error.localizedDescription, privacy: .public)")
179 }
180 }
181
182 func details(of body: Body) async -> BodyDetails? {
183 try? await astronomer.details(of: body, at: displayedDate, from: observer)
184 }
185
186 nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
187 guard let c = locations.last?.coordinate else { return }
188 Task { @MainActor in
189 guard UserDefaults.standard.string(forKey: SettingsKey.locationMode) != "city" else { return }
190 self.observer = Observer(latitude: c.latitude, longitude: c.longitude)
191 self.hasLocation = true
192 self.placeName = "Your location"
193 self.refresh()
194 }
195 }
196
197 nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
198 Task { @MainActor in log.error("Location failed: \(error.localizedDescription, privacy: .public)") }
199 }
200
201 nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
202 let status = manager.authorizationStatus
203 Task { @MainActor in
204 if [.authorizedWhenInUse, .authorizedAlways].contains(status) { self.manager.requestLocation() }
205 }
206 }
207}