Packages/MoonriseKit/Sources/SkyRenderer/Orrery.swift
146 lines · 6830 bytes
1import Foundation
2import SkyEngine
3import SwiftUI
4
5/// SkyEngine's Body, named so it isn't shadowed by View.Body.
6typealias SolarBody = Body
7
8/// Deep-space probes with heliocentric ecliptic positions every 30 days to 2050,
9/// from JPL Horizons (REF_PLANE ECLIPTIC, CENTER 500@10), fetched 2026-10-17.
10public struct Spacecraft: Sendable, Identifiable, Decodable {
11 public var id: Int
12 public var name: String
13 public var launch: String
14 /// [days since 1970-01-01, x AU, y AU, z AU]
15 public var points: [[Double]]
16
17 public static let all: [Spacecraft] = {
18 let url = Bundle.module.url(forResource: "spacecraft", withExtension: "json")!
19 return try! JSONDecoder().decode([Spacecraft].self, from: Data(contentsOf: url))
20 }()
21
22 var firstDay: Double { points.first?[0] ?? 0 }
23
24 /// Linearly interpolated position on the ecliptic plane, or nil before the data starts.
25 public func position(at date: Date) -> SIMD2<Double>? {
26 position3(at: date).map { SIMD2($0.x, $0.y) }
27 }
28
29 /// Linearly interpolated position in AU, or nil before the data starts.
30 public func position3(at date: Date) -> SIMD3<Double>? {
31 let d = date.timeIntervalSince1970 / 86_400
32 guard let first = points.first, d >= first[0] else { return nil }
33 let v: ([Double]) -> SIMD3<Double> = { SIMD3($0[1], $0[2], $0[3]) }
34 guard let i = points.firstIndex(where: { $0[0] >= d }) else { return points.last.map(v) }
35 if i == 0 { return v(first) }
36 let a = points[i - 1], b = points[i]
37 let t = (d - a[0]) / (b[0] - a[0])
38 return v(a) + (v(b) - v(a)) * t
39 }
40
41 /// Distance from the Sun in AU.
42 public func distance(at date: Date) -> Double? {
43 position3(at: date).map { ($0 * $0).sum().squareRoot() }
44 }
45
46 /// Path from the start of the data up to `date`.
47 public func path(until date: Date) -> [SIMD2<Double>] {
48 let d = date.timeIntervalSince1970 / 86_400
49 var out = points.prefix { $0[0] <= d }.map { SIMD2($0[1], $0[2]) }
50 if let p = position(at: date) { out.append(p) }
51 return out
52 }
53}
54
55/// Top-down view of the solar system on ecliptic axes with a compressed
56/// radial scale so inner planets and the Voyagers fit on one screen.
57public struct OrreryScale: Sendable {
58 public var size: CGSize
59 /// Distance (AU) mapped to the edge of the view.
60 public var maxAU: Double = 230
61
62 public init(size: CGSize) { self.size = size }
63
64 /// Radius in points for a distance in AU: logarithmic beyond 1 AU, linear inside.
65 public func radius(_ au: Double) -> Double {
66 let edge = Double(min(size.width, size.height)) / 2 - 8
67 let f = au <= 1 ? au * 0.2 : 0.2 + 0.8 * log10(au) / log10(maxAU)
68 return edge * f
69 }
70
71 public func point(_ v: SIMD2<Double>) -> CGPoint {
72 let au = (v * v).sum().squareRoot()
73 guard au > 0 else { return CGPoint(x: size.width / 2, y: size.height / 2) }
74 let r = radius(au) / au
75 // Ecliptic x (vernal equinox) to the right, y up.
76 return CGPoint(x: size.width / 2 + v.x * r, y: size.height / 2 - v.y * r)
77 }
78}
79
80public struct OrreryView: View {
81 public var date: Date
82 public var ink: Color
83 public var accent: Color
84 /// Spacecraft name to draw in full; the others are drawn faint and unlabelled.
85 public var highlight: String?
86
87 public init(date: Date, ink: Color, accent: Color, highlight: String? = nil) {
88 self.date = date
89 self.ink = ink
90 self.accent = accent
91 self.highlight = highlight
92 }
93
94 /// Mean distances (AU) for orbit rings.
95 static let orbits: [(SolarBody, Double)] = [(.mercury, 0.387), (.venus, 0.723), (.sun, 1.0), (.mars, 1.524), (.jupiter, 5.203), (.saturn, 9.537), (.uranus, 19.19), (.neptune, 30.07)]
96
97 public var body: some View {
98 Canvas { context, size in
99 let s = OrreryScale(size: size)
100 let c = CGPoint(x: size.width / 2, y: size.height / 2)
101
102 for au: Double in [50, 100, 200] {
103 let r = CGFloat(s.radius(au))
104 context.stroke(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)),
105 with: .color(ink.opacity(0.12)), style: StrokeStyle(lineWidth: 0.5, dash: [2, 4]))
106 context.draw(Text("\(Int(au)) AU").font(.caption2).foregroundStyle(ink.opacity(0.6)), at: CGPoint(x: c.x, y: c.y - r - 7))
107 }
108 for (_, au) in Self.orbits {
109 let r = CGFloat(s.radius(au))
110 context.stroke(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)), with: .color(ink.opacity(0.25)), lineWidth: 0.5)
111 }
112 context.fill(Path(ellipseIn: CGRect(x: c.x - 4, y: c.y - 4, width: 8, height: 8)), with: .color(Color(red: 0.95, green: 0.75, blue: 0.35)))
113
114 for craft in Spacecraft.all {
115 let path = craft.path(until: date)
116 guard let last = path.last else { continue }
117 let faint = highlight != nil && craft.name != highlight
118 var line = Path()
119 line.move(to: s.point(path[0]))
120 for v in path.dropFirst() { line.addLine(to: s.point(v)) }
121 context.stroke(line, with: .color(accent.opacity(faint ? 0.2 : 0.7)), lineWidth: faint ? 0.5 : 0.8)
122 let p = s.point(last)
123 context.fill(Path(ellipseIn: CGRect(x: p.x - 2.5, y: p.y - 2.5, width: 5, height: 5)), with: .color(accent.opacity(faint ? 0.3 : 1)))
124 if !faint {
125 context.draw(Text(craft.name).font(.caption2).foregroundStyle(accent), at: CGPoint(x: p.x + 6, y: p.y), anchor: .leading)
126 }
127 }
128
129 for body in [SolarBody.mercury, .venus, .mars, .jupiter, .saturn, .uranus, .neptune] {
130 guard let v = try? SkyEngine.heliocentricEcliptic(body, at: date) else { continue }
131 let p = s.point(SIMD2(v.x, v.y))
132 let r: CGFloat = [.jupiter, .saturn].contains(body) ? 4 : 3
133 context.fill(Path(ellipseIn: CGRect(x: p.x - r, y: p.y - r, width: 2 * r, height: 2 * r)), with: .color(ink))
134 if [.jupiter, .saturn, .uranus, .neptune].contains(body) {
135 context.draw(Text(body.rawValue.capitalized).font(.caption2).foregroundStyle(ink.opacity(0.8)), at: CGPoint(x: p.x, y: p.y + 10))
136 }
137 }
138 if let earth = try? SkyEngine.heliocentricEarth(at: date) {
139 let p = s.point(SIMD2(earth.x, earth.y))
140 context.fill(Path(ellipseIn: CGRect(x: p.x - 3, y: p.y - 3, width: 6, height: 6)), with: .color(Color(red: 0.35, green: 0.6, blue: 0.95)))
141 }
142 }
143 .aspectRatio(1, contentMode: .fit)
144 }
145
146}