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