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 8631c80826

8631c80826aeb1ab8ff026bd26a2cef7587307db

parent: d92796e659

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-17 23:40 UTC

Add Today sheet with moon, planets, sun and space weather

- SkyEngine: Tonight (darkness window from now or next sunset to
  sunrise, moon phase and next quarter, naked-eye planets with best
  altitude sampled every 15 min).
- Feeds: SpaceWeather client for SWPC 1-minute Kp and the 3-day Kp
  forecast; G-scale mapping.
- App: pull-up sheet holding the time scrubber, a one-line summary and
  Moon, Planets tonight, Sun and Space weather sections. Kp refreshed
  every 10 minutes. Object card moved to the top.

Layout: unified · split

App/Sources/SkyModel.swift +20
@@ -1,4 +1,5 @@
11import CoreLocation
2import Feeds
23import Foundation
34import Observation
45import SkyEngine
@@ -17,6 +18,10 @@ actor Astronomer {
1718 let rs = SkyEngine.riseSet(body, after: date, from: observer)
1819 return BodyDetails(body: body, position: h, distanceAU: eq.distance, magnitude: i.magnitude, phaseFraction: i.phaseFraction, rise: rs.rise, set: rs.set)
1920 }
21
22 func tonight(at date: Date, from observer: Observer) throws -> Tonight {
23 try Tonight(at: date, from: observer)
24 }
2025}
2126
2227struct BodyDetails: Sendable {
@@ -40,6 +45,9 @@ final class SkyModel: NSObject, CLLocationManagerDelegate {
4045 private(set) var hasLocation = false
4146 private(set) var scene: SkyScene?
4247 private(set) var lastBuild: Duration = .zero
48 private(set) var tonight: Tonight?
49 private(set) var spaceWeather: SpaceWeather?
50 private var weatherFetched: Date?
4351 /// Hours from now shown by the time scrubber.
4452 var offsetHours = 0.0 { didSet { refresh() } }
4553 var displayedDate: Date { .now.addingTimeInterval(offsetHours * 3600) }
@@ -74,11 +82,23 @@ final class SkyModel: NSObject, CLLocationManagerDelegate {
7482 scene = try? await astronomer.scene(at: date, from: observer)
7583 lastBuild = clock.now - start
7684 if let scene { self.scene = scene }
85 if offsetHours == 0 { tonight = try? await astronomer.tonight(at: date, from: observer) }
7786 building = false
7887 if pending { pending = false; refresh() }
7988 }
8089 }
8190
91 /// SWPC asks for no more than once a minute; this checks every 10.
92 func refreshSpaceWeather() async {
93 if let weatherFetched, Date.now.timeIntervalSince(weatherFetched) < 600 { return }
94 do {
95 spaceWeather = try await SpaceWeather.fetch()
96 weatherFetched = .now
97 } catch {
98 log.error("Space weather failed: \(error.localizedDescription, privacy: .public)")
99 }
100 }
101
82102 func details(of body: Body) async -> BodyDetails? {
83103 try? await astronomer.details(of: body, at: displayedDate, from: observer)
84104 }
App/Sources/SkyScreen.swift +7 −18
@@ -35,13 +35,18 @@ struct SkyScreen: View {
3535 }
3636 VStack(spacing: 0) {
3737 header
38 if let selection { card(for: selection).padding(.horizontal, 16).padding(.top, 8) }
3839 Spacer()
39 if let selection { card(for: selection).padding(.horizontal, 16).padding(.bottom, 10) }
40 scrubber
4140 }
4241 }
4342 }
4443 .foregroundStyle(ink)
44 .sheet(isPresented: .constant(true)) {
45 TodaySheet(model: model, ink: ink, background: style.ground)
46 .presentationDetents([.height(110), .medium, .large])
47 .presentationBackgroundInteraction(.enabled(upThrough: .medium))
48 .interactiveDismissDisabled()
49 }
4550 .onChange(of: model.offsetHours) { select(selection) }
4651 .task {
4752 model.start()
@@ -110,22 +115,6 @@ struct SkyScreen: View {
110115 .padding(.top, 8)
111116 }
112117
113 private var scrubber: some View {
114 HStack(spacing: 12) {
115 Text(model.displayedDate.formatted(date: .omitted, time: .shortened))
116 .font(.system(size: 13).monospacedDigit())
117 .frame(width: 70, alignment: .leading)
118 Slider(value: $model.offsetHours, in: -12...12, step: 0.25)
119 .tint(ink)
120 Button("Now") { model.offsetHours = 0 }
121 .font(.system(size: 13, weight: .medium))
122 .disabled(model.offsetHours == 0)
123 }
124 .padding(.horizontal, 20)
125 .padding(.vertical, 12)
126 .background(style.ground.opacity(0.9))
127 }
128
129118 @ViewBuilder
130119 private func card(for object: SkyObject) -> some View {
131120 VStack(alignment: .leading, spacing: 8) {
App/Sources/TodaySheet.swift added +142
@@ -0,0 +1,142 @@
1import Feeds
2import SkyEngine
3import SwiftUI
4
5/// Pull-up sheet over the sky: time scrubber, then tonight's summary.
6struct TodaySheet: View {
7 @Bindable var model: SkyModel
8 var ink: Color
9 var background: Color
10
11 var body: some View {
12 ScrollView {
13 VStack(alignment: .leading, spacing: 0) {
14 scrubber
15 Text(summary).font(.system(size: 13)).opacity(0.7).padding(.bottom, 16)
16 if let t = model.tonight {
17 section("Moon") { moon(t) }
18 section("Planets tonight") { planets(t) }
19 section("Sun") { sun(t) }
20 }
21 section("Space weather") { weather }
22 Text("Space weather data: NOAA Space Weather Prediction Center.")
23 .font(.system(size: 11)).opacity(0.5).padding(.top, 8)
24 }
25 .padding(.horizontal, 20)
26 .padding(.top, 18)
27 }
28 .foregroundStyle(ink)
29 .presentationBackground(background)
30 .task {
31 while !Task.isCancelled {
32 await model.refreshSpaceWeather()
33 try? await Task.sleep(for: .seconds(600))
34 }
35 }
36 }
37
38 private var summary: String {
39 var parts: [String] = []
40 if let t = model.tonight {
41 parts.append("Moon \(Int((t.moonIllumination * 100).rounded()))%")
42 let up = t.planets.filter { $0.best != nil }.map { $0.body.rawValue.capitalized }
43 parts.append(up.isEmpty ? "No bright planets tonight" : up.joined(separator: ", "))
44 }
45 if let w = model.spaceWeather { parts.append(String(format: "Kp %.1f", w.kpNow)) }
46 return parts.joined(separator: " · ")
47 }
48
49 private var scrubber: some View {
50 HStack(spacing: 12) {
51 Text(model.displayedDate.formatted(date: .omitted, time: .shortened))
52 .font(.system(size: 13).monospacedDigit())
53 .frame(width: 70, alignment: .leading)
54 Slider(value: $model.offsetHours, in: -12...12, step: 0.25).tint(ink)
55 Button("Now") { model.offsetHours = 0 }
56 .font(.system(size: 13, weight: .medium))
57 .opacity(model.offsetHours == 0 ? 0.4 : 1)
58 }
59 .padding(.bottom, 10)
60 }
61
62 private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View {
63 VStack(alignment: .leading, spacing: 8) {
64 Text(title).font(.system(size: 11, weight: .medium)).textCase(.uppercase).tracking(0.8).opacity(0.6)
65 content()
66 }
67 .frame(maxWidth: .infinity, alignment: .leading)
68 .padding(.vertical, 12)
69 .overlay(alignment: .top) { Rectangle().fill(ink.opacity(0.25)).frame(height: 0.5) }
70 }
71
72 private func moon(_ t: Tonight) -> some View {
73 let quarter = switch t.nextQuarter.quarter {
74 case .new: "New moon"
75 case .firstQuarter: "First quarter"
76 case .full: "Full moon"
77 case .lastQuarter: "Last quarter"
78 }
79 return rows([
80 ("Illuminated", "\(Int((t.moonIllumination * 100).rounded()))%, \(t.moonPhase < 180 ? "waxing" : "waning")"),
81 (quarter, t.nextQuarter.date.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute())),
82 ])
83 }
84
85 private func planets(_ t: Tonight) -> some View {
86 rows(t.planets.map { p in
87 let name = p.body.rawValue.capitalized
88 guard let best = p.best else { return (name, "not well placed tonight") }
89 var parts: [String] = []
90 if p.now.altitude > 0 {
91 parts.append(String(format: "up now, %.0f° %@", p.now.altitude, compass(p.now.azimuth)))
92 } else if let rise = p.rise, rise < t.end {
93 parts.append("rises \(time(rise))")
94 }
95 // A best point at the end of the window means it is still climbing at dawn.
96 if t.end.timeIntervalSince(best.date) < 900 {
97 parts.append(String(format: "%.0f° %@ by dawn", best.position.altitude, compass(best.position.azimuth)))
98 } else {
99 parts.append(String(format: "highest %@ at %.0f°", time(best.date), best.position.altitude))
100 }
101 return (name, parts.joined(separator: " · "))
102 })
103 }
104
105 private func sun(_ t: Tonight) -> some View {
106 rows([
107 ("Sunset", t.sunset.map(time) ?? "already set"),
108 ("Sunrise", t.sunrise.map(time) ?? "–"),
109 ])
110 }
111
112 @ViewBuilder
113 private var weather: some View {
114 if let w = model.spaceWeather {
115 rows([
116 ("Kp now", String(format: "%.1f%@", w.kpNow, SpaceWeather.gScale(w.kpNow).map { " (G\($0) storm)" } ?? "")),
117 ("Next 24 h", w.kpNext24h.map { String(format: "up to Kp %.1f%@", $0, SpaceWeather.gScale($0).map { " (G\($0))" } ?? "") } ?? "–"),
118 ("Updated", time(w.observedAt)),
119 ])
120 } else {
121 Text("Loading").font(.system(size: 13)).opacity(0.6)
122 }
123 }
124
125 private func rows(_ items: [(String, String)]) -> some View {
126 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
127 ForEach(items, id: \.0) { item in
128 GridRow {
129 Text(item.0).opacity(0.7)
130 Text(item.1).monospacedDigit()
131 }
132 .font(.system(size: 14))
133 }
134 }
135 }
136
137 private func time(_ d: Date) -> String { d.formatted(date: .omitted, time: .shortened) }
138
139 private func compass(_ azimuth: Double) -> String {
140 ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][Int(((azimuth + 22.5) / 45).rounded(.down)) % 8]
141 }
142}
App/project.yml +2
@@ -16,6 +16,8 @@ targets:
1616 product: SkyEngine
1717 - package: MoonriseKit
1818 product: SkyRenderer
19 - package: MoonriseKit
20 product: Feeds
1921 entitlements:
2022 path: Sources/Moonrise.entitlements
2123 properties:
Packages/MoonriseKit/Package.swift +3
@@ -7,6 +7,7 @@ let package = Package(
77 products: [
88 .library(name: "SkyEngine", targets: ["SkyEngine"]),
99 .library(name: "SkyRenderer", targets: ["SkyRenderer"]),
10 .library(name: "Feeds", targets: ["Feeds"]),
1011 ],
1112 targets: [
1213 // Astronomy Engine v2.1.19 (MIT), https://github.com/cosinekitty/astronomy
@@ -21,7 +22,9 @@ let package = Package(
2122 dependencies: ["SkyEngine"],
2223 resources: [.copy("Resources/bsc5.json"), .copy("Resources/constellations.json")]
2324 ),
25 .target(name: "Feeds"),
2426 .testTarget(name: "SkyEngineTests", dependencies: ["SkyEngine"]),
27 .testTarget(name: "FeedsTests", dependencies: ["Feeds"]),
2528 .testTarget(name: "SkyRendererTests", dependencies: ["SkyRenderer"]),
2629 ]
2730)
Packages/MoonriseKit/Sources/Feeds/SpaceWeather.swift added +55
@@ -0,0 +1,55 @@
1import Foundation
2
3/// Planetary Kp from NOAA SWPC (US government data).
4public struct SpaceWeather: Sendable, Equatable {
5 /// Latest 1-minute estimated Kp.
6 public var kpNow: Double
7 public var observedAt: Date
8 /// Highest predicted 3-hour Kp in the next 24 hours, if any.
9 public var kpNext24h: Double?
10
11 public static let nowURL = URL(string: "https://services.swpc.noaa.gov/json/planetary_k_index_1m.json")!
12 public static let forecastURL = URL(string: "https://services.swpc.noaa.gov/products/noaa-planetary-k-index-forecast.json")!
13
14 struct Minute: Decodable {
15 var time_tag: String
16 var estimated_kp: Double
17 }
18
19 struct Forecast: Decodable {
20 var time_tag: String
21 var kp: Double
22 var observed: String
23 }
24
25 /// SWPC time tags are UTC without a zone designator.
26 static func parse(_ tag: String) -> Date? {
27 try? Date(tag + "Z", strategy: .iso8601)
28 }
29
30 public static func decode(now: Data, forecast: Data, at date: Date) throws -> SpaceWeather {
31 let minutes = try JSONDecoder().decode([Minute].self, from: now)
32 guard let last = minutes.last, let observedAt = parse(last.time_tag) else {
33 throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "empty Kp feed"))
34 }
35 let rows = try JSONDecoder().decode([Forecast].self, from: forecast)
36 let horizon = date.addingTimeInterval(24 * 3600)
37 let next = rows
38 .filter { $0.observed == "predicted" }
39 .filter { row in parse(row.time_tag).map { $0 >= date.addingTimeInterval(-3 * 3600) && $0 <= horizon } ?? false }
40 .map(\.kp)
41 .max()
42 return SpaceWeather(kpNow: last.estimated_kp, observedAt: observedAt, kpNext24h: next)
43 }
44
45 public static func fetch(session: URLSession = .shared, at date: Date = .now) async throws -> SpaceWeather {
46 async let now = session.data(from: nowURL).0
47 async let forecast = session.data(from: forecastURL).0
48 return try decode(now: await now, forecast: await forecast, at: date)
49 }
50
51 /// NOAA G-scale for a Kp value: G1 at Kp 5 through G5 at Kp 9.
52 public static func gScale(_ kp: Double) -> Int? {
53 kp >= 5 ? min(Int(kp.rounded(.down)) - 4, 5) : nil
54 }
55}
Packages/MoonriseKit/Sources/SkyEngine/Tonight.swift added +61
@@ -0,0 +1,61 @@
1import Foundation
2
3public struct PlanetTonight: Sendable {
4 public var body: Body
5 public var magnitude: Double
6 public var now: Horizontal
7 /// Highest point during the night, if it gets above 10°.
8 public var best: (date: Date, position: Horizontal)?
9 public var rise: Date?
10 public var set: Date?
11}
12
13public struct Tonight: Sendable {
14 /// Darkness window: from now if it is already dark, else from the next sunset.
15 public var start: Date
16 public var end: Date
17 public var sunset: Date?
18 public var sunrise: Date?
19 public var moonIllumination: Double
20 public var moonPhase: Double
21 public var nextQuarter: (quarter: SkyEngine.MoonQuarter, date: Date)
22 /// Naked-eye planets, those visible tonight first, brightest first.
23 public var planets: [PlanetTonight]
24
25 public init(at date: Date, from observer: Observer) throws {
26 let sunNow = try SkyEngine.horizontal(.sun, at: date, from: observer, refraction: true)
27 let sunToday = SkyEngine.riseSet(.sun, after: date, from: observer)
28 if sunNow.altitude < -0.8 {
29 start = date
30 sunset = nil
31 } else {
32 sunset = sunToday.set
33 start = sunToday.set ?? date
34 }
35 sunrise = SkyEngine.riseSet(.sun, after: start, from: observer).rise
36 end = sunrise ?? start.addingTimeInterval(12 * 3600)
37
38 moonIllumination = try SkyEngine.illumination(.moon, at: date).phaseFraction
39 moonPhase = try SkyEngine.moonPhase(at: date)
40 nextQuarter = try SkyEngine.nextMoonQuarter(after: date)
41
42 let samples = stride(from: start.timeIntervalSince1970, through: end.timeIntervalSince1970, by: 900).map(Date.init(timeIntervalSince1970:))
43 planets = try [Body.mercury, .venus, .mars, .jupiter, .saturn].map { body in
44 var best: (Date, Horizontal)?
45 for s in samples {
46 let h = try SkyEngine.horizontal(body, at: s, from: observer, refraction: true)
47 if h.altitude > 10, h.altitude > best?.1.altitude ?? -90 { best = (s, h) }
48 }
49 let rs = SkyEngine.riseSet(body, after: date, from: observer)
50 return PlanetTonight(
51 body: body,
52 magnitude: try SkyEngine.illumination(body, at: date).magnitude,
53 now: try SkyEngine.horizontal(body, at: date, from: observer, refraction: true),
54 best: best.map { (date: $0.0, position: $0.1) },
55 rise: rs.rise,
56 set: rs.set
57 )
58 }
59 .sorted { ($0.best != nil ? 0 : 1, $0.magnitude) < ($1.best != nil ? 0 : 1, $1.magnitude) }
60 }
61}
Packages/MoonriseKit/Tests/FeedsTests/SpaceWeatherTests.swift added +30
@@ -0,0 +1,30 @@
1import Foundation
2import Testing
3@testable import Feeds
4
5// Shapes copied from the live SWPC feeds on 2026-10-17.
6let minutes = Data("""
7[{"time_tag": "2026-10-17T23:32:00", "kp_index": 0, "estimated_kp": 0.33, "kp": "0P"},
8 {"time_tag": "2026-10-17T23:33:00", "kp_index": 0, "estimated_kp": 0.67, "kp": "1M"}]
9""".utf8)
10
11let forecast = Data("""
12[{"time_tag": "2026-10-17T18:00:00", "kp": 2.33, "observed": "observed", "noaa_scale": null},
13 {"time_tag": "2026-10-18T00:00:00", "kp": 3.67, "observed": "predicted", "noaa_scale": null},
14 {"time_tag": "2026-10-18T09:00:00", "kp": 5.33, "observed": "predicted", "noaa_scale": "G1"},
15 {"time_tag": "2026-10-19T21:00:00", "kp": 7.0, "observed": "predicted", "noaa_scale": "G3"}]
16""".utf8)
17
18@Test func decodesLatestKpAndNext24hMaximum() throws {
19 let at = try #require(SpaceWeather.parse("2026-10-17T23:34:00"))
20 let w = try SpaceWeather.decode(now: minutes, forecast: forecast, at: at)
21 #expect(w.kpNow == 0.67)
22 #expect(w.observedAt == SpaceWeather.parse("2026-10-17T23:33:00"))
23 #expect(w.kpNext24h == 5.33)
24}
25
26@Test func gScale() {
27 #expect(SpaceWeather.gScale(4.67) == nil)
28 #expect(SpaceWeather.gScale(5.33) == 1)
29 #expect(SpaceWeather.gScale(9) == 5)
30}
Packages/MoonriseKit/Tests/SkyEngineTests/TonightTests.swift added +24
@@ -0,0 +1,24 @@
1import Foundation
2import Testing
3@testable import SkyEngine
4
5@Test func tonightFromDarkStartsNowAndEndsAtSunrise() throws {
6 // 2026-10-17 02:00 UT is 21:00 CDT on the 16th: already dark in Chicago.
7 let t = try Tonight(at: observed, from: chicago)
8 #expect(t.start == observed)
9 #expect(t.sunset == nil)
10 let sunrise = try #require(t.sunrise)
11 #expect(abs(sunrise.timeIntervalSince(ut(12, 6))) < 120)
12 let saturn = try #require(t.planets.first { $0.body == .saturn })
13 #expect(saturn.best != nil)
14 #expect(t.planets.first?.best != nil)
15}
16
17@Test func tonightFromAfternoonStartsAtSunset() throws {
18 // 2026-10-17 18:00 UT is 13:00 CDT.
19 let afternoon = ut(18, 0)
20 let t = try Tonight(at: afternoon, from: chicago)
21 let sunset = try #require(t.sunset)
22 #expect(abs(sunset.timeIntervalSince(ut(23, 6))) < 120)
23 #expect(t.start == sunset)
24}