M1: object cards, time scrubber, night mode !3
8 files changed, +324 −28
Layout: unified · split
App/Sources/SkyModel.swift +47 −12
| @@ -4,8 +4,32 @@ import Observation | |||
| 4 | import SkyEngine | 4 | import SkyEngine |
| 5 | import SkyRenderer | 5 | import SkyRenderer |
| 6 | 6 | ||
| 7 | /// Current place and the scene computed for it. Scenes are built off the main | 7 | /// Serializes Astronomy Engine calls, which are not thread-safe. |
| 8 | /// thread one at a time, since Astronomy Engine is not thread-safe. | 8 | actor 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 | |||
| 22 | struct 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. | ||
| 9 | @MainActor | 33 | @MainActor |
| 10 | @Observable | 34 | @Observable |
| 11 | final class SkyModel: NSObject, CLLocationManagerDelegate { | 35 | final class SkyModel: NSObject, CLLocationManagerDelegate { |
| @@ -16,9 +40,14 @@ final class SkyModel: NSObject, CLLocationManagerDelegate { | |||
| 16 | private(set) var hasLocation = false | 40 | private(set) var hasLocation = false |
| 17 | private(set) var scene: SkyScene? | 41 | private(set) var scene: SkyScene? |
| 18 | private(set) var lastBuild: Duration = .zero | 42 | 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) } | ||
| 19 | 46 | ||
| 47 | let astronomer = Astronomer() | ||
| 20 | private let manager = CLLocationManager() | 48 | private let manager = CLLocationManager() |
| 21 | private var building = false | 49 | private var building = false |
| 50 | private var pending = false | ||
| 22 | 51 | ||
| 23 | override init() { | 52 | override init() { |
| 24 | super.init() | 53 | super.init() |
| @@ -32,22 +61,28 @@ final class SkyModel: NSObject, CLLocationManagerDelegate { | |||
| 32 | refresh() | 61 | refresh() |
| 33 | } | 62 | } |
| 34 | 63 | ||
| 35 | func refresh(at date: Date = .now) { | 64 | /// Rebuilds the scene for the displayed time. Requests made during a build |
| 36 | guard !building else { return } | 65 | /// collapse into one rebuild when it finishes. |
| 66 | func refresh() { | ||
| 67 | guard !building else { pending = true; return } | ||
| 37 | building = true | 68 | building = true |
| 38 | let observer = observer | 69 | let date = displayedDate, observer = observer, astronomer = astronomer |
| 39 | Task.detached(priority: .userInitiated) { | 70 | Task { |
| 40 | let clock = ContinuousClock() | 71 | let clock = ContinuousClock() |
| 41 | var scene: SkyScene? | 72 | var scene: SkyScene? |
| 42 | let elapsed = clock.measure { scene = try? SkyScene(date: date, observer: observer) } | 73 | let start = clock.now |
| 43 | await MainActor.run { | 74 | scene = try? await astronomer.scene(at: date, from: observer) |
| 44 | self.scene = scene | 75 | lastBuild = clock.now - start |
| 45 | self.lastBuild = elapsed | 76 | if let scene { self.scene = scene } |
| 46 | self.building = false | 77 | building = false |
| 47 | } | 78 | if pending { pending = false; refresh() } |
| 48 | } | 79 | } |
| 49 | } | 80 | } |
| 50 | 81 | ||
| 82 | func details(of body: Body) async -> BodyDetails? { | ||
| 83 | try? await astronomer.details(of: body, at: displayedDate, from: observer) | ||
| 84 | } | ||
| 85 | |||
| 51 | nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { | 86 | nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { |
| 52 | guard let c = locations.last?.coordinate else { return } | 87 | guard let c = locations.last?.coordinate else { return } |
| 53 | Task { @MainActor in | 88 | Task { @MainActor in |
App/Sources/SkyScreen.swift +123 −13
| @@ -1,3 +1,4 @@ | |||
| 1 | import SkyEngine | ||
| 1 | import SkyRenderer | 2 | import SkyRenderer |
| 2 | import SwiftUI | 3 | import SwiftUI |
| 3 | 4 | ||
| @@ -6,25 +7,42 @@ struct SkyScreen: View { | |||
| 6 | @State private var azimuth = 180.0 | 7 | @State private var azimuth = 180.0 |
| 7 | @State private var altitude = 45.0 | 8 | @State private var altitude = 45.0 |
| 8 | @State private var fieldOfView = 100.0 | 9 | @State private var fieldOfView = 100.0 |
| 10 | @State private var night = false | ||
| 11 | @State private var selection: SkyObject? | ||
| 12 | @State private var details: BodyDetails? | ||
| 9 | @GestureState private var drag: CGSize = .zero | 13 | @GestureState private var drag: CGSize = .zero |
| 10 | @GestureState private var zoom: CGFloat = 1 | 14 | @GestureState private var zoom: CGFloat = 1 |
| 11 | private let stats = DrawStats() | 15 | private let stats = DrawStats() |
| 12 | 16 | ||
| 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 | |||
| 13 | var body: some View { | 20 | var body: some View { |
| 14 | GeometryReader { geo in | 21 | GeometryReader { geo in |
| 15 | ZStack(alignment: .topLeading) { | 22 | ZStack { |
| 16 | Color(red: 0.043, green: 0.078, blue: 0.141).ignoresSafeArea() | 23 | style.background.ignoresSafeArea() |
| 17 | if let scene = model.scene { | 24 | if let scene = model.scene { |
| 18 | let view = current(width: geo.size.width) | 25 | 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 |
| 20 | stats.record(d) | 27 | stats.record(d) |
| 21 | } | 28 | } |
| 22 | .ignoresSafeArea() | 29 | .ignoresSafeArea() |
| 23 | .gesture(panGesture(width: geo.size.width).simultaneously(with: zoomGesture)) | 30 | .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 | ||
| 24 | } | 41 | } |
| 25 | header | ||
| 26 | } | 42 | } |
| 27 | } | 43 | } |
| 44 | .foregroundStyle(ink) | ||
| 45 | .onChange(of: model.offsetHours) { select(selection) } | ||
| 28 | .task { | 46 | .task { |
| 29 | model.start() | 47 | model.start() |
| 30 | while !Task.isCancelled { | 48 | while !Task.isCancelled { |
| @@ -34,6 +52,13 @@ struct SkyScreen: View { | |||
| 34 | } | 52 | } |
| 35 | } | 53 | } |
| 36 | 54 | ||
| 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 | |||
| 37 | /// The view direction with any in-progress gestures applied. | 62 | /// The view direction with any in-progress gestures applied. |
| 38 | private func current(width: CGFloat) -> (azimuth: Double, altitude: Double, fieldOfView: Double) { | 63 | private func current(width: CGFloat) -> (azimuth: Double, altitude: Double, fieldOfView: Double) { |
| 39 | let fov = min(max(fieldOfView / Double(zoom), 30), 140) | 64 | let fov = min(max(fieldOfView / Double(zoom), 30), 140) |
| @@ -44,7 +69,7 @@ struct SkyScreen: View { | |||
| 44 | } | 69 | } |
| 45 | 70 | ||
| 46 | private func panGesture(width: CGFloat) -> some Gesture { | 71 | private func panGesture(width: CGFloat) -> some Gesture { |
| 47 | DragGesture(minimumDistance: 0) | 72 | DragGesture(minimumDistance: 6) |
| 48 | .updating($drag) { value, state, _ in state = value.translation } | 73 | .updating($drag) { value, state, _ in state = value.translation } |
| 49 | .onEnded { value in | 74 | .onEnded { value in |
| 50 | let degreesPerPoint = fieldOfView / Double(max(width, 1)) | 75 | let degreesPerPoint = fieldOfView / Double(max(width, 1)) |
| @@ -60,19 +85,104 @@ struct SkyScreen: View { | |||
| 60 | } | 85 | } |
| 61 | 86 | ||
| 62 | private var header: some View { | 87 | private var header: some View { |
| 63 | VStack(alignment: .leading, spacing: 4) { | 88 | HStack(alignment: .top) { |
| 64 | Text("Tonight").font(.system(size: 22, weight: .medium)) | 89 | VStack(alignment: .leading, spacing: 4) { |
| 65 | Text(model.hasLocation ? "Your location" : "Chicago (location unavailable)") | 90 | Text(model.offsetHours == 0 ? "Tonight" : "Sky at \(model.displayedDate.formatted(date: .omitted, time: .shortened))") |
| 66 | .font(.system(size: 11)).foregroundStyle(.secondary) | 91 | .font(.system(size: 22, weight: .medium)) |
| 67 | TimelineView(.periodic(from: .now, by: 1)) { _ in | 92 | Text(model.hasLocation ? "Your location" : "Chicago (location unavailable)") |
| 68 | Text(stats.summary(build: model.lastBuild)) | 93 | .font(.system(size: 11)).opacity(0.7) |
| 69 | .font(.system(size: 10).monospacedDigit()).foregroundStyle(.secondary) | 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) | ||
| 70 | } | 106 | } |
| 107 | .accessibilityLabel(night ? "Turn off night mode" : "Turn on night mode") | ||
| 71 | } | 108 | } |
| 72 | .foregroundStyle(Color(red: 0.95, green: 0.95, blue: 0.93)) | ||
| 73 | .padding(.horizontal, 20) | 109 | .padding(.horizontal, 20) |
| 74 | .padding(.top, 8) | 110 | .padding(.top, 8) |
| 75 | } | 111 | } |
| 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 | } | ||
| 76 | } | 186 | } |
| 77 | 187 | ||
| 78 | /// Draw timings for the debug readout. Written from the Canvas closure, read once a second. | 188 | /// 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 { | |||
| 122 | return Illumination(magnitude: i.mag, phaseFraction: i.phase_fraction) | 122 | return Illumination(magnitude: i.mag, phaseFraction: i.phase_fraction) |
| 123 | } | 123 | } |
| 124 | 124 | ||
| 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 | |||
| 125 | /// Moon's ecliptic longitude east of the sun in degrees: 0 new, 90 first quarter, 180 full, 270 last quarter. | 149 | /// Moon's ecliptic longitude east of the sun in degrees: 0 new, 90 first quarter, 180 full, 270 last quarter. |
| 126 | public static func moonPhase(at date: Date) throws -> Double { | 150 | public static func moonPhase(at date: Date) throws -> Double { |
| 127 | let a = Astronomy_MoonPhase(time(date)) | 151 | let a = Astronomy_MoonPhase(time(date)) |
Packages/MoonriseKit/Sources/SkyRenderer/ConstellationNames.swift added +21
| @@ -0,0 +1,21 @@ | |||
| 1 | /// IAU abbreviations to names. | ||
| 2 | public 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 @@ | |||
| 1 | import CoreGraphics | ||
| 2 | import SkyEngine | ||
| 3 | |||
| 4 | public enum SkyObject: Sendable, Equatable { | ||
| 5 | case body(Body) | ||
| 6 | /// IAU abbreviation. | ||
| 7 | case constellation(String) | ||
| 8 | } | ||
| 9 | |||
| 10 | extension 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 { | |||
| 39 | public var muted = Color(red: 0.60, green: 0.64, blue: 0.70) | 39 | public var muted = Color(red: 0.60, green: 0.64, blue: 0.70) |
| 40 | public var moonLight = Color(red: 0.93, green: 0.91, blue: 0.85) | 40 | public var moonLight = Color(red: 0.93, green: 0.91, blue: 0.85) |
| 41 | public var moonDark = Color(red: 0.16, green: 0.20, blue: 0.27) | 41 | 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? | ||
| 42 | 45 | ||
| 43 | public init() {} | 46 | public init() {} |
| 44 | 47 | ||
| 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 | |||
| 45 | /// Star tint from B−V colour index, kept pale. | 64 | /// Star tint from B−V colour index, kept pale. |
| 46 | func starColor(_ bv: Double) -> Color { | 65 | func starColor(_ bv: Double) -> Color { |
| 66 | if let monochromeStars { return monochromeStars } | ||
| 47 | let t = min(max((bv + 0.3) / 2.0, 0), 1) | 67 | let t = min(max((bv + 0.3) / 2.0, 0), 1) |
| 48 | return Color(red: 0.80 + 0.20 * t, green: 0.86 - 0.06 * t, blue: 1.0 - 0.32 * t) | 68 | return Color(red: 0.80 + 0.20 * t, green: 0.86 - 0.06 * t, blue: 1.0 - 0.32 * t) |
| 49 | } | 69 | } |
| @@ -54,12 +74,13 @@ public struct SkyView: View { | |||
| 54 | public var centerAzimuth: Double | 74 | public var centerAzimuth: Double |
| 55 | public var centerAltitude: Double | 75 | public var centerAltitude: Double |
| 56 | public var fieldOfView: Double | 76 | public var fieldOfView: Double |
| 57 | public var style = SkyStyle() | 77 | public var style: SkyStyle |
| 58 | /// Called after each draw with the time the draw took. | 78 | /// Called after each draw with the time the draw took. |
| 59 | public var onDraw: (@Sendable (Duration) -> Void)? | 79 | public var onDraw: (@Sendable (Duration) -> Void)? |
| 60 | 80 | ||
| 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) { |
| 62 | self.scene = scene | 82 | self.scene = scene |
| 83 | self.style = style | ||
| 63 | self.centerAzimuth = centerAzimuth | 84 | self.centerAzimuth = centerAzimuth |
| 64 | self.centerAltitude = centerAltitude | 85 | self.centerAltitude = centerAltitude |
| 65 | self.fieldOfView = fieldOfView | 86 | self.fieldOfView = fieldOfView |
| @@ -113,7 +134,7 @@ public struct SkyView: View { | |||
| 113 | label(&context, "Moon \(Int((b.phaseFraction * 100).rounded()))%", at: CGPoint(x: pt.x - 20, y: pt.y), anchor: .trailing) | 134 | label(&context, "Moon \(Int((b.phaseFraction * 100).rounded()))%", at: CGPoint(x: pt.x - 20, y: pt.y), anchor: .trailing) |
| 114 | } else { | 135 | } else { |
| 115 | let r = max(2.0, 3.6 - 0.35 * b.magnitude) | 136 | 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)) |
| 117 | var tick = Path() | 138 | var tick = Path() |
| 118 | tick.move(to: CGPoint(x: pt.x + r + 3, y: pt.y)) | 139 | tick.move(to: CGPoint(x: pt.x + r + 3, y: pt.y)) |
| 119 | tick.addLine(to: CGPoint(x: pt.x + r + 15, y: pt.y)) | 140 | tick.addLine(to: CGPoint(x: pt.x + r + 15, y: pt.y)) |
Packages/MoonriseKit/Tests/SkyEngineTests/EventTests.swift added +31
| @@ -0,0 +1,31 @@ | |||
| 1 | import Foundation | ||
| 2 | import 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 | |||
| 8 | let midnightUT = Date(timeIntervalSince1970: 1_792_195_200) // 2026-10-17T00:00:00Z | ||
| 9 | |||
| 10 | func 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 | ]) | ||
| 19 | func 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 | |||
| 42 | #expect(CGImageDestinationFinalize(dest)) | 42 | #expect(CGImageDestinationFinalize(dest)) |
| 43 | } | 43 | } |
| 44 | } | 44 | } |
| 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 | } | ||