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

Commit ddb85068e2

ddb85068e200fb1cc985c86b6816d869bf3360f7

parent: d75d05b065

Unsigned

cmc <hello@cleberg.net> · 2026-10-18 08:36 UTC

v2: Events (!21)

- =SkyEngine.events(from:days:observer:)=: moon phases, lunar and solar eclipses (local visibility and obscuration), oppositions, greatest elongations, seasons, major meteor showers (IMO list) with Moon illumination.
- Events sheet on the Sky tab (calendar button): next 12 months by month, Notable / Everything.

Tests: Luxor sees the 2027-08-02 eclipse as total, 2026-08-12 eclipse, Saturn opposition 2026-10-04, all kinds present over a year. Checked in the simulator.

Layout: unified · split

App/Sources/EventsScreen.swift added +92
@@ -0,0 +1,92 @@
1import SkyEngine
2import SwiftUI
3
4/// The next year of eclipses, oppositions, elongations, meteor showers, moon phases and seasons.
5struct EventsScreen: View {
6 var observer: Observer
7 var placeName: String
8 @State private var events: [SkyEvent] = []
9 @State private var filter = "notable"
10 @Environment(\.dismiss) private var dismiss
11
12 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
13 private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
14
15 private var shown: [SkyEvent] {
16 filter == "all" ? events : events.filter { $0.kind != .moonPhase && $0.kind != .season }
17 }
18
19 private var months: [(String, [SkyEvent])] {
20 let groups = Dictionary(grouping: shown) { $0.date.formatted(.dateTime.year().month(.wide)) }
21 return groups.sorted { $0.value[0].date < $1.value[0].date }.map { ($0.key, $0.value) }
22 }
23
24 var body: some View {
25 NavigationStack {
26 List {
27 Section {
28 Picker("Show", selection: $filter) {
29 Text("Notable").tag("notable")
30 Text("Everything").tag("all")
31 }
32 .pickerStyle(.segmented)
33 .listRowBackground(Color.clear)
34 } footer: {
35 Text("Visibility is for \(placeName). Meteor shower peaks are typical dates and can shift by a day.")
36 }
37 if events.isEmpty {
38 ProgressView().frame(maxWidth: .infinity).listRowBackground(Color.clear)
39 }
40 ForEach(months, id: \.0) { month, items in
41 Section(month) {
42 ForEach(items) { row($0) }
43 }
44 }
45 }
46 .listStyle(.insetGrouped)
47 .scrollContentBackground(.hidden)
48 .background(Color.black.ignoresSafeArea())
49 .navigationTitle("Events")
50 .navigationBarTitleDisplayMode(.inline)
51 .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
52 }
53 .foregroundStyle(ink)
54 .task {
55 let observer = observer
56 events = await Task.detached { SkyEngine.events(from: .now, observer: observer) }.value
57 }
58 }
59
60 private func row(_ e: SkyEvent) -> some View {
61 HStack(alignment: .firstTextBaseline, spacing: 12) {
62 Image(systemName: symbol(e.kind)).frame(width: 22).dimmed(0.8).accessibilityHidden(true)
63 VStack(alignment: .leading, spacing: 3) {
64 Text(e.title).font(.body)
65 Text(e.date.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute()))
66 .font(.footnote.monospacedDigit()).dimmed(0.7)
67 if !e.detail.isEmpty { Text(e.detail).font(.footnote).dimmed(0.8) }
68 if let v = e.visibleHere {
69 Text(v ? "Visible from here" : "Not visible from here")
70 .font(.caption.weight(.medium))
71 .foregroundStyle(v ? ink : ink.opacity(0.7))
72 .padding(.horizontal, 6).padding(.vertical, 2)
73 .overlay(RoundedRectangle(cornerRadius: 3).stroke(v ? accent : ink.opacity(0.4), lineWidth: 1))
74 }
75 }
76 }
77 .padding(.vertical, 4)
78 .accessibilityElement(children: .combine)
79 }
80
81 private func symbol(_ k: SkyEvent.Kind) -> String {
82 switch k {
83 case .moonPhase: "moonphase.full.moon"
84 case .lunarEclipse: "moon.circle"
85 case .solarEclipse: "sun.max.circle"
86 case .opposition: "circle.and.line.horizontal"
87 case .elongation: "arrow.left.and.right"
88 case .season: "calendar"
89 case .meteorShower: "sparkles"
90 }
91 }
92}
App/Sources/SkyScreen.swift +12
@@ -11,6 +11,7 @@ struct SkyScreen: View {
11 @State private var fieldOfView = 100.0 11 @State private var fieldOfView = 100.0
12 @AppStorage(SettingsKey.nightMode) private var nightMode = "auto" 12 @AppStorage(SettingsKey.nightMode) private var nightMode = "auto"
13 @State private var showSettings = false 13 @State private var showSettings = false
14 @State private var showEvents = false
14 @AppStorage(SettingsKey.onboarded) private var onboarded = false 15 @AppStorage(SettingsKey.onboarded) private var onboarded = false
15 @AppStorage(SettingsKey.nightActive) private var nightActive = false 16 @AppStorage(SettingsKey.nightActive) private var nightActive = false
16 @State private var showToday = false 17 @State private var showToday = false
@@ -57,6 +58,11 @@ struct SkyScreen: View {
57 } 58 }
58 .foregroundStyle(ink) 59 .foregroundStyle(ink)
59 .environment(\.nightMode, night) 60 .environment(\.nightMode, night)
61 .sheet(isPresented: $showEvents) {
62 EventsScreen(observer: model.observer, placeName: model.placeName)
63 .preferredColorScheme(.dark)
64 .tint(.blue)
65 }
60 .sheet(isPresented: $showSettings) { 66 .sheet(isPresented: $showSettings) {
61 SettingsScreen { model.applyLocationSettings() } 67 SettingsScreen { model.applyLocationSettings() }
62 .preferredColorScheme(.dark) 68 .preferredColorScheme(.dark)
@@ -147,6 +153,12 @@ struct SkyScreen: View {
147 } 153 }
148 .accessibilityLabel(pointer.active ? "Stop following the phone" : "Point the phone at the sky") 154 .accessibilityLabel(pointer.active ? "Stop following the phone" : "Point the phone at the sky")
149 } 155 }
156 Button { showEvents = true } label: {
157 Image(systemName: "calendar")
158 .font(.body)
159 .frame(width: 44, height: 44)
160 }
161 .accessibilityLabel("Events")
150 Button { showSettings = true } label: { 162 Button { showSettings = true } label: {
151 Image(systemName: "gearshape") 163 Image(systemName: "gearshape")
152 .font(.body) 164 .font(.body)
Packages/MoonriseKit/Sources/SkyEngine/Events.swift added +152
@@ -0,0 +1,152 @@
1import CAstronomy
2import Foundation
3
4public struct SkyEvent: Sendable, Identifiable, Equatable {
5 public enum Kind: String, Sendable {
6 case moonPhase, lunarEclipse, solarEclipse, opposition, elongation, season, meteorShower
7 }
8
9 public var kind: Kind
10 public var date: Date
11 public var title: String
12 public var detail: String
13 /// Whether it can be seen from the observer's location; nil when that does not apply.
14 public var visibleHere: Bool?
15 public var id: String { "\(kind.rawValue)-\(title)-\(Int(date.timeIntervalSince1970))" }
16}
17
18/// A major annual meteor shower (International Meteor Organization working list).
19public struct MeteorShower: Sendable {
20 public var name: String
21 /// Typical peak, month and day; the exact time varies by a day or so each year.
22 public var month: Int
23 public var day: Int
24 /// Zenithal hourly rate at peak under ideal skies.
25 public var zhr: Int
26
27 public static let major: [MeteorShower] = [
28 MeteorShower(name: "Quadrantids", month: 1, day: 3, zhr: 80),
29 MeteorShower(name: "Lyrids", month: 4, day: 22, zhr: 18),
30 MeteorShower(name: "Eta Aquariids", month: 5, day: 6, zhr: 50),
31 MeteorShower(name: "Perseids", month: 8, day: 12, zhr: 100),
32 MeteorShower(name: "Orionids", month: 10, day: 21, zhr: 20),
33 MeteorShower(name: "Leonids", month: 11, day: 17, zhr: 15),
34 MeteorShower(name: "Geminids", month: 12, day: 14, zhr: 150),
35 MeteorShower(name: "Ursids", month: 12, day: 22, zhr: 10),
36 ]
37}
38
39extension SkyEngine {
40 static func eclipseKind(_ k: astro_eclipse_kind_t) -> String {
41 switch k {
42 case ECLIPSE_PENUMBRAL: "Penumbral"
43 case ECLIPSE_PARTIAL: "Partial"
44 case ECLIPSE_ANNULAR: "Annular"
45 case ECLIPSE_TOTAL: "Total"
46 default: ""
47 }
48 }
49
50 /// Astronomical events from `start` for `days` days, sorted by date.
51 public static func events(from start: Date, days: Double = 366, observer: Observer) -> [SkyEvent] {
52 lock.lock()
53 defer { lock.unlock() }
54 let end = start.addingTimeInterval(days * 86_400)
55 var out: [SkyEvent] = []
56
57 // Full and new moons
58 var t = time(start)
59 while true {
60 let q = Astronomy_SearchMoonQuarter(t)
61 guard q.status == ASTRO_SUCCESS else { break }
62 let d = date(q.time)
63 if d > end { break }
64 if q.quarter == 0 || q.quarter == 2 {
65 out.append(SkyEvent(kind: .moonPhase, date: d, title: q.quarter == 0 ? "New moon" : "Full moon", detail: q.quarter == 0 ? "Darkest skies of the month." : "The Moon is opposite the Sun and up all night.", visibleHere: nil))
66 }
67 t = Astronomy_AddDays(q.time, 1)
68 }
69
70 // Lunar eclipses
71 var lunar = Astronomy_SearchLunarEclipse(time(start))
72 while lunar.status == ASTRO_SUCCESS, date(lunar.peak) <= end {
73 var peak = lunar.peak
74 let moon = Astronomy_Equator(BODY_MOON, &peak, observer.astro, EQUATOR_OF_DATE, ABERRATION)
75 let alt = Astronomy_Horizon(&peak, observer.astro, moon.ra, moon.dec, REFRACTION_NORMAL).altitude
76 let minutes = lunar.sd_total > 0 ? lunar.sd_total * 2 : lunar.sd_partial * 2
77 let length = minutes > 0 ? String(format: " %@ phase lasts %.0f minutes.", lunar.sd_total > 0 ? "Total" : "Partial", minutes) : ""
78 out.append(SkyEvent(kind: .lunarEclipse, date: date(lunar.peak), title: "\(eclipseKind(lunar.kind)) lunar eclipse", detail: (alt > 0 ? "The Moon is up here at mid-eclipse." : "The Moon is below the horizon here at mid-eclipse.") + length, visibleHere: alt > 0))
79 lunar = Astronomy_NextLunarEclipse(lunar.peak)
80 }
81
82 // Solar eclipses: global, with local circumstances for the observer
83 var local: [Date: astro_local_solar_eclipse_t] = [:]
84 var l = Astronomy_SearchLocalSolarEclipse(time(start), observer.astro)
85 while l.status == ASTRO_SUCCESS, date(l.peak.time) <= end {
86 local[date(l.peak.time)] = l
87 l = Astronomy_NextLocalSolarEclipse(l.peak.time, observer.astro)
88 }
89 var solar = Astronomy_SearchGlobalSolarEclipse(time(start))
90 while solar.status == ASTRO_SUCCESS, date(solar.peak) <= end {
91 let peakDate = date(solar.peak)
92 let here = local.first { abs($0.key.timeIntervalSince(peakDate)) < 6 * 3600 }?.value
93 var detail: String
94 var visible = false
95 if let here, here.peak.altitude > 0 {
96 visible = true
97 detail = String(format: "From here: %@, %.0f%% of the Sun covered at %@.", eclipseKind(here.kind).lowercased(), here.obscuration * 100, date(here.peak.time).formatted(date: .omitted, time: .shortened))
98 } else {
99 detail = "Not visible from here."
100 }
101 if solar.kind == ECLIPSE_TOTAL || solar.kind == ECLIPSE_ANNULAR {
102 detail += String(format: " Greatest eclipse at %.1f°%@, %.1f°%@.", abs(solar.latitude), solar.latitude >= 0 ? "N" : "S", abs(solar.longitude), solar.longitude >= 0 ? "E" : "W")
103 }
104 out.append(SkyEvent(kind: .solarEclipse, date: peakDate, title: "\(eclipseKind(solar.kind)) solar eclipse", detail: detail, visibleHere: visible))
105 solar = Astronomy_NextGlobalSolarEclipse(solar.peak)
106 }
107
108 // Oppositions of the outer planets
109 for body in [Body.mars, .jupiter, .saturn, .uranus, .neptune] {
110 let r = Astronomy_SearchRelativeLongitude(body.astro, 0, time(start))
111 guard r.status == ASTRO_SUCCESS, date(r.time) <= end else { continue }
112 out.append(SkyEvent(kind: .opposition, date: date(r.time), title: "\(body.rawValue.capitalized) at opposition", detail: "Opposite the Sun: up all night and at its closest and brightest for the year.", visibleHere: nil))
113 }
114
115 // Greatest elongations of Mercury and Venus
116 for body in [Body.mercury, .venus] {
117 var from = time(start)
118 while true {
119 let e = Astronomy_SearchMaxElongation(body.astro, from)
120 guard e.status == ASTRO_SUCCESS, date(e.time) <= end else { break }
121 let when = e.visibility == VISIBLE_MORNING ? "morning" : "evening"
122 out.append(SkyEvent(kind: .elongation, date: date(e.time), title: "\(body.rawValue.capitalized) at greatest \(when) elongation", detail: String(format: "%.0f° from the Sun, best placed in the %@ sky.", e.elongation, when), visibleHere: nil))
123 from = Astronomy_AddDays(e.time, 10)
124 }
125 }
126
127 // Equinoxes and solstices
128 let cal = Calendar(identifier: .gregorian)
129 let y0 = cal.component(.year, from: start)
130 for y in y0...(y0 + 1) {
131 let s = Astronomy_Seasons(Int32(y))
132 guard s.status == ASTRO_SUCCESS else { continue }
133 for (t, name) in [(s.mar_equinox, "March equinox"), (s.jun_solstice, "June solstice"), (s.sep_equinox, "September equinox"), (s.dec_solstice, "December solstice")] {
134 let d = date(t)
135 if d >= start && d <= end { out.append(SkyEvent(kind: .season, date: d, title: name, detail: "", visibleHere: nil)) }
136 }
137 }
138
139 // Meteor showers, with how much moonlight there is at peak
140 var utc = Calendar(identifier: .gregorian)
141 utc.timeZone = TimeZone(identifier: "UTC")!
142 for y in y0...(y0 + 1) {
143 for m in MeteorShower.major {
144 guard let d = utc.date(from: DateComponents(year: y, month: m.month, day: m.day, hour: 12)), d >= start, d <= end else { continue }
145 let lit = Astronomy_Illumination(BODY_MOON, time(d)).phase_fraction
146 out.append(SkyEvent(kind: .meteorShower, date: d, title: "\(m.name) peak", detail: String(format: "Up to about %d meteors an hour under dark skies. Moon %.0f%% lit.", m.zhr, lit * 100), visibleHere: nil))
147 }
148 }
149
150 return out.sorted { $0.date < $1.date }
151 }
152}
Packages/MoonriseKit/Tests/SkyEngineTests/EventsTests.swift added +35
@@ -0,0 +1,35 @@
1import Foundation
2import Testing
3@testable import SkyEngine
4
5func utc(_ s: String) -> Date { try! Date(s, strategy: .iso8601) }
6
7@Test func august2027TotalEclipseIsTotalFromLuxor() throws {
8 let luxor = Observer(latitude: 25.69, longitude: 32.64)
9 let events = SkyEngine.events(from: utc("2027-07-01T00:00:00Z"), days: 60, observer: luxor)
10 let eclipse = try #require(events.first { $0.kind == .solarEclipse })
11 #expect(eclipse.title == "Total solar eclipse")
12 #expect(abs(eclipse.date.timeIntervalSince(utc("2027-08-02T10:07:00Z"))) < 3600)
13 #expect(eclipse.visibleHere == true)
14 #expect(eclipse.detail.contains("100%"))
15}
16
17@Test func august2026EclipseIsPartialFromChicagoOrNotVisible() throws {
18 let events = SkyEngine.events(from: utc("2026-08-01T00:00:00Z"), days: 30, observer: chicago)
19 let eclipse = try #require(events.first { $0.kind == .solarEclipse })
20 #expect(eclipse.title == "Total solar eclipse")
21 #expect(abs(eclipse.date.timeIntervalSince(utc("2026-08-12T17:46:00Z"))) < 3600)
22}
23
24@Test func saturnOpposition2026() throws {
25 let events = SkyEngine.events(from: utc("2026-09-01T00:00:00Z"), days: 60, observer: chicago)
26 let opp = try #require(events.first { $0.title == "Saturn at opposition" })
27 #expect(abs(opp.date.timeIntervalSince(utc("2026-10-04T12:00:00Z"))) < 2 * 86_400)
28}
29
30@Test func yearOfEventsIsSortedAndHasEachKind() {
31 let events = SkyEngine.events(from: utc("2026-10-18T00:00:00Z"), observer: chicago)
32 #expect(events == events.sorted { $0.date < $1.date })
33 let kinds = Set(events.map(\.kind))
34 #expect(kinds.isSuperset(of: [.moonPhase, .lunarEclipse, .solarEclipse, .opposition, .elongation, .season, .meteorShower]))
35}