App/Sources/EclipseLocation.swift
67 lines · 2529 bytes
1import CoreLocation
2import MapKit
3import Observation
4import SkyEngine
5
6/// Continuous best-accuracy fixes while an eclipse screen is open: near the edge of the path a contact moves
7/// about 4 s per kilometre.
8@MainActor
9@Observable
10final class EclipseLocation: NSObject, CLLocationManagerDelegate {
11 struct Fix: Equatable {
12 var observer: Observer
13 /// Horizontal accuracy, metres.
14 var accuracy: Double
15 }
16
17 private(set) var fix: Fix?
18 private let manager = CLLocationManager()
19 /// Screens using the fixes; updates stop when the last one goes.
20 private var users = 0
21
22 override init() {
23 super.init()
24 manager.delegate = self
25 manager.desiredAccuracy = kCLLocationAccuracyBest
26 manager.distanceFilter = 5
27 }
28
29 func start() {
30 users += 1
31 guard users == 1 else { return }
32 manager.requestWhenInUseAuthorization()
33 manager.startUpdatingLocation()
34 }
35
36 func stop() {
37 users -= 1
38 if users == 0 { manager.stopUpdatingLocation() }
39 }
40
41 nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
42 guard let l = locations.last, l.horizontalAccuracy >= 0 else { return }
43 let fix = Fix(
44 observer: Observer(latitude: l.coordinate.latitude, longitude: l.coordinate.longitude, height: l.verticalAccuracy >= 0 ? l.ellipsoidalAltitude : 0),
45 accuracy: l.horizontalAccuracy
46 )
47 Task { @MainActor in self.fix = fix }
48 }
49
50 nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
51 Task { @MainActor in log.error("Eclipse location failed: \(error.localizedDescription, privacy: .public)") }
52 }
53
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)
59 }
60}
61
62/// "UTC+2" for a zone's offset at `date`.
63func utcOffset(_ zone: TimeZone, at date: Date) -> String {
64 let s = zone.secondsFromGMT(for: date)
65 let h = abs(s) / 3600, m = abs(s) % 3600 / 60
66 return "UTC\(s < 0 ? "−" : "+")\(h)\(m == 0 ? "" : String(format: ":%02d", m))"
67}