Commit d92796e659
Verified · cmc
Layout: unified · split
App/Sources/SkyModel.swift +47 −12
| @@ -4,8 +4,32 @@ import Observation | ||
| 4 | 4 | import SkyEngine |
| 5 | 5 | import SkyRenderer |
| 6 | 6 | |
| 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. | |
| 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 | 33 | @MainActor |
| 10 | 34 | @Observable |
| 11 | 35 | final class SkyModel: NSObject, CLLocationManagerDelegate { |
| @@ -16,9 +40,14 @@ final class SkyModel: NSObject, CLLocationManagerDelegate { | ||
| 16 | 40 | private(set) var hasLocation = false |
| 17 | 41 | private(set) var scene: SkyScene? |
| 18 | 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 | 48 | private let manager = CLLocationManager() |
| 21 | 49 | private var building = false |
| 50 | private var pending = false | |
| 22 | 51 | |
| 23 | 52 | override init() { |
| 24 | 53 | super.init() |
| @@ -32,22 +61,28 @@ final class SkyModel: NSObject, CLLocationManagerDelegate { | ||
| 32 | 61 | refresh() |
| 33 | 62 | } |
| 34 | 63 | |
| 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 } | |
| 37 | 68 | building = true |
| 38 | let observer = observer | |
| 39 | Task.detached(priority: .userInitiated) { | |
| 69 | let date = displayedDate, observer = observer, astronomer = astronomer | |
| 70 | Task { | |
| 40 | 71 | let clock = ContinuousClock() |
| 41 | 72 | 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() } | |
| 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 | 86 | nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { |
| 52 | 87 | guard let c = locations.last?.coordinate else { return } |
| 53 | 88 | Task { @MainActor in |
App/Sources/SkyScreen.swift +123 −13
| @@ -1,3 +1,4 @@ | ||
| 1 | import SkyEngine | |
| 1 | 2 | import SkyRenderer |
| 2 | 3 | import SwiftUI |
| 3 | 4 | |
| @@ -6,25 +7,42 @@ struct SkyScreen: View { | ||
| 6 | 7 | @State private var azimuth = 180.0 |
| 7 | 8 | @State private var altitude = 45.0 |
| 8 | 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 | 13 | @GestureState private var drag: CGSize = .zero |
| 10 | 14 | @GestureState private var zoom: CGFloat = 1 |
| 11 | 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 | 20 | var body: some View { |
| 14 | 21 | 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() | |
| 17 | 24 | if let scene = model.scene { |
| 18 | 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 | 27 | stats.record(d) |
| 21 | 28 | } |
| 22 | 29 | .ignoresSafeArea() |
| 23 | 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 | 46 | .task { |
| 29 | 47 | model.start() |
| 30 | 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 | 62 | /// The view direction with any in-progress gestures applied. |
| 38 | 63 | private func current(width: CGFloat) -> (azimuth: Double, altitude: Double, fieldOfView: Double) { |
| 39 | 64 | let fov = min(max(fieldOfView / Double(zoom), 30), 140) |
| @@ -44,7 +69,7 @@ struct SkyScreen: View { | ||
| 44 | 69 | } |
| 45 | 70 | |
| 46 | 71 | private func panGesture(width: CGFloat) -> some Gesture { |
| 47 | DragGesture(minimumDistance: 0) | |
| 72 | DragGesture(minimumDistance: 6) | |
| 48 | 73 | .updating($drag) { value, state, _ in state = value.translation } |
| 49 | 74 | .onEnded { value in |
| 50 | 75 | let degreesPerPoint = fieldOfView / Double(max(width, 1)) |
| @@ -60,19 +85,104 @@ struct SkyScreen: View { | ||
| 60 | 85 | } |
| 61 | 86 | |
| 62 | 87 | 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) | |
| 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 | 109 | .padding(.horizontal, 20) |
| 74 | 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 | 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 | 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 | 149 | /// Moon's ecliptic longitude east of the sun in degrees: 0 new, 90 first quarter, 180 full, 270 last quarter. |
| 126 | 150 | public static func moonPhase(at date: Date) throws -> Double { |
| 127 | 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 | 39 | public var muted = Color(red: 0.60, green: 0.64, blue: 0.70) |
| 40 | 40 | public var moonLight = Color(red: 0.93, green: 0.91, blue: 0.85) |
| 41 | 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 | 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 | 64 | /// Star tint from B−V colour index, kept pale. |
| 46 | 65 | func starColor(_ bv: Double) -> Color { |
| 66 | if let monochromeStars { return monochromeStars } | |
| 47 | 67 | let t = min(max((bv + 0.3) / 2.0, 0), 1) |
| 48 | 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 | 74 | public var centerAzimuth: Double |
| 55 | 75 | public var centerAltitude: Double |
| 56 | 76 | public var fieldOfView: Double |
| 57 | public var style = SkyStyle() | |
| 77 | public var style: SkyStyle | |
| 58 | 78 | /// Called after each draw with the time the draw took. |
| 59 | 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 | 82 | self.scene = scene |
| 83 | self.style = style | |
| 63 | 84 | self.centerAzimuth = centerAzimuth |
| 64 | 85 | self.centerAltitude = centerAltitude |
| 65 | 86 | self.fieldOfView = fieldOfView |
| @@ -113,7 +134,7 @@ public struct SkyView: View { | ||
| 113 | 134 | label(&context, "Moon \(Int((b.phaseFraction * 100).rounded()))%", at: CGPoint(x: pt.x - 20, y: pt.y), anchor: .trailing) |
| 114 | 135 | } else { |
| 115 | 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 | 138 | var tick = Path() |
| 118 | 139 | tick.move(to: CGPoint(x: pt.x + r + 3, y: pt.y)) |
| 119 | 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 | 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 | } | |