Commit 5395f2f431
Verified · cmc
Layout: unified · split
App/Sources/EclipseScreen.swift added +164
| @@ -0,0 +1,164 @@ | ||
| 1 | import SkyEngine | |
| 2 | import SkyRenderer | |
| 3 | import SwiftUI | |
| 4 | ||
| 5 | /// Map of a total or annular eclipse's path, with timings at greatest eclipse and for the observer. | |
| 6 | struct 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. | |
| 101 | struct 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 { | ||
| 7 | 7 | var placeName: String |
| 8 | 8 | @State private var events: [SkyEvent] = [] |
| 9 | 9 | @State private var filter = "notable" |
| 10 | @State private var eclipse: SkyEvent? | |
| 10 | 11 | @Environment(\.dismiss) private var dismiss |
| 11 | 12 | |
| 12 | 13 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) |
| @@ -39,7 +40,15 @@ struct EventsScreen: View { | ||
| 39 | 40 | } |
| 40 | 41 | ForEach(months, id: \.0) { month, items in |
| 41 | 42 | 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 | } | |
| 43 | 52 | } |
| 44 | 53 | } |
| 45 | 54 | } |
| @@ -49,6 +58,7 @@ struct EventsScreen: View { | ||
| 49 | 58 | .navigationTitle("Events") |
| 50 | 59 | .navigationBarTitleDisplayMode(.inline) |
| 51 | 60 | .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } |
| 61 | .sheet(item: $eclipse) { e in EclipseScreen(date: e.date, observer: observer, placeName: placeName) } | |
| 52 | 62 | } |
| 53 | 63 | .foregroundStyle(ink) |
| 54 | 64 | .task { |
PLAN.org +1
| @@ -393,6 +393,7 @@ Sky, System, Sun, Launches, Library core (above). | ||
| 393 | 393 | - Events: eclipses, meteor showers, conjunctions, oppositions, each with |
| 394 | 394 | an observing plan. Eclipse path maps with local timings; the 2 August |
| 395 | 395 | 2027 total eclipse (Spain, North Africa, Egypt) is a launch target. |
| 396 | Path maps and local timings done for every total and annular eclipse. | |
| 396 | 397 | - Deep Space Network Now: which DSN dishes are talking to which |
| 397 | 398 | spacecraft. |
| 398 | 399 | - More satellites: Tiangong, Hubble, Starlink trains, bright passes. Done: |
Packages/MoonriseKit/Sources/SkyEngine/EclipsePath.swift added +154
| @@ -0,0 +1,154 @@ | ||
| 1 | import CAstronomy | |
| 2 | import 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. | |
| 6 | public 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 | ||
| 115 | private func dot(_ a: SIMD3<Double>, _ b: SIMD3<Double>) -> Double { (a * b).sum() } | |
| 116 | private func length(_ a: SIMD3<Double>) -> Double { dot(a, a).squareRoot() } | |
| 117 | private func normalize(_ a: SIMD3<Double>) -> SIMD3<Double> { a / length(a) } | |
| 118 | private 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. | |
| 123 | public 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import SkyEngine | |
| 4 | ||
| 5 | /// Distance in km between two points on a sphere. | |
| 6 | private 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 | } | |