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

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}