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

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

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}