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

App/Sources/SkyModel.swift

04b2b1d914788057bcd20945b876847386eb8f65
moonrise/App/Sources/SkyModel.swift history · blame · raw

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}