App/Sources/EclipseScreen.swift
164 lines · 8254 bytes
7 symbols in this file
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 @Environment(\.dismiss) private var dismiss
14
15 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
16 private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
17
18 var body: some View {
19 NavigationStack {
20 ScrollView {
21 VStack(alignment: .leading, spacing: 16) {
22 if let path {
23 EclipseMap(path: path, observer: observer, ink: ink, accent: accent)
24 .aspectRatio(1.6, contentMode: .fit)
25 .accessibilityLabel("Map of the path of the eclipse")
26 facts(path)
27 } else if loaded {
28 Text("This eclipse is partial everywhere, so there is no path to map.").font(.footnote).dimmed(0.7)
29 } else {
30 ProgressView().tint(ink).frame(maxWidth: .infinity, minHeight: 200)
31 }
32 if loaded { here }
33 }
34 .padding(20)
35 .readableWidth()
36 }
37 .background(Color.black.ignoresSafeArea())
38 .foregroundStyle(ink)
39 .navigationTitle(path.map { "\($0.kind) solar eclipse" } ?? "Solar eclipse")
40 .navigationBarTitleDisplayMode(.inline)
41 .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
42 }
43 .task {
44 let date = date, observer = observer
45 (path, local) = await Task.detached { (EclipsePath.compute(near: date), LocalEclipse.at(observer, near: date)) }.value
46 loaded = true
47 }
48 }
49
50 private func facts(_ p: EclipsePath) -> some View {
51 let middle = p.points.min { abs($0.date.timeIntervalSince(p.peak)) < abs($1.date.timeIntervalSince(p.peak)) }
52 var rows: [(String, String)] = [
53 ("Date", p.peak.formatted(date: .long, time: .omitted)),
54 ("Greatest eclipse", "\(p.peak.formatted(date: .omitted, time: .shortened)) at \(latLon(p.greatest))"),
55 ]
56 if let d = p.centralDuration { rows.append(("\(p.kind == "Total" ? "Totality" : "Annularity") there", minutes(d))) }
57 if let m = middle { rows.append(("Path width there", "\(Int(m.widthKm.rounded())) km")) }
58 return Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
59 ForEach(rows, id: \.0) { r in
60 GridRow { Text(r.0).dimmed(0.7); Text(r.1).monospacedDigit() }.font(.subheadline)
61 }
62 }
63 }
64
65 @ViewBuilder
66 private var here: some View {
67 VStack(alignment: .leading, spacing: 6) {
68 Text("From \(placeName)").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
69 if let l = local {
70 let central = l.kind == "Total" ? "Totality" : "Annular phase"
71 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
72 GridRow { Text("Begins").dimmed(0.7); Text(time(l.partialBegin)) }
73 if let b = l.centralBegin, let e = l.centralEnd {
74 GridRow { Text(central).dimmed(0.7); Text("\(time(b))–\(time(e)), \(minutes(e.timeIntervalSince(b)))") }
75 }
76 GridRow { Text("Maximum").dimmed(0.7); Text("\(time(l.peak)), \(Int((l.obscuration * 100).rounded()))% of the Sun covered") }
77 GridRow { Text("Ends").dimmed(0.7); Text(time(l.partialEnd)) }
78 }
79 .font(.subheadline.monospacedDigit())
80 if l.peakAltitude < 10 {
81 Text("The Sun is low at maximum (\(Int(l.peakAltitude.rounded()))°). Find a clear horizon.").font(.footnote).dimmed(0.7)
82 }
83 } else {
84 Text("Not visible from here.").font(.subheadline).dimmed(0.7)
85 }
86 Text("Never look at the Sun without certified eclipse glasses, except during totality itself.")
87 .font(.footnote).foregroundStyle(accent).padding(.top, 6)
88 }
89 }
90
91 private func time(_ d: Date) -> String { d.formatted(date: .omitted, time: .standard) }
92
93 private func minutes(_ s: TimeInterval) -> String { "\(Int(s) / 60) m \(Int(s) % 60) s" }
94
95 private func latLon(_ p: (lat: Double, lon: Double)) -> String {
96 String(format: "%.1f°%@, %.1f°%@", abs(p.lat), p.lat >= 0 ? "N" : "S", abs(p.lon), p.lon >= 0 ? "E" : "W")
97 }
98}
99
100/// Equirectangular map around the path: coastlines, the band between the limits, and the centre line.
101struct EclipseMap: View {
102 var path: EclipsePath
103 var observer: Observer
104 var ink: Color
105 var accent: Color
106
107 var body: some View {
108 Canvas { context, size in
109 let centers = path.points.map(\.center)
110 let lats = centers.map(\.lat), lons = centers.map(\.lon)
111 let latRange = (lats.min()! - 8)...(lats.max()! + 8)
112 let lonRange = (lons.min()! - 10)...(lons.max()! + 10)
113 func pt(_ lat: Double, _ lon: Double) -> CGPoint {
114 CGPoint(x: (lon - lonRange.lowerBound) / (lonRange.upperBound - lonRange.lowerBound) * size.width,
115 y: (latRange.upperBound - lat) / (latRange.upperBound - latRange.lowerBound) * size.height)
116 }
117
118 context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(ink.opacity(0.04)))
119 var grid = Path()
120 for lat in stride(from: -80.0, through: 80, by: 10) where latRange.contains(lat) {
121 grid.move(to: pt(lat, lonRange.lowerBound)); grid.addLine(to: pt(lat, lonRange.upperBound))
122 }
123 for lon in stride(from: -180.0, through: 180, by: 10) where lonRange.contains(lon) {
124 grid.move(to: pt(latRange.lowerBound, lon)); grid.addLine(to: pt(latRange.upperBound, lon))
125 }
126 context.stroke(grid, with: .color(ink.opacity(0.08)), lineWidth: 0.5)
127
128 var coast = Path()
129 for line in Coastline.lines {
130 var pen = false
131 for p in line {
132 let q = pt(p.lat, p.lon)
133 if pen { coast.addLine(to: q) } else { coast.move(to: q); pen = true }
134 }
135 }
136 context.stroke(coast, with: .color(ink.opacity(0.55)), lineWidth: 0.8)
137
138 let limits = path.points.compactMap { p in p.north.flatMap { n in p.south.map { (n, $0) } } }
139 if limits.count > 1 {
140 var band = Path()
141 band.move(to: pt(limits[0].0.lat, limits[0].0.lon))
142 for l in limits.dropFirst() { band.addLine(to: pt(l.0.lat, l.0.lon)) }
143 for l in limits.reversed() { band.addLine(to: pt(l.1.lat, l.1.lon)) }
144 band.closeSubpath()
145 context.fill(band, with: .color(accent.opacity(0.4)))
146 }
147 var center = Path()
148 center.addLines(centers.map { pt($0.lat, $0.lon) })
149 context.stroke(center, with: .color(accent), lineWidth: 1.2)
150
151 let g = pt(path.greatest.lat, path.greatest.lon)
152 context.stroke(Path(ellipseIn: CGRect(x: g.x - 5, y: g.y - 5, width: 10, height: 10)), with: .color(ink), lineWidth: 1.5)
153 if latRange.contains(observer.latitude), lonRange.contains(observer.longitude) {
154 let o = pt(observer.latitude, observer.longitude)
155 context.fill(Path(ellipseIn: CGRect(x: o.x - 4, y: o.y - 4, width: 8, height: 8)), with: .color(ink))
156 context.draw(Text("You").font(.caption2).foregroundStyle(ink), at: CGPoint(x: o.x, y: o.y - 11))
157 }
158 }
159 .overlay(alignment: .bottomLeading) {
160 Text("Shaded: where the eclipse is \(path.kind.lowercased()). Ring: greatest eclipse.")
161 .font(.caption2).dimmed(0.7).padding(6)
162 }
163 }
164}