M1: object cards, time scrubber, night mode !3

merged merged by cmc on 2026-10-18 01:54 UTC · krz/moonrise:m1-interaction into main

8 files changed, +324 −28

Layout: unified · split

App/Sources/SkyModel.swift +47 −12
@@ -4,8 +4,32 @@ import Observation
44import SkyEngine
55import SkyRenderer
66
7/// Current place and the scene computed for it. Scenes are built off the main
8/// thread one at a time, since Astronomy Engine is not thread-safe.
7/// Serializes Astronomy Engine calls, which are not thread-safe.
8actor Astronomer {
9 func scene(at date: Date, from observer: Observer) throws -> SkyScene {
10 try SkyScene(date: date, observer: observer)
11 }
12
13 func details(of body: Body, at date: Date, from observer: Observer) throws -> BodyDetails {
14 let h = try SkyEngine.horizontal(body, at: date, from: observer, refraction: true)
15 let eq = try SkyEngine.equatorial(body, at: date, from: observer)
16 let i = try SkyEngine.illumination(body, at: date)
17 let rs = SkyEngine.riseSet(body, after: date, from: observer)
18 return BodyDetails(body: body, position: h, distanceAU: eq.distance, magnitude: i.magnitude, phaseFraction: i.phaseFraction, rise: rs.rise, set: rs.set)
19 }
20}
21
22struct BodyDetails: Sendable {
23 var body: Body
24 var position: Horizontal
25 var distanceAU: Double
26 var magnitude: Double
27 var phaseFraction: Double
28 var rise: Date?
29 var set: Date?
30}
31
32/// Current place, the displayed time and the scene computed for them.
933@MainActor
1034@Observable
1135final class SkyModel: NSObject, CLLocationManagerDelegate {
@@ -16,9 +40,14 @@ final class SkyModel: NSObject, CLLocationManagerDelegate {
1640 private(set) var hasLocation = false
1741 private(set) var scene: SkyScene?
1842 private(set) var lastBuild: Duration = .zero
43 /// Hours from now shown by the time scrubber.
44 var offsetHours = 0.0 { didSet { refresh() } }
45 var displayedDate: Date { .now.addingTimeInterval(offsetHours * 3600) }
1946
47 let astronomer = Astronomer()
2048 private let manager = CLLocationManager()
2149 private var building = false
50 private var pending = false
2251
2352 override init() {
2453 super.init()
@@ -32,22 +61,28 @@ final class SkyModel: NSObject, CLLocationManagerDelegate {
3261 refresh()
3362 }
3463
35 func refresh(at date: Date = .now) {
36 guard !building else { return }
64 /// Rebuilds the scene for the displayed time. Requests made during a build
65 /// collapse into one rebuild when it finishes.
66 func refresh() {
67 guard !building else { pending = true; return }
3768 building = true
38 let observer = observer
39 Task.detached(priority: .userInitiated) {
69 let date = displayedDate, observer = observer, astronomer = astronomer
70 Task {
4071 let clock = ContinuousClock()
4172 var scene: SkyScene?
42 let elapsed = clock.measure { scene = try? SkyScene(date: date, observer: observer) }
43 await MainActor.run {
44 self.scene = scene
45 self.lastBuild = elapsed
46 self.building = false
47 }
73 let start = clock.now
74 scene = try? await astronomer.scene(at: date, from: observer)
75 lastBuild = clock.now - start
76 if let scene { self.scene = scene }
77 building = false
78 if pending { pending = false; refresh() }
4879 }
4980 }
5081
82 func details(of body: Body) async -> BodyDetails? {
83 try? await astronomer.details(of: body, at: displayedDate, from: observer)
84 }
85
5186 nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
5287 guard let c = locations.last?.coordinate else { return }
5388 Task { @MainActor in
App/Sources/SkyScreen.swift +123 −13
@@ -1,3 +1,4 @@
1import SkyEngine
12import SkyRenderer
23import SwiftUI
34
@@ -6,25 +7,42 @@ struct SkyScreen: View {
67 @State private var azimuth = 180.0
78 @State private var altitude = 45.0
89 @State private var fieldOfView = 100.0
10 @State private var night = false
11 @State private var selection: SkyObject?
12 @State private var details: BodyDetails?
913 @GestureState private var drag: CGSize = .zero
1014 @GestureState private var zoom: CGFloat = 1
1115 private let stats = DrawStats()
1216
17 private var style: SkyStyle { night ? .night : SkyStyle() }
18 private var ink: Color { night ? Color(red: 0.85, green: 0.12, blue: 0.08) : Color(red: 0.95, green: 0.95, blue: 0.93) }
19
1320 var body: some View {
1421 GeometryReader { geo in
15 ZStack(alignment: .topLeading) {
16 Color(red: 0.043, green: 0.078, blue: 0.141).ignoresSafeArea()
22 ZStack {
23 style.background.ignoresSafeArea()
1724 if let scene = model.scene {
1825 let view = current(width: geo.size.width)
19 SkyView(scene: scene, centerAzimuth: view.azimuth, centerAltitude: view.altitude, fieldOfView: view.fieldOfView) { [stats] d in
26 SkyView(scene: scene, centerAzimuth: view.azimuth, centerAltitude: view.altitude, fieldOfView: view.fieldOfView, style: style) { [stats] d in
2027 stats.record(d)
2128 }
2229 .ignoresSafeArea()
2330 .gesture(panGesture(width: geo.size.width).simultaneously(with: zoomGesture))
31 .onTapGesture(coordinateSpace: .local) { location in
32 let p = Projection(centerAzimuth: view.azimuth, centerAltitude: view.altitude, fieldOfView: view.fieldOfView, size: geo.size)
33 select(scene.object(at: location, projection: p))
34 }
35 }
36 VStack(spacing: 0) {
37 header
38 Spacer()
39 if let selection { card(for: selection).padding(.horizontal, 16).padding(.bottom, 10) }
40 scrubber
2441 }
25 header
2642 }
2743 }
44 .foregroundStyle(ink)
45 .onChange(of: model.offsetHours) { select(selection) }
2846 .task {
2947 model.start()
3048 while !Task.isCancelled {
@@ -34,6 +52,13 @@ struct SkyScreen: View {
3452 }
3553 }
3654
55 private func select(_ object: SkyObject?) {
56 selection = object
57 details = nil
58 guard case .body(let body) = object else { return }
59 Task { details = await model.details(of: body) }
60 }
61
3762 /// The view direction with any in-progress gestures applied.
3863 private func current(width: CGFloat) -> (azimuth: Double, altitude: Double, fieldOfView: Double) {
3964 let fov = min(max(fieldOfView / Double(zoom), 30), 140)
@@ -44,7 +69,7 @@ struct SkyScreen: View {
4469 }
4570
4671 private func panGesture(width: CGFloat) -> some Gesture {
47 DragGesture(minimumDistance: 0)
72 DragGesture(minimumDistance: 6)
4873 .updating($drag) { value, state, _ in state = value.translation }
4974 .onEnded { value in
5075 let degreesPerPoint = fieldOfView / Double(max(width, 1))
@@ -60,19 +85,104 @@ struct SkyScreen: View {
6085 }
6186
6287 private var header: some View {
63 VStack(alignment: .leading, spacing: 4) {
64 Text("Tonight").font(.system(size: 22, weight: .medium))
65 Text(model.hasLocation ? "Your location" : "Chicago (location unavailable)")
66 .font(.system(size: 11)).foregroundStyle(.secondary)
67 TimelineView(.periodic(from: .now, by: 1)) { _ in
68 Text(stats.summary(build: model.lastBuild))
69 .font(.system(size: 10).monospacedDigit()).foregroundStyle(.secondary)
88 HStack(alignment: .top) {
89 VStack(alignment: .leading, spacing: 4) {
90 Text(model.offsetHours == 0 ? "Tonight" : "Sky at \(model.displayedDate.formatted(date: .omitted, time: .shortened))")
91 .font(.system(size: 22, weight: .medium))
92 Text(model.hasLocation ? "Your location" : "Chicago (location unavailable)")
93 .font(.system(size: 11)).opacity(0.7)
94 TimelineView(.periodic(from: .now, by: 1)) { _ in
95 Text(stats.summary(build: model.lastBuild))
96 .font(.system(size: 10).monospacedDigit()).opacity(0.7)
97 }
98 }
99 Spacer()
100 Button {
101 night.toggle()
102 } label: {
103 Image(systemName: night ? "eye.fill" : "eye")
104 .font(.system(size: 17))
105 .frame(width: 44, height: 44)
70106 }
107 .accessibilityLabel(night ? "Turn off night mode" : "Turn on night mode")
71108 }
72 .foregroundStyle(Color(red: 0.95, green: 0.95, blue: 0.93))
73109 .padding(.horizontal, 20)
74110 .padding(.top, 8)
75111 }
112
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
129 @ViewBuilder
130 private func card(for object: SkyObject) -> some View {
131 VStack(alignment: .leading, spacing: 8) {
132 HStack {
133 Text(title(for: object)).font(.system(size: 20, weight: .medium))
134 Spacer()
135 Button { selection = nil } label: { Image(systemName: "xmark").frame(width: 32, height: 32) }
136 .accessibilityLabel("Close")
137 }
138 switch object {
139 case .body:
140 if let d = details { bodyFacts(d) } else { ProgressView().tint(ink) }
141 case .constellation(let id):
142 Text("Constellation · \(id)").font(.system(size: 13)).opacity(0.7)
143 }
144 }
145 .padding(16)
146 .background(style.background.opacity(0.92), in: .rect(cornerRadius: 12))
147 .overlay(RoundedRectangle(cornerRadius: 12).stroke(ink.opacity(0.25), lineWidth: 0.5))
148 }
149
150 private func title(for object: SkyObject) -> String {
151 switch object {
152 case .body(let b): b.rawValue.capitalized
153 case .constellation(let id): constellationNames[id] ?? id
154 }
155 }
156
157 private func bodyFacts(_ d: BodyDetails) -> some View {
158 let time: (Date?) -> String = { $0?.formatted(date: .omitted, time: .shortened) ?? "–" }
159 let distance = d.body == .moon
160 ? "\(Int((d.distanceAU * 149_597_870.7).rounded()).formatted()) km"
161 : String(format: "%.2f AU", d.distanceAU)
162 let rows: [(String, String)] = [
163 ("Altitude", String(format: "%.0f°", d.position.altitude)),
164 ("Direction", String(format: "%.0f° %@", d.position.azimuth, compass(d.position.azimuth))),
165 ("Magnitude", String(format: "%.1f", d.magnitude)),
166 (d.body == .moon ? "Illuminated" : "Phase", String(format: "%.0f%%", d.phaseFraction * 100)),
167 ("Distance", distance),
168 ("Next rise", time(d.rise)),
169 ("Next set", time(d.set)),
170 ]
171 return Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 4) {
172 ForEach(rows, id: \.0) { row in
173 GridRow {
174 Text(row.0).opacity(0.7)
175 Text(row.1).monospacedDigit()
176 }
177 .font(.system(size: 13))
178 }
179 }
180 }
181
182 private func compass(_ azimuth: Double) -> String {
183 let points = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
184 return points[Int(((azimuth + 22.5) / 45).rounded(.down)) % 8]
185 }
76186}
77187
78188/// Draw timings for the debug readout. Written from the Canvas closure, read once a second.
Packages/MoonriseKit/Sources/SkyEngine/SkyEngine.swift +24
@@ -122,6 +122,30 @@ public enum SkyEngine {
122122 return Illumination(magnitude: i.mag, phaseFraction: i.phase_fraction)
123123 }
124124
125 static func date(_ t: astro_time_t) -> Date {
126 Date(timeIntervalSince1970: 946_728_000 + t.ut * 86_400)
127 }
128
129 /// Next rise and next set within `limitDays` after `date`; nil if none in that window.
130 public static func riseSet(_ body: Body, after date: Date, from observer: Observer, limitDays: Double = 1) -> (rise: Date?, set: Date?) {
131 let t = time(date)
132 let rise = Astronomy_SearchRiseSetEx(body.astro, observer.astro, DIRECTION_RISE, t, limitDays, 0)
133 let set = Astronomy_SearchRiseSetEx(body.astro, observer.astro, DIRECTION_SET, t, limitDays, 0)
134 return (rise.status == ASTRO_SUCCESS ? Self.date(rise.time) : nil,
135 set.status == ASTRO_SUCCESS ? Self.date(set.time) : nil)
136 }
137
138 public enum MoonQuarter: Int, Sendable {
139 case new, firstQuarter, full, lastQuarter
140 }
141
142 /// The first lunar quarter after `date`.
143 public static func nextMoonQuarter(after date: Date) throws -> (quarter: MoonQuarter, date: Date) {
144 let q = Astronomy_SearchMoonQuarter(time(date))
145 try check(q.status)
146 return (MoonQuarter(rawValue: Int(q.quarter))!, Self.date(q.time))
147 }
148
125149 /// Moon's ecliptic longitude east of the sun in degrees: 0 new, 90 first quarter, 180 full, 270 last quarter.
126150 public static func moonPhase(at date: Date) throws -> Double {
127151 let a = Astronomy_MoonPhase(time(date))
Packages/MoonriseKit/Sources/SkyRenderer/ConstellationNames.swift added +21
@@ -0,0 +1,21 @@
1/// IAU abbreviations to names.
2public let constellationNames: [String: String] = [
3 "And": "Andromeda", "Ant": "Antlia", "Aps": "Apus", "Aqr": "Aquarius", "Aql": "Aquila",
4 "Ara": "Ara", "Ari": "Aries", "Aur": "Auriga", "Boo": "Boötes", "Cae": "Caelum",
5 "Cam": "Camelopardalis", "Cnc": "Cancer", "CVn": "Canes Venatici", "CMa": "Canis Major", "CMi": "Canis Minor",
6 "Cap": "Capricornus", "Car": "Carina", "Cas": "Cassiopeia", "Cen": "Centaurus", "Cep": "Cepheus",
7 "Cet": "Cetus", "Cha": "Chamaeleon", "Cir": "Circinus", "Col": "Columba", "Com": "Coma Berenices",
8 "CrA": "Corona Australis", "CrB": "Corona Borealis", "Crv": "Corvus", "Crt": "Crater", "Cru": "Crux",
9 "Cyg": "Cygnus", "Del": "Delphinus", "Dor": "Dorado", "Dra": "Draco", "Equ": "Equuleus",
10 "Eri": "Eridanus", "For": "Fornax", "Gem": "Gemini", "Gru": "Grus", "Her": "Hercules",
11 "Hor": "Horologium", "Hya": "Hydra", "Hyi": "Hydrus", "Ind": "Indus", "Lac": "Lacerta",
12 "Leo": "Leo", "LMi": "Leo Minor", "Lep": "Lepus", "Lib": "Libra", "Lup": "Lupus",
13 "Lyn": "Lynx", "Lyr": "Lyra", "Men": "Mensa", "Mic": "Microscopium", "Mon": "Monoceros",
14 "Mus": "Musca", "Nor": "Norma", "Oct": "Octans", "Oph": "Ophiuchus", "Ori": "Orion",
15 "Pav": "Pavo", "Peg": "Pegasus", "Per": "Perseus", "Phe": "Phoenix", "Pic": "Pictor",
16 "Psc": "Pisces", "PsA": "Piscis Austrinus", "Pup": "Puppis", "Pyx": "Pyxis", "Ret": "Reticulum",
17 "Sge": "Sagitta", "Sgr": "Sagittarius", "Sco": "Scorpius", "Scl": "Sculptor", "Sct": "Scutum",
18 "Ser": "Serpens", "Sex": "Sextans", "Tau": "Taurus", "Tel": "Telescopium", "Tri": "Triangulum",
19 "TrA": "Triangulum Australe", "Tuc": "Tucana", "UMa": "Ursa Major", "UMi": "Ursa Minor", "Vel": "Vela",
20 "Vir": "Virgo", "Vol": "Volans", "Vul": "Vulpecula",
21]
Packages/MoonriseKit/Sources/SkyRenderer/HitTest.swift added +34
@@ -0,0 +1,34 @@
1import CoreGraphics
2import SkyEngine
3
4public enum SkyObject: Sendable, Equatable {
5 case body(Body)
6 /// IAU abbreviation.
7 case constellation(String)
8}
9
10extension SkyScene {
11 /// The object under a tap: the nearest visible sun, moon or planet within
12 /// `radius` points, else the constellation whose figure has a star nearest the tap.
13 public func object(at point: CGPoint, projection p: Projection, radius: CGFloat = 28) -> SkyObject? {
14 func distance(_ h: Horizontal) -> CGFloat? {
15 guard h.altitude > 0, let q = p.point(h) else { return nil }
16 return hypot(q.x - point.x, q.y - point.y)
17 }
18 let bodyHit = bodies
19 .filter { $0.magnitude <= 6 }
20 .compactMap { b in distance(b.position).map { (b.body, $0) } }
21 .filter { $0.1 <= radius }
22 .min { $0.1 < $1.1 }
23 if let bodyHit { return .body(bodyHit.0) }
24
25 var best: (String, CGFloat)?
26 for c in Catalog.constellations {
27 for hr in c.lines.joined() {
28 guard let h = starsByHR[hr], let d = distance(h), d <= radius * 1.5 else { continue }
29 if best == nil || d < best!.1 { best = (c.id, d) }
30 }
31 }
32 return best.map { .constellation($0.0) }
33 }
34}
Packages/MoonriseKit/Sources/SkyRenderer/SkyView.swift +24 −3
@@ -39,11 +39,31 @@ public struct SkyStyle: Sendable {
3939 public var muted = Color(red: 0.60, green: 0.64, blue: 0.70)
4040 public var moonLight = Color(red: 0.93, green: 0.91, blue: 0.85)
4141 public var moonDark = Color(red: 0.16, green: 0.20, blue: 0.27)
42 public var planet = Color(red: 0.95, green: 0.84, blue: 0.60)
43 /// Draw stars in a single red, for dark-adapted eyes.
44 public var monochromeStars: Color?
4245
4346 public init() {}
4447
48 /// Red on black, to keep night vision.
49 public static let night: SkyStyle = {
50 var s = SkyStyle()
51 let red = Color(red: 0.85, green: 0.12, blue: 0.08)
52 s.background = .black
53 s.ground = Color(red: 0.06, green: 0, blue: 0)
54 s.line = red.opacity(0.35)
55 s.label = red
56 s.muted = red.opacity(0.7)
57 s.moonLight = red
58 s.moonDark = Color(red: 0.12, green: 0, blue: 0)
59 s.planet = red
60 s.monochromeStars = red
61 return s
62 }()
63
4564 /// Star tint from B−V colour index, kept pale.
4665 func starColor(_ bv: Double) -> Color {
66 if let monochromeStars { return monochromeStars }
4767 let t = min(max((bv + 0.3) / 2.0, 0), 1)
4868 return Color(red: 0.80 + 0.20 * t, green: 0.86 - 0.06 * t, blue: 1.0 - 0.32 * t)
4969 }
@@ -54,12 +74,13 @@ public struct SkyView: View {
5474 public var centerAzimuth: Double
5575 public var centerAltitude: Double
5676 public var fieldOfView: Double
57 public var style = SkyStyle()
77 public var style: SkyStyle
5878 /// Called after each draw with the time the draw took.
5979 public var onDraw: (@Sendable (Duration) -> Void)?
6080
61 public init(scene: SkyScene, centerAzimuth: Double = 180, centerAltitude: Double = 55, fieldOfView: Double = 110, onDraw: (@Sendable (Duration) -> Void)? = nil) {
81 public init(scene: SkyScene, centerAzimuth: Double = 180, centerAltitude: Double = 55, fieldOfView: Double = 110, style: SkyStyle = SkyStyle(), onDraw: (@Sendable (Duration) -> Void)? = nil) {
6282 self.scene = scene
83 self.style = style
6384 self.centerAzimuth = centerAzimuth
6485 self.centerAltitude = centerAltitude
6586 self.fieldOfView = fieldOfView
@@ -113,7 +134,7 @@ public struct SkyView: View {
113134 label(&context, "Moon \(Int((b.phaseFraction * 100).rounded()))%", at: CGPoint(x: pt.x - 20, y: pt.y), anchor: .trailing)
114135 } else {
115136 let r = max(2.0, 3.6 - 0.35 * b.magnitude)
116 context.fill(Path(ellipseIn: CGRect(x: pt.x - r, y: pt.y - r, width: 2 * r, height: 2 * r)), with: .color(Color(red: 0.95, green: 0.84, blue: 0.60)))
137 context.fill(Path(ellipseIn: CGRect(x: pt.x - r, y: pt.y - r, width: 2 * r, height: 2 * r)), with: .color(style.planet))
117138 var tick = Path()
118139 tick.move(to: CGPoint(x: pt.x + r + 3, y: pt.y))
119140 tick.addLine(to: CGPoint(x: pt.x + r + 15, y: pt.y))
Packages/MoonriseKit/Tests/SkyEngineTests/EventTests.swift added +31
@@ -0,0 +1,31 @@
1import Foundation
2import Testing
3@testable import SkyEngine
4
5// Rise and set times from JPL Horizons (R_T_S_ONLY=TVH, refracted, minute resolution),
6// Chicago, searching 2026-10-17 00:00 to 2026-10-18 00:00 UT.
7
8let midnightUT = Date(timeIntervalSince1970: 1_792_195_200) // 2026-10-17T00:00:00Z
9
10func ut(_ hour: Int, _ minute: Int) -> Date {
11 midnightUT.addingTimeInterval(TimeInterval(hour * 3600 + minute * 60))
12}
13
14@Test(arguments: [
15 (Body.sun, ut(12, 6), ut(23, 6)),
16 (Body.saturn, ut(22, 39), ut(10, 58)),
17 (Body.moon, ut(18, 57), ut(2, 49)),
18])
19func riseSetMatchesHorizons(_ body: Body, _ rise: Date, _ set: Date) throws {
20 let r = SkyEngine.riseSet(body, after: midnightUT, from: chicago)
21 let gotRise = try #require(r.rise)
22 let gotSet = try #require(r.set)
23 #expect(abs(gotRise.timeIntervalSince(rise)) < 120, "\(body) rise \(gotRise)")
24 #expect(abs(gotSet.timeIntervalSince(set)) < 120, "\(body) set \(gotSet)")
25}
26
27@Test func nextQuarterAfterObservationIsFirstQuarter() throws {
28 let q = try SkyEngine.nextMoonQuarter(after: observed)
29 #expect(q.quarter == .firstQuarter)
30 #expect(q.date > observed && q.date < observed.addingTimeInterval(4 * 86_400))
31}
Packages/MoonriseKit/Tests/SkyRendererTests/SkyRenderTests.swift +20
@@ -42,3 +42,23 @@ let evening = Date(timeIntervalSince1970: 1_792_202_400) // 2026-10-17T02:00:00Z
4242 #expect(CGImageDestinationFinalize(dest))
4343 }
4444}
45
46@Test func tapOnSaturnFindsSaturnAndNearbyStarsFindTheirConstellation() throws {
47 let scene = try SkyScene(date: evening, observer: chicago)
48 let p = Projection(centerAzimuth: 180, centerAltitude: 45, fieldOfView: 100, size: CGSize(width: 393, height: 852))
49 let saturn = try #require(scene.bodies.first { $0.body == .saturn })
50 let pt = try #require(p.point(saturn.position))
51 #expect(scene.object(at: CGPoint(x: pt.x + 5, y: pt.y), projection: p) == .body(.saturn))
52
53 // Altair (HR 7557) is in Aquila's figure.
54 let altair = try #require(scene.starsByHR[7557])
55 let a = try #require(p.point(altair))
56 #expect(scene.object(at: a, projection: p) == .constellation("Aql"))
57}
58
59@MainActor
60@Test func nightStyleRenders() throws {
61 let scene = try SkyScene(date: evening, observer: chicago)
62 let renderer = ImageRenderer(content: SkyView(scene: scene, style: .night).frame(width: 393, height: 852))
63 #expect(renderer.cgImage != nil)
64}