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

dda3950a2e0fd0cc0d97262ff3b6646d6871181b
moonrise/App/Sources/EclipseScreen.swift history · blame · raw

203 lines · 10542 bytes

  1import SkyEngine
  2import SkyRenderer
  3import SwiftUI
  4
  5/// Map of a total or annular eclipse's path, with timings at greatest eclipse and for the observer.
  6struct EclipseScreen: View {
  7    var date: Date
  8    var observer: Observer
  9    var placeName: String
 10    @State private var path: EclipsePath?
 11    @State private var local: LocalEclipse?
 12    @State private var loaded = false
 13    @State private var location = EclipseLocation()
 14    /// Time zone at the site, from Apple Maps; times are also given in UTC, and only in UTC when this is unknown.
 15    @State private var zone: TimeZone?
 16    @AppStorage(SettingsKey.locationMode) private var locationMode = "current"
 17    @Environment(\.dismiss) private var dismiss
 18
 19    /// The latest precise fix, or the place the screen was opened for (a chosen city, or the last coarse fix).
 20    private var site: Observer { locationMode == "city" ? observer : location.fix?.observer ?? observer }
 21
 22    private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
 23    private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
 24
 25    var body: some View {
 26        NavigationStack {
 27            ScrollView {
 28                VStack(alignment: .leading, spacing: 16) {
 29                    if let path {
 30                        EclipseMap(path: path, observer: site, ink: ink, accent: accent)
 31                            .aspectRatio(1.6, contentMode: .fit)
 32                            .accessibilityLabel("Map of the path of the eclipse")
 33                        facts(path)
 34                    } else if loaded {
 35                        Text("This eclipse is partial everywhere, so there is no path to map.").font(.footnote).dimmed(0.7)
 36                    } else {
 37                        ProgressView().tint(ink).frame(maxWidth: .infinity, minHeight: 200)
 38                    }
 39                    if loaded { here }
 40                }
 41                .padding(20)
 42                .readableWidth()
 43            }
 44            .background(Color.black.ignoresSafeArea())
 45            .foregroundStyle(ink)
 46            .navigationTitle(path.map { "\($0.kind) solar eclipse" } ?? "Solar eclipse")
 47            .navigationBarTitleDisplayMode(.inline)
 48            .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
 49        }
 50        .task {
 51            let date = date
 52            path = await Task.detached { EclipsePath.compute(near: date) }.value
 53            loaded = true
 54        }
 55        .task(id: site) {
 56            let date = date, site = site
 57            local = await Task.detached { LocalEclipse.at(site, near: date) }.value
 58        }
 59        .task(id: [(site.latitude * 10).rounded(), (site.longitude * 10).rounded()]) {
 60            if let z = await EclipseLocation.timeZone(at: site) { zone = z }
 61        }
 62        .onAppear { if locationMode != "city" { location.start() } }
 63        .onDisappear { location.stop() }
 64    }
 65
 66    private func facts(_ p: EclipsePath) -> some View {
 67        let middle = p.points.min { abs($0.date.timeIntervalSince(p.peak)) < abs($1.date.timeIntervalSince(p.peak)) }
 68        var rows: [(String, String)] = [
 69            ("Date", p.peak.formatted(Date.FormatStyle(date: .long, time: .omitted, timeZone: .gmt))),
 70            ("Greatest eclipse", "\(p.peak.formatted(Date.FormatStyle(date: .omitted, time: .shortened, timeZone: .gmt))) UTC at \(latLon(p.greatest))"),
 71        ]
 72        if let d = p.centralDuration { rows.append(("\(p.kind == "Total" ? "Totality" : "Annularity") there", minutes(d))) }
 73        if let m = middle { rows.append(("Path width there", "\(Int(m.widthKm.rounded())) km")) }
 74        return Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
 75            ForEach(rows, id: \.0) { r in
 76                GridRow { Text(r.0).dimmed(0.7); Text(r.1).monospacedDigit() }.font(.subheadline)
 77            }
 78        }
 79    }
 80
 81    @ViewBuilder
 82    private var here: some View {
 83        VStack(alignment: .leading, spacing: 6) {
 84            Text(placeLabel).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
 85            if let l = local {
 86                let central = l.kind == "Total" ? "Totality" : "Annular phase"
 87                let zones = [zone, .gmt].compactMap { $0 }
 88                Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
 89                    GridRow {
 90                        Text("")
 91                        if let zone { Text("Local, \(utcOffset(zone, at: l.peak))").dimmed(0.6) }
 92                        Text("UTC").dimmed(0.6)
 93                    }
 94                    .font(.caption)
 95                    GridRow { Text("Begins").dimmed(0.7); ForEach(zones, id: \.self) { Text(time(l.partialBegin, $0)) } }
 96                    if let b = l.centralBegin, let e = l.centralEnd {
 97                        GridRow { Text("\(central) begins").dimmed(0.7); ForEach(zones, id: \.self) { Text(time(b, $0)) } }
 98                        GridRow { Text("\(central) ends").dimmed(0.7); ForEach(zones, id: \.self) { Text(time(e, $0)) } }
 99                    }
100                    GridRow { Text("Maximum").dimmed(0.7); ForEach(zones, id: \.self) { Text(time(l.peak, $0)) } }
101                    GridRow { Text("Ends").dimmed(0.7); ForEach(zones, id: \.self) { Text(time(l.partialEnd, $0)) } }
102                }
103                .font(.subheadline.monospacedDigit())
104                if let b = l.centralBegin, let e = l.centralEnd {
105                    Text("\(central) lasts \(minutes(e.timeIntervalSince(b))).").font(.subheadline)
106                }
107                Text("\(Int((l.obscuration * 100).rounded()))% of the Sun covered at maximum.").font(.subheadline)
108                if zone == nil {
109                    Text("Local time at this site needs a connection to look up, so times are in UTC.").font(.footnote).dimmed(0.7)
110                }
111                if l.peakAltitude < 10 {
112                    Text("The Sun is low at maximum (\(Int(l.peakAltitude.rounded()))°). Find a clear horizon.").font(.footnote).dimmed(0.7)
113                }
114            } else {
115                Text("Not visible from here.").font(.subheadline).dimmed(0.7)
116            }
117            Text("Never look at the Sun without certified eclipse glasses, except during totality itself.")
118                .font(.footnote).foregroundStyle(accent).padding(.top, 6)
119        }
120    }
121
122    private var placeLabel: String {
123        if locationMode != "city", let fix = location.fix { return "From your location, ±\(Int(fix.accuracy.rounded())) m" }
124        return "From \(placeName)"
125    }
126
127    /// To the nearest second in `zone`.
128    private func time(_ d: Date, _ zone: TimeZone) -> String {
129        Date(timeIntervalSinceReferenceDate: d.timeIntervalSinceReferenceDate.rounded()).formatted(Date.FormatStyle(date: .omitted, time: .standard, timeZone: zone))
130    }
131
132    private func minutes(_ s: TimeInterval) -> String { "\(Int(s) / 60) m \(Int(s) % 60) s" }
133
134    private func latLon(_ p: (lat: Double, lon: Double)) -> String {
135        String(format: "%.1f°%@, %.1f°%@", abs(p.lat), p.lat >= 0 ? "N" : "S", abs(p.lon), p.lon >= 0 ? "E" : "W")
136    }
137}
138
139/// Equirectangular map around the path: coastlines, the band between the limits, and the centre line.
140struct EclipseMap: View {
141    var path: EclipsePath
142    var observer: Observer
143    var ink: Color
144    var accent: Color
145
146    var body: some View {
147        Canvas { context, size in
148            let centers = path.points.map(\.center)
149            let lats = centers.map(\.lat), lons = centers.map(\.lon)
150            let latRange = (lats.min()! - 8)...(lats.max()! + 8)
151            let lonRange = (lons.min()! - 10)...(lons.max()! + 10)
152            func pt(_ lat: Double, _ lon: Double) -> CGPoint {
153                CGPoint(x: (lon - lonRange.lowerBound) / (lonRange.upperBound - lonRange.lowerBound) * size.width,
154                        y: (latRange.upperBound - lat) / (latRange.upperBound - latRange.lowerBound) * size.height)
155            }
156
157            context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(ink.opacity(0.04)))
158            var grid = Path()
159            for lat in stride(from: -80.0, through: 80, by: 10) where latRange.contains(lat) {
160                grid.move(to: pt(lat, lonRange.lowerBound)); grid.addLine(to: pt(lat, lonRange.upperBound))
161            }
162            for lon in stride(from: -180.0, through: 180, by: 10) where lonRange.contains(lon) {
163                grid.move(to: pt(latRange.lowerBound, lon)); grid.addLine(to: pt(latRange.upperBound, lon))
164            }
165            context.stroke(grid, with: .color(ink.opacity(0.08)), lineWidth: 0.5)
166
167            var coast = Path()
168            for line in Coastline.lines {
169                var pen = false
170                for p in line {
171                    let q = pt(p.lat, p.lon)
172                    if pen { coast.addLine(to: q) } else { coast.move(to: q); pen = true }
173                }
174            }
175            context.stroke(coast, with: .color(ink.opacity(0.55)), lineWidth: 0.8)
176
177            let limits = path.points.compactMap { p in p.north.flatMap { n in p.south.map { (n, $0) } } }
178            if limits.count > 1 {
179                var band = Path()
180                band.move(to: pt(limits[0].0.lat, limits[0].0.lon))
181                for l in limits.dropFirst() { band.addLine(to: pt(l.0.lat, l.0.lon)) }
182                for l in limits.reversed() { band.addLine(to: pt(l.1.lat, l.1.lon)) }
183                band.closeSubpath()
184                context.fill(band, with: .color(accent.opacity(0.4)))
185            }
186            var center = Path()
187            center.addLines(centers.map { pt($0.lat, $0.lon) })
188            context.stroke(center, with: .color(accent), lineWidth: 1.2)
189
190            let g = pt(path.greatest.lat, path.greatest.lon)
191            context.stroke(Path(ellipseIn: CGRect(x: g.x - 5, y: g.y - 5, width: 10, height: 10)), with: .color(ink), lineWidth: 1.5)
192            if latRange.contains(observer.latitude), lonRange.contains(observer.longitude) {
193                let o = pt(observer.latitude, observer.longitude)
194                context.fill(Path(ellipseIn: CGRect(x: o.x - 4, y: o.y - 4, width: 8, height: 8)), with: .color(ink))
195                context.draw(Text("You").font(.caption2).foregroundStyle(ink), at: CGPoint(x: o.x, y: o.y - 11))
196            }
197        }
198        .overlay(alignment: .bottomLeading) {
199            Text("Shaded: where the eclipse is \(path.kind.lowercased()). Ring: greatest eclipse.")
200                .font(.caption2).dimmed(0.7).padding(6)
201        }
202    }
203}