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/EclipseScreen.swift

59d401dca1a327705632d1e2e20f00f807dee828
moonrise/App/Sources/EclipseScreen.swift history · blame · raw

331 lines · 18204 bytes

15 symbols in this file
  1import ActivityKit
  2import SkyEngine
  3import SkyRenderer
  4import SwiftUI
  5
  6/// Map of a total or annular eclipse's path, with timings at greatest eclipse and for the observer.
  7struct EclipseScreen: View {
  8    var date: Date
  9    var observer: Observer
 10    var placeName: String
 11    @State private var path: EclipsePath?
 12    @State private var local: LocalEclipse?
 13    @State private var timeline: EclipseTimeline?
 14    @State private var loaded = false
 15    @State private var location = EclipseLocation()
 16    /// Time zone at the site, from Apple Maps; times are also given in UTC, and only in UTC when this is unknown.
 17    @State private var zone: TimeZone?
 18    @State private var city: String?
 19    @State private var day: Day?
 20    @State private var activityNote: String?
 21    @AppStorage(SettingsKey.locationMode) private var locationMode = "current"
 22    @Environment(\.dismiss) private var dismiss
 23
 24    /// The latest precise fix, or the place the screen was opened for (a chosen city, or the last coarse fix).
 25    private var site: Observer { locationMode == "city" ? observer : location.fix?.observer ?? observer }
 26
 27    private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
 28    private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
 29
 30    private enum Day: Identifiable {
 31        case live, rehearsal
 32        var id: Self { self }
 33    }
 34
 35    var body: some View {
 36        NavigationStack {
 37            ScrollView {
 38                VStack(alignment: .leading, spacing: 16) {
 39                    if let path {
 40                        EclipseMap(path: path, observer: site, ink: ink, accent: accent)
 41                            .aspectRatio(1.6, contentMode: .fit)
 42                            .accessibilityLabel("Map of the path of the eclipse")
 43                        facts(path)
 44                    } else if loaded {
 45                        Text("This eclipse is partial everywhere, so there is no path to map.").font(.footnote).dimmed(0.7)
 46                    } else {
 47                        ProgressView().tint(ink).frame(maxWidth: .infinity, minHeight: 200)
 48                    }
 49                    if loaded { here }
 50                    if let timeline {
 51                        SunDiagram(timeline: timeline, ink: ink, accent: accent)
 52                        dayTools(timeline)
 53                    }
 54                }
 55                .padding(20)
 56                .readableWidth()
 57            }
 58            .background(Color.black.ignoresSafeArea())
 59            .foregroundStyle(ink)
 60            .navigationTitle(path.map { "\($0.kind) solar eclipse" } ?? "Solar eclipse")
 61            .navigationBarTitleDisplayMode(.inline)
 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            }
 66        }
 67        .task {
 68            let date = date
 69            path = await Task.detached { EclipsePath.compute(near: date) }.value
 70            loaded = true
 71        }
 72        .task(id: site) {
 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
 78        }
 79        .task(id: [(site.latitude * 10).rounded(), (site.longitude * 10).rounded()]) {
 80            if let s = await EclipseLocation.site(at: site) { (zone, city) = s }
 81        }
 82        .onAppear { if locationMode != "city" { location.start() } }
 83        .onDisappear { if locationMode != "city" { location.stop() } }
 84    }
 85
 86    private func facts(_ p: EclipsePath) -> some View {
 87        let middle = p.points.min { abs($0.date.timeIntervalSince(p.peak)) < abs($1.date.timeIntervalSince(p.peak)) }
 88        var rows: [(String, String)] = [
 89            ("Date", p.peak.formatted(Date.FormatStyle(date: .long, time: .omitted, timeZone: .gmt))),
 90            ("Greatest eclipse", "\(p.peak.formatted(Date.FormatStyle(date: .omitted, time: .shortened, timeZone: .gmt))) UTC at \(latLon(p.greatest))"),
 91        ]
 92        if let d = p.centralDuration { rows.append(("\(p.kind == "Total" ? "Totality" : "Annularity") there", minutes(d))) }
 93        if let m = middle { rows.append(("Path width there", "\(Int(m.widthKm.rounded())) km")) }
 94        return Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
 95            ForEach(rows, id: \.0) { r in
 96                GridRow { Text(r.0).dimmed(0.7); Text(r.1).monospacedDigit() }.font(.subheadline)
 97            }
 98        }
 99    }
100
101    @ViewBuilder
102    private var here: some View {
103        VStack(alignment: .leading, spacing: 6) {
104            Text(placeLabel).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
105            if let l = local {
106                let central = l.kind == "Total" ? "Totality" : "Annular phase"
107                let zones = [zone, .gmt].compactMap { $0 }
108                Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
109                    GridRow {
110                        Text("")
111                        if let zone { Text("Local, \(utcOffset(zone, at: l.peak))").dimmed(0.6) }
112                        Text("UTC").dimmed(0.6)
113                    }
114                    .font(.caption)
115                    GridRow { Text("Begins").dimmed(0.7); ForEach(zones, id: \.self) { Text(time(l.partialBegin, $0)) } }
116                    if let b = l.centralBegin, let e = l.centralEnd {
117                        GridRow { Text("\(central) begins").dimmed(0.7); ForEach(zones, id: \.self) { Text(time(b, $0)) } }
118                        GridRow { Text("\(central) ends").dimmed(0.7); ForEach(zones, id: \.self) { Text(time(e, $0)) } }
119                    }
120                    GridRow { Text("Maximum").dimmed(0.7); ForEach(zones, id: \.self) { Text(time(l.peak, $0)) } }
121                    GridRow { Text("Ends").dimmed(0.7); ForEach(zones, id: \.self) { Text(time(l.partialEnd, $0)) } }
122                }
123                .font(.subheadline.monospacedDigit())
124                if let b = l.centralBegin, let e = l.centralEnd {
125                    Text("\(central) lasts \(minutes(e.timeIntervalSince(b))).").font(.subheadline)
126                }
127                Text("\(Int((l.obscuration * 100).rounded()))% of the Sun covered at maximum.").font(.subheadline)
128                if zone == nil {
129                    Text("Local time at this site needs a connection to look up, so times are in UTC.").font(.footnote).dimmed(0.7)
130                }
131                if l.peakAltitude < 10 {
132                    Text("The Sun is low at maximum (\(Int(l.peakAltitude.rounded()))°). Find a clear horizon.").font(.footnote).dimmed(0.7)
133                }
134            } else {
135                Text("Not visible from here.").font(.subheadline).dimmed(0.7)
136            }
137            Text("Never look at the Sun without certified eclipse glasses, except during totality itself.")
138                .font(.footnote).foregroundStyle(accent).padding(.top, 6)
139        }
140    }
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
186    private var placeLabel: String {
187        if locationMode != "city", let fix = location.fix { return "From your location, ±\(Int(fix.accuracy.rounded())) m" }
188        return "From \(placeName)"
189    }
190
191    /// To the nearest second in `zone`.
192    private func time(_ d: Date, _ zone: TimeZone) -> String {
193        Date(timeIntervalSinceReferenceDate: d.timeIntervalSinceReferenceDate.rounded()).formatted(Date.FormatStyle(date: .omitted, time: .standard, timeZone: zone))
194    }
195
196    private func minutes(_ s: TimeInterval) -> String { "\(Int(s) / 60) m \(Int(s) % 60) s" }
197
198    private func latLon(_ p: (lat: Double, lon: Double)) -> String {
199        String(format: "%.1f°%@, %.1f°%@", abs(p.lat), p.lat >= 0 ? "N" : "S", abs(p.lon), p.lon >= 0 ? "E" : "W")
200    }
201}
202
203/// Equirectangular map around the path: coastlines, the band between the limits, and the centre line.
204struct EclipseMap: View {
205    var path: EclipsePath
206    var observer: Observer
207    var ink: Color
208    var accent: Color
209
210    var body: some View {
211        Canvas { context, size in
212            let centers = path.points.map(\.center)
213            let lats = centers.map(\.lat), lons = centers.map(\.lon)
214            let latRange = (lats.min()! - 8)...(lats.max()! + 8)
215            let lonRange = (lons.min()! - 10)...(lons.max()! + 10)
216            func pt(_ lat: Double, _ lon: Double) -> CGPoint {
217                CGPoint(x: (lon - lonRange.lowerBound) / (lonRange.upperBound - lonRange.lowerBound) * size.width,
218                        y: (latRange.upperBound - lat) / (latRange.upperBound - latRange.lowerBound) * size.height)
219            }
220
221            context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(ink.opacity(0.04)))
222            var grid = Path()
223            for lat in stride(from: -80.0, through: 80, by: 10) where latRange.contains(lat) {
224                grid.move(to: pt(lat, lonRange.lowerBound)); grid.addLine(to: pt(lat, lonRange.upperBound))
225            }
226            for lon in stride(from: -180.0, through: 180, by: 10) where lonRange.contains(lon) {
227                grid.move(to: pt(latRange.lowerBound, lon)); grid.addLine(to: pt(latRange.upperBound, lon))
228            }
229            context.stroke(grid, with: .color(ink.opacity(0.08)), lineWidth: 0.5)
230
231            var coast = Path()
232            for line in Coastline.lines {
233                var pen = false
234                for p in line {
235                    let q = pt(p.lat, p.lon)
236                    if pen { coast.addLine(to: q) } else { coast.move(to: q); pen = true }
237                }
238            }
239            context.stroke(coast, with: .color(ink.opacity(0.55)), lineWidth: 0.8)
240
241            let limits = path.points.compactMap { p in p.north.flatMap { n in p.south.map { (n, $0) } } }
242            if limits.count > 1 {
243                var band = Path()
244                band.move(to: pt(limits[0].0.lat, limits[0].0.lon))
245                for l in limits.dropFirst() { band.addLine(to: pt(l.0.lat, l.0.lon)) }
246                for l in limits.reversed() { band.addLine(to: pt(l.1.lat, l.1.lon)) }
247                band.closeSubpath()
248                context.fill(band, with: .color(accent.opacity(0.4)))
249            }
250            var center = Path()
251            center.addLines(centers.map { pt($0.lat, $0.lon) })
252            context.stroke(center, with: .color(accent), lineWidth: 1.2)
253
254            let g = pt(path.greatest.lat, path.greatest.lon)
255            context.stroke(Path(ellipseIn: CGRect(x: g.x - 5, y: g.y - 5, width: 10, height: 10)), with: .color(ink), lineWidth: 1.5)
256            if latRange.contains(observer.latitude), lonRange.contains(observer.longitude) {
257                let o = pt(observer.latitude, observer.longitude)
258                context.fill(Path(ellipseIn: CGRect(x: o.x - 4, y: o.y - 4, width: 8, height: 8)), with: .color(ink))
259                context.draw(Text("You").font(.caption2).foregroundStyle(ink), at: CGPoint(x: o.x, y: o.y - 11))
260            }
261        }
262        .overlay(alignment: .bottomLeading) {
263            Text("Shaded: where the eclipse is \(path.kind.lowercased()). Ring: greatest eclipse.")
264                .font(.caption2).dimmed(0.7).padding(6)
265        }
266    }
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.
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}