v2: Eclipse path maps !33

merged merged by cmc on 2026-10-18 21:34 UTC · krz/moonrise:v2-eclipse-map into main

5 files changed, +377 −1

Layout: unified · split

App/Sources/EclipseScreen.swift added +164
@@ -0,0 +1,164 @@
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}
App/Sources/EventsScreen.swift +11 −1
@@ -7,6 +7,7 @@ struct EventsScreen: View {
77 var placeName: String
88 @State private var events: [SkyEvent] = []
99 @State private var filter = "notable"
10 @State private var eclipse: SkyEvent?
1011 @Environment(\.dismiss) private var dismiss
1112
1213 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
@@ -39,7 +40,15 @@ struct EventsScreen: View {
3940 }
4041 ForEach(months, id: \.0) { month, items in
4142 Section(month) {
42 ForEach(items) { row($0) }
43 ForEach(items) { e in
44 if e.kind == .solarEclipse {
45 Button { eclipse = e } label: { row(e) }
46 .buttonStyle(.plain)
47 .accessibilityHint("Shows the path and local times")
48 } else {
49 row(e)
50 }
51 }
4352 }
4453 }
4554 }
@@ -49,6 +58,7 @@ struct EventsScreen: View {
4958 .navigationTitle("Events")
5059 .navigationBarTitleDisplayMode(.inline)
5160 .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
61 .sheet(item: $eclipse) { e in EclipseScreen(date: e.date, observer: observer, placeName: placeName) }
5262 }
5363 .foregroundStyle(ink)
5464 .task {
PLAN.org +1
@@ -393,6 +393,7 @@ Sky, System, Sun, Launches, Library core (above).
393393- Events: eclipses, meteor showers, conjunctions, oppositions, each with
394394 an observing plan. Eclipse path maps with local timings; the 2 August
395395 2027 total eclipse (Spain, North Africa, Egypt) is a launch target.
396 Path maps and local timings done for every total and annular eclipse.
396397- Deep Space Network Now: which DSN dishes are talking to which
397398 spacecraft.
398399- More satellites: Tiangong, Hubble, Starlink trains, bright passes. Done:
Packages/MoonriseKit/Sources/SkyEngine/EclipsePath.swift added +154
@@ -0,0 +1,154 @@
1import CAstronomy
2import Foundation
3
4/// Ground track of the Moon's umbra (total) or antumbra (annular) for one solar eclipse,
5/// from the Sun–Moon shadow axis intersected with the Earth's ellipsoid.
6public struct EclipsePath: Sendable {
7 public struct Point: Sendable, Equatable {
8 public var date: Date
9 public var center: (lat: Double, lon: Double)
10 public var north: (lat: Double, lon: Double)?
11 public var south: (lat: Double, lon: Double)?
12 /// Width of the path at this point, km.
13 public var widthKm: Double
14
15 public static func == (a: Point, b: Point) -> Bool { a.date == b.date }
16 }
17
18 public var kind: String
19 public var peak: Date
20 /// Greatest eclipse.
21 public var greatest: (lat: Double, lon: Double)
22 /// Duration of totality or annularity at greatest eclipse, seconds.
23 public var centralDuration: TimeInterval?
24 public var points: [Point]
25
26 static let sunRadiusKm = 695_700.0
27 static let moonRadiusKm = 1_737.4
28 static let earthA = 6_378.137
29 static let earthF = 1 / 298.257223563
30 static let kmPerAU = 1.495978707e8
31
32 /// The path of the first total or annular eclipse whose peak is within a day of `date`.
33 public static func compute(near date: Date, step: TimeInterval = 60) -> EclipsePath? {
34 SkyEngine.lock.lock()
35 defer { SkyEngine.lock.unlock() }
36 let g = Astronomy_SearchGlobalSolarEclipse(SkyEngine.time(date.addingTimeInterval(-86_400)))
37 guard g.status == ASTRO_SUCCESS, g.kind == ECLIPSE_TOTAL || g.kind == ECLIPSE_ANNULAR else { return nil }
38 let peak = SkyEngine.date(g.peak)
39 guard abs(peak.timeIntervalSince(date)) < 86_400 else { return nil }
40
41 var points: [Point] = []
42 var t = peak.addingTimeInterval(-4 * 3600)
43 while t <= peak.addingTimeInterval(4 * 3600) {
44 if let p = point(at: t, step: step) { points.append(p) }
45 t = t.addingTimeInterval(step)
46 }
47
48 let at = Observer(latitude: g.latitude, longitude: g.longitude)
49 let local = Astronomy_SearchLocalSolarEclipse(SkyEngine.time(peak.addingTimeInterval(-6 * 3600)), at.astro)
50 var duration: TimeInterval?
51 if local.status == ASTRO_SUCCESS, local.kind == g.kind {
52 duration = SkyEngine.date(local.total_end.time).timeIntervalSince(SkyEngine.date(local.total_begin.time))
53 }
54 return EclipsePath(kind: SkyEngine.eclipseKind(g.kind), peak: peak, greatest: (g.latitude, g.longitude), centralDuration: duration, points: points)
55 }
56
57 /// Centre and limits of the path at `date`, or nil if the shadow axis misses the Earth.
58 static func point(at date: Date, step: TimeInterval) -> Point? {
59 guard let (center, n, r) = axis(at: date), let next = axis(at: date.addingTimeInterval(step)) else { return nil }
60 guard let c = center.hit, let c2 = next.0.hit else { return nil }
61 // Unit vector across the track: perpendicular to the shadow axis and the ground motion.
62 let across = normalize(cross(n, c2 - c))
63 let offset = across * (abs(r) / kmPerAU)
64 let north = Ray(origin: center.origin + offset, direction: n).hit
65 let south = Ray(origin: center.origin - offset, direction: n).hit
66 let t = SkyEngine.time(date)
67 func geo(_ v: SIMD3<Double>) -> (lat: Double, lon: Double) {
68 var vec = astro_vector_t(status: ASTRO_SUCCESS, x: v.x, y: v.y, z: v.z, t: t)
69 let o = Astronomy_VectorObserver(&vec, EQUATOR_OF_DATE)
70 return (o.latitude, o.longitude)
71 }
72 var a = geo(north ?? c), b = geo(south ?? c)
73 if a.lat < b.lat { swap(&a, &b) }
74 let width = (north != nil && south != nil) ? length(north! - south!) * kmPerAU : 2 * abs(r)
75 return Point(date: date, center: geo(c), north: north == nil ? nil : a, south: south == nil ? nil : b, widthKm: width)
76 }
77
78 struct Ray {
79 var origin: SIMD3<Double>
80 var direction: SIMD3<Double>
81
82 /// First intersection with the Earth's ellipsoid, AU, equator-of-date axes.
83 var hit: SIMD3<Double>? {
84 let k = 1 / (1 - EclipsePath.earthF)
85 let o = SIMD3(origin.x, origin.y, origin.z * k), d = SIMD3(direction.x, direction.y, direction.z * k)
86 let a = EclipsePath.earthA / EclipsePath.kmPerAU
87 let A = dot(d, d), B = 2 * dot(o, d), C = dot(o, o) - a * a
88 let disc = B * B - 4 * A * C
89 guard disc >= 0 else { return nil }
90 let s = (-B - disc.squareRoot()) / (2 * A)
91 guard s > 0 else { return nil }
92 let p = o + d * s
93 return SIMD3(p.x, p.y, p.z / k)
94 }
95 }
96
97 /// Shadow axis from the Moon away from the Sun (equator of date), its direction,
98 /// and the umbra radius where it meets the Earth (km; negative for the antumbra).
99 static func axis(at date: Date) -> (Ray, SIMD3<Double>, Double)? {
100 var t = SkyEngine.time(date)
101 let rot = Astronomy_Rotation_EQJ_EQD(&t)
102 let sun = Astronomy_RotateVector(rot, Astronomy_GeoVector(BODY_SUN, t, NO_ABERRATION))
103 let moon = Astronomy_RotateVector(rot, Astronomy_GeoMoon(t))
104 let s = SIMD3(sun.x, sun.y, sun.z), m = SIMD3(moon.x, moon.y, moon.z)
105 let n = normalize(m - s)
106 let ray = Ray(origin: m, direction: n)
107 guard let hit = ray.hit else { return nil }
108 let D = length(m - s) * kmPerAU
109 let L = length(hit - m) * kmPerAU
110 let r = moonRadiusKm - L * (sunRadiusKm - moonRadiusKm) / D
111 return (ray, n, r)
112 }
113}
114
115private func dot(_ a: SIMD3<Double>, _ b: SIMD3<Double>) -> Double { (a * b).sum() }
116private func length(_ a: SIMD3<Double>) -> Double { dot(a, a).squareRoot() }
117private func normalize(_ a: SIMD3<Double>) -> SIMD3<Double> { a / length(a) }
118private func cross(_ a: SIMD3<Double>, _ b: SIMD3<Double>) -> SIMD3<Double> {
119 SIMD3(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x)
120}
121
122/// A solar eclipse as seen from one place.
123public struct LocalEclipse: Sendable {
124 public var kind: String
125 public var obscuration: Double
126 public var partialBegin: Date
127 public var centralBegin: Date?
128 public var peak: Date
129 public var centralEnd: Date?
130 public var partialEnd: Date
131 /// Sun's altitude at the peak, degrees.
132 public var peakAltitude: Double
133
134 /// The observer's view of the solar eclipse peaking within six hours of `date`, if any part is above the horizon.
135 public static func at(_ observer: Observer, near date: Date) -> LocalEclipse? {
136 SkyEngine.lock.lock()
137 defer { SkyEngine.lock.unlock() }
138 let l = Astronomy_SearchLocalSolarEclipse(SkyEngine.time(date.addingTimeInterval(-6 * 3600)), observer.astro)
139 guard l.status == ASTRO_SUCCESS else { return nil }
140 let peak = SkyEngine.date(l.peak.time)
141 guard abs(peak.timeIntervalSince(date)) < 6 * 3600 else { return nil }
142 guard l.partial_begin.altitude > 0 || l.peak.altitude > 0 || l.partial_end.altitude > 0 else { return nil }
143 let central = l.kind == ECLIPSE_TOTAL || l.kind == ECLIPSE_ANNULAR
144 return LocalEclipse(
145 kind: SkyEngine.eclipseKind(l.kind), obscuration: l.obscuration,
146 partialBegin: SkyEngine.date(l.partial_begin.time),
147 centralBegin: central ? SkyEngine.date(l.total_begin.time) : nil,
148 peak: peak,
149 centralEnd: central ? SkyEngine.date(l.total_end.time) : nil,
150 partialEnd: SkyEngine.date(l.partial_end.time),
151 peakAltitude: l.peak.altitude
152 )
153 }
154}
Packages/MoonriseKit/Tests/SkyEngineTests/EclipsePathTests.swift added +47
@@ -0,0 +1,47 @@
1import Foundation
2import Testing
3@testable import SkyEngine
4
5/// Distance in km between two points on a sphere.
6private func km(_ a: (lat: Double, lon: Double), _ b: (lat: Double, lon: Double)) -> Double {
7 let r = Double.pi / 180
8 let h = pow(sin((b.lat - a.lat) * r / 2), 2) + cos(a.lat * r) * cos(b.lat * r) * pow(sin((b.lon - a.lon) * r / 2), 2)
9 return 2 * 6371 * asin(h.squareRoot())
10}
11
12@Test func totalEclipse2027Path() throws {
13 // NASA (Espenak): greatest eclipse 2027-08-02 10:07 UT at 25.5° N, 33.2° E,
14 // totality 6 m 23 s, path 258 km wide; the path crosses southern Spain and Luxor.
15 let path = try #require(EclipsePath.compute(near: Date(timeIntervalSince1970: 1_817_201_220)))
16 #expect(path.kind == "Total")
17 #expect(abs(path.greatest.lat - 25.5) < 0.3 && abs(path.greatest.lon - 33.2) < 0.3)
18 let duration = try #require(path.centralDuration)
19 #expect(abs(duration - 383) < 5)
20 #expect(path.points.count > 100)
21
22 func nearest(_ place: (lat: Double, lon: Double)) -> Double {
23 path.points.map { km($0.center, place) }.min()!
24 }
25 #expect(nearest((25.69, 32.64)) < 40) // Luxor, close to the centre line
26 #expect(nearest((36.01, -5.60)) < 120) // Tarifa, inside the path
27
28 let middle = path.points.min { abs($0.date.timeIntervalSince(path.peak)) < abs($1.date.timeIntervalSince(path.peak)) }!
29 #expect(abs(middle.widthKm - 258) < 20)
30}
31
32@Test func noPathForPartialEclipse() {
33 // 21 September 2025 was partial only.
34 #expect(EclipsePath.compute(near: Date(timeIntervalSince1970: 1_758_483_660)) == nil)
35}
36
37@Test func localCircumstances2027() throws {
38 let peak = Date(timeIntervalSince1970: 1_817_201_220)
39 let luxor = try #require(LocalEclipse.at(Observer(latitude: 25.69, longitude: 32.64), near: peak))
40 #expect(luxor.kind == "Total")
41 let begin = try #require(luxor.centralBegin), end = try #require(luxor.centralEnd)
42 #expect(end.timeIntervalSince(begin) > 360)
43 let madrid = try #require(LocalEclipse.at(Observer(latitude: 40.42, longitude: -3.70), near: peak))
44 #expect(madrid.kind == "Partial" && madrid.centralBegin == nil)
45 #expect(madrid.obscuration > 0.8 && madrid.obscuration < 0.95)
46 #expect(LocalEclipse.at(Observer(latitude: 41.88, longitude: -87.63), near: peak) == nil) // Chicago: night
47}