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

Commit 2a55438cfb

2a55438cfbc3a5f49c302f0c9fc180a9a9eaf849

parent: 82b363a829

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-19 06:01 UTC

Add a 3D solar system to the System tab

Layout: unified · split

App/Globes/sun.jpg added

Binary file not shown.

App/Sources/SolarSystem3D.swift added +579
@@ -0,0 +1,579 @@
1import Feeds
2import Library
3import RealityKit
4import SkyEngine
5import SwiftUI
6import UIKit
7
8/// How distances and sizes are drawn in the 3D solar system.
9enum SolarScale: String, CaseIterable, Identifiable {
10 /// Distances raised to the power 0.4 and sizes enlarged, so everything fits and can be seen.
11 case compressed
12 /// Distances and sizes both true (1 AU = 1 scene metre); planets need markers to be found.
13 case trueScale
14 var id: String { rawValue }
15 var name: String { self == .compressed ? "Compressed" : "True scale" }
16}
17
18/// One of the Sun's planets, with what the scene needs to draw it.
19struct SolarPlanet: Identifiable, Sendable {
20 var id: String
21 var body: Body?
22 var radiusKm: Double
23 var color: String
24 var tiltDeg: Double
25 var periodDays: Double
26
27 static let all: [SolarPlanet] = [
28 ("mercury", Body.mercury, 88.0), ("venus", .venus, 224.7), ("earth", nil, 365.25), ("mars", .mars, 687.0),
29 ("jupiter", .jupiter, 4332.6), ("saturn", .saturn, 10_759.2), ("uranus", .uranus, 30_688.5), ("neptune", .neptune, 60_182.0),
30 ].compactMap { id, body, period in
31 guard let d = Library.page(id: id)?.drawing else { return nil }
32 return SolarPlanet(id: id, body: body, radiusKm: d.radiusKm ?? 6371, color: d.color ?? "#cccccc", tiltDeg: d.axialTiltDeg ?? 0, periodDays: period)
33 }
34
35 /// Heliocentric position on J2000 ecliptic axes, AU.
36 func position(at date: Date) -> SIMD3<Double> {
37 if let body { return (try? SkyEngine.heliocentricEcliptic(body, at: date)) ?? .zero }
38 return (try? SkyEngine.heliocentricEarth(at: date)) ?? .zero
39 }
40}
41
42/// Maps ecliptic AU to scene metres (y up) and gives drawn radii for each scale.
43struct SolarMapping {
44 var scale: SolarScale
45
46 static let auKm = 1.495978707e8
47 static let sunRadiusKm = 695_700.0
48
49 func point(_ v: SIMD3<Double>) -> SIMD3<Float> {
50 let r = (v * v).sum().squareRoot()
51 guard r > 0 else { return .zero }
52 let d = scale == .compressed ? 1.6 * pow(r, 0.4) : r
53 let u = v / r * d
54 return SIMD3(Float(u.x), Float(u.z), Float(-u.y))
55 }
56
57 func radius(km: Double) -> Float {
58 scale == .compressed ? Float(0.09 * pow(km / 6371, 0.45)) : Float(km / Self.auKm)
59 }
60
61 var sunRadius: Float { scale == .compressed ? 0.35 : Float(Self.sunRadiusKm / Self.auKm) }
62}
63
64/// Orbit camera around a target point.
65struct SolarCamera: Equatable {
66 var target: SIMD3<Float> = .zero
67 var yaw: Float = 0.4
68 var pitch: Float = 0.55
69 var distance: Float = 14
70 static let fovDegrees: Float = 50
71
72 var position: SIMD3<Float> {
73 target + distance * SIMD3(cos(pitch) * sin(yaw), sin(pitch), cos(pitch) * cos(yaw))
74 }
75
76 /// Screen position of a scene point, or nil if it is behind the camera.
77 func project(_ p: SIMD3<Float>, in size: CGSize) -> CGPoint? {
78 let pos = position
79 let f = simd_normalize(target - pos)
80 let r = simd_normalize(simd_cross(f, SIMD3(0, 1, 0)))
81 let u = simd_cross(r, f)
82 let v = p - pos
83 let z = simd_dot(v, f)
84 guard z > 0 else { return nil }
85 let k = Float(size.height / 2) / tan(Self.fovDegrees * .pi / 360)
86 return CGPoint(x: size.width / 2 + CGFloat(simd_dot(v, r) / z * k), y: size.height / 2 - CGFloat(simd_dot(v, u) / z * k))
87 }
88
89 func distance(to p: SIMD3<Float>) -> Float { simd_length(p - position) }
90}
91
92/// The live space weather the scene shows: a flare on the Sun and an aurora ring on Earth.
93struct SolarWeather: Equatable {
94 var kp: Double?
95 /// Strongest X-ray flux in the last three hours and when it peaked.
96 var flare: (flux: Double, time: Date)?
97
98 static func == (a: SolarWeather, b: SolarWeather) -> Bool { a.kp == b.kp && a.flare?.flux == b.flare?.flux }
99
100 var showsFlare: Bool { (flare?.flux ?? 0) >= 1e-5 }
101 var showsAurora: Bool { (kp ?? 0) >= 4 }
102
103 var summary: String {
104 var parts: [String] = []
105 if let f = flare {
106 parts.append(showsFlare
107 ? "\(XRay.flareClass(f.flux)) solar flare at \(f.time.formatted(date: .omitted, time: .shortened))"
108 : "No large flares in the last 3 h (peak \(XRay.flareClass(f.flux)))")
109 }
110 if let kp {
111 parts.append(showsAurora
112 ? String(format: "Kp %.1f: aurora ring around Earth's poles", kp)
113 : String(format: "Kp %.1f: geomagnetic field quiet", kp))
114 }
115 return parts.isEmpty ? "Loading space weather" : parts.joined(separator: " · ")
116 }
117}
118
119/// Entities created once and moved on each update.
120@MainActor
121final class SolarSceneEntities {
122 let root = Entity()
123 let camera = PerspectiveCamera()
124 let sun = ModelEntity()
125 var planets: [String: ModelEntity] = [:]
126 var nights: [String: ModelEntity] = [:]
127 var orbits: [String: ModelEntity] = [:]
128 var orbitsBuilt: (SolarScale, Float)?
129 let flare = ModelEntity()
130 let aurora = Entity()
131}
132
133struct SolarSystem3DView: View {
134 var date: Date
135 var live: Bool
136 @Binding var scale: SolarScale
137 var ink: Color
138 var accent: Color
139 var onOpen: (LibraryPage) -> Void
140
141 @State private var entities = SolarSceneEntities()
142 @State private var cam = SolarCamera()
143 @State private var dragStart: SolarCamera?
144 @State private var pinchStart: Float?
145 @State private var selected: String?
146 @State private var weather = SolarWeather()
147 @State private var flight: Task<Void, Never>?
148 @State private var pulse = false
149 @State private var viewSize = CGSize(width: 400, height: 600)
150
151 private var mapping: SolarMapping { SolarMapping(scale: scale) }
152
153 var body: some View {
154 GeometryReader { geo in
155 ZStack {
156 RealityView { content in
157 content.camera = .virtual
158 build(content)
159 } update: { _ in
160 place()
161 }
162 .gesture(dragGesture.simultaneously(with: pinchGesture))
163 .onTapGesture(coordinateSpace: .local) { tap($0, size: geo.size) }
164
165 labels(size: geo.size).allowsHitTesting(false)
166
167 VStack(spacing: 8) {
168 if let s = selected, let planet = planetOrSun(s) { card(planet) }
169 Spacer()
170 Text(live ? weather.summary : "Live space weather is shown only for today.")
171 .font(.caption2).dimmed(0.75)
172 .padding(.horizontal, 10).padding(.vertical, 6)
173 .background(Color.black.opacity(0.6), in: Capsule())
174 }
175 .padding(12)
176 }
177 .clipped()
178 .onAppear { viewSize = geo.size; cam.distance = fitDistance() }
179 .onChange(of: geo.size) { viewSize = geo.size }
180 }
181 .accessibilityElement(children: .contain)
182 .accessibilityLabel("3D solar system. Drag to turn, pinch to zoom, tap a planet to learn about it.")
183 .onChange(of: scale) { resetView() }
184 .task { await loadWeather() }
185 .task {
186 while !Task.isCancelled {
187 try? await Task.sleep(for: .milliseconds(700))
188 pulse.toggle()
189 }
190 }
191 }
192
193 // MARK: Scene
194
195 private func build(_ content: RealityViewCameraContent) {
196 let e = entities
197 content.add(e.root)
198 e.camera.camera.fieldOfViewInDegrees = SolarCamera.fovDegrees
199 e.root.addChild(e.camera)
200
201 var sunMaterial = UnlitMaterial(color: UIColor(red: 1, green: 0.86, blue: 0.5, alpha: 1))
202 if let url = Bundle.main.url(forResource: "sun", withExtension: "jpg", subdirectory: "Globes"), let t = try? TextureResource.load(contentsOf: url) {
203 sunMaterial.color = .init(tint: .white, texture: .init(t))
204 }
205 e.sun.model = ModelComponent(mesh: .generateSphere(radius: 1), materials: [sunMaterial])
206 e.root.addChild(e.sun)
207 // Glow: nested translucent shells, fainter outward.
208 for i in 1...10 {
209 let r = 1 + Float(i) * 0.07
210 let a = CGFloat(0.09 * pow(0.78, Double(i - 1)))
211 var g = UnlitMaterial(color: UIColor(red: 1, green: 0.72, blue: 0.32, alpha: a))
212 g.blending = .transparent(opacity: .init(floatLiteral: Float(a)))
213 let shell = ModelEntity(mesh: .generateSphere(radius: r), materials: [g])
214 e.sun.addChild(shell)
215 }
216
217
218 for p in SolarPlanet.all {
219 let planet = ModelEntity()
220 // Unlit, with a night-side shell turned away from the Sun, so day and night are exact
221 // without depending on scene lighting.
222 var m = UnlitMaterial()
223 if let url = GlobeView.mapURL(p.id), let texture = try? TextureResource.load(contentsOf: url) {
224 m.color = .init(texture: .init(texture))
225 } else {
226 m.color = .init(tint: UIColor(Color(hex: p.color) ?? .gray))
227 }
228 planet.model = ModelComponent(mesh: .generateSphere(radius: 1), materials: [m])
229 let night = Self.nightShell()
230 night.name = "night"
231 e.nights[p.id] = night
232 e.root.addChild(night)
233 planet.orientation = simd_quatf(angle: Float(p.tiltDeg * .pi / 180), axis: [1, 0, 0])
234 if p.id == "saturn" { planet.addChild(Self.rings(inner: 1.24, outer: 2.27)) }
235 e.planets[p.id] = planet
236 e.root.addChild(planet)
237 }
238
239 var flareMaterial = UnlitMaterial(color: UIColor(red: 1, green: 1, blue: 0.85, alpha: 0.9))
240 flareMaterial.blending = .transparent(opacity: .init(floatLiteral: 0.9))
241 e.flare.model = ModelComponent(mesh: .generateSphere(radius: 1), materials: [flareMaterial])
242 e.root.addChild(e.flare)
243 // Two faint bands per pole, a wider glow and a brighter core, just above the surface.
244 for y: Float in [0.86, -0.86] {
245 for (inner, outer, alpha) in [(0.36, 0.66, 0.22), (0.45, 0.56, 0.5)] as [(Float, Float, CGFloat)] {
246 let ring = Self.annulus(inner: inner, outer: outer, color: UIColor(red: 0.35, green: 1, blue: 0.55, alpha: alpha))
247 ring.position.y = y * 1.02
248 e.aurora.addChild(ring)
249 }
250 }
251 e.planets["earth"]?.addChild(e.aurora)
252 place()
253 }
254
255 /// Moves everything for the current date, scale and camera.
256 private func place() {
257 let e = entities
258 let map = mapping
259 e.sun.scale = .init(repeating: map.sunRadius)
260 for p in SolarPlanet.all {
261 guard let planet = e.planets[p.id] else { continue }
262 planet.position = map.point(p.position(at: date))
263 planet.scale = .init(repeating: map.radius(km: p.radiusKm))
264 if let night = e.nights[p.id] {
265 night.position = planet.position
266 night.scale = .init(repeating: map.radius(km: p.radiusKm) * 1.004)
267 // The shell's open side (+y) faces the Sun.
268 let toSun = simd_normalize(-planet.position)
269 night.orientation = simd_quatf(from: SIMD3(0, 1, 0), to: toSun)
270 }
271 }
272 rebuildOrbitsIfNeeded()
273 // Orbits are a guide at system scale; close to a planet they only cut across it.
274 let farEnough = cam.distance > map.radius(km: 70_000) * 25
275 for o in e.orbits.values { o.isEnabled = farEnough }
276
277 // Flare: a bright bulge on the side of the Sun facing Earth, pulsing.
278 if live, weather.showsFlare, let earth = e.planets["earth"] {
279 e.flare.isEnabled = true
280 let dir = simd_normalize(earth.position)
281 e.flare.position = dir * map.sunRadius * 1.02
282 e.flare.scale = .init(repeating: map.sunRadius * (pulse ? 0.22 : 0.16))
283 } else {
284 e.flare.isEnabled = false
285 }
286 e.aurora.isEnabled = live && weather.showsAurora
287
288 e.camera.look(at: cam.target, from: cam.position, relativeTo: nil)
289 e.camera.camera.near = max(cam.distance * 0.001, 1e-9)
290 e.camera.camera.far = cam.distance * 100 + 200
291 }
292
293 /// Orbit ribbons, rebuilt when the scale changes or the zoom changes enough that their width would look wrong.
294 private func rebuildOrbitsIfNeeded() {
295 let e = entities
296 let width = cam.distance * 0.0022
297 if let (s, w) = e.orbitsBuilt, s == scale, w / width < 1.6, width / w < 1.6 { return }
298 e.orbitsBuilt = (scale, width)
299 let map = mapping
300 for p in SolarPlanet.all {
301 let steps = 240
302 let points = (0..<steps).map { i in
303 map.point(p.position(at: date.addingTimeInterval(Double(i) / Double(steps) * p.periodDays * 86_400)))
304 }
305 let ribbon = Self.ribbon(points, width: width)
306 if let existing = e.orbits[p.id] {
307 existing.model?.mesh = ribbon
308 } else {
309 var m = UnlitMaterial(color: UIColor(white: 1, alpha: 1))
310 m.blending = .transparent(opacity: .init(floatLiteral: 0.22))
311 m.faceCulling = .none
312 let orbit = ModelEntity(mesh: ribbon, materials: [m])
313 e.orbits[p.id] = orbit
314 e.root.addChild(orbit)
315 }
316 }
317 }
318
319 // MARK: Meshes
320
321 /// A closed flat ribbon through `points`, lying in the plane of the orbit.
322 private static func ribbon(_ points: [SIMD3<Float>], width: Float) -> MeshResource {
323 var positions: [SIMD3<Float>] = []
324 var indices: [UInt32] = []
325 let n = points.count
326 for i in 0..<n {
327 let t = simd_normalize(points[(i + 1) % n] - points[(i + n - 1) % n])
328 var side = simd_cross(t, SIMD3(0, 1, 0))
329 if simd_length(side) < 1e-6 { side = SIMD3(1, 0, 0) }
330 side = simd_normalize(side) * width / 2
331 positions += [points[i] - side, points[i] + side]
332 let a = UInt32(2 * i), b = UInt32(2 * ((i + 1) % n))
333 indices += [a, a + 1, b, b, a + 1, b + 1]
334 }
335 var d = MeshDescriptor(name: "orbit")
336 d.positions = MeshBuffer(positions)
337 d.primitives = .triangles(indices)
338 return (try? MeshResource.generate(from: [d])) ?? .generateBox(size: 0.001)
339 }
340
341 /// A flat ring in the local x-z plane, radii in units of the parent's radius.
342 private static func annulus(inner: Float, outer: Float, color: UIColor) -> ModelEntity {
343 let n = 96
344 var positions: [SIMD3<Float>] = []
345 var indices: [UInt32] = []
346 for i in 0..<n {
347 let a = Float(i) / Float(n) * 2 * .pi
348 positions += [SIMD3(cos(a) * inner, 0, sin(a) * inner), SIMD3(cos(a) * outer, 0, sin(a) * outer)]
349 let p = UInt32(2 * i), q = UInt32(2 * ((i + 1) % n))
350 indices += [p, p + 1, q, q, p + 1, q + 1]
351 }
352 var d = MeshDescriptor(name: "ring")
353 d.positions = MeshBuffer(positions)
354 d.primitives = .triangles(indices)
355 var m = UnlitMaterial(color: color)
356 m.blending = .transparent(opacity: .init(floatLiteral: Float(color.cgColor.alpha)))
357 m.faceCulling = .none
358 return ModelEntity(mesh: (try? MeshResource.generate(from: [d])) ?? .generateBox(size: 0.001), materials: [m])
359 }
360
361 /// The half of a unit sphere below y = 0, drawn dark and mostly opaque over the planet's night side.
362 private static func nightShell() -> ModelEntity {
363 let rings = 24, segments = 64
364 var positions: [SIMD3<Float>] = []
365 var indices: [UInt32] = []
366 for i in 0...rings {
367 let lat = -Float.pi / 2 * Float(i) / Float(rings)
368 for j in 0...segments {
369 let lon = Float(j) / Float(segments) * 2 * .pi
370 positions.append(SIMD3(cos(lat) * cos(lon), sin(lat), cos(lat) * sin(lon)))
371 }
372 }
373 for i in 0..<rings {
374 for j in 0..<segments {
375 let a = UInt32(i * (segments + 1) + j), b = a + UInt32(segments + 1)
376 indices += [a, b, a + 1, a + 1, b, b + 1]
377 }
378 }
379 var d = MeshDescriptor(name: "night")
380 d.positions = MeshBuffer(positions)
381 d.primitives = .triangles(indices)
382 var m = UnlitMaterial(color: .black)
383 m.blending = .transparent(opacity: .init(floatLiteral: 0.88))
384 m.faceCulling = .none
385 return ModelEntity(mesh: (try? MeshResource.generate(from: [d])) ?? .generateBox(size: 0.001), materials: [m])
386 }
387
388 private static func rings(inner: Float, outer: Float) -> ModelEntity {
389 annulus(inner: inner, outer: outer, color: UIColor(red: 0.85, green: 0.78, blue: 0.62, alpha: 0.7))
390 }
391
392 // MARK: Labels, taps and the card
393
394 private func screenPoints(size: CGSize) -> [(id: String, point: CGPoint, radius: CGFloat)] {
395 let e = entities
396 var out: [(String, CGPoint, CGFloat)] = []
397 if let p = cam.project(.zero, in: size) { out.append(("sun", p, pixelRadius(mapping.sunRadius, at: .zero, size: size))) }
398 for planet in SolarPlanet.all {
399 guard let entity = e.planets[planet.id] else { continue }
400 let pos = mapping.point(planet.position(at: date))
401 if let p = cam.project(pos, in: size) {
402 out.append((planet.id, p, pixelRadius(mapping.radius(km: planet.radiusKm), at: pos, size: size)))
403 }
404 _ = entity
405 }
406 return out
407 }
408
409 private func pixelRadius(_ r: Float, at p: SIMD3<Float>, size: CGSize) -> CGFloat {
410 let k = Float(size.height / 2) / tan(SolarCamera.fovDegrees * .pi / 360)
411 return CGFloat(r / max(cam.distance(to: p), 1e-9) * k)
412 }
413
414 /// Points to label: larger bodies first, skipping a label that would sit on one already placed.
415 private func labelled(size: CGSize) -> [(id: String, point: CGPoint, radius: CGFloat, showName: Bool)] {
416 let order = ["sun", "jupiter", "saturn", "uranus", "neptune", "earth", "venus", "mars", "mercury"]
417 var placed: [CGPoint] = []
418 return screenPoints(size: size)
419 .sorted { (order.firstIndex(of: $0.id) ?? 99) < (order.firstIndex(of: $1.id) ?? 99) }
420 .map { item in
421 let at = CGPoint(x: item.point.x, y: item.point.y - max(item.radius, 5) - 9)
422 let clear = item.id == selected || placed.allSatisfy { abs($0.x - at.x) > 44 || abs($0.y - at.y) > 14 }
423 if clear { placed.append(at) }
424 return (item.id, item.point, item.radius, clear)
425 }
426 }
427
428 private func labels(size: CGSize) -> some View {
429 ZStack {
430 ForEach(labelled(size: size), id: \.id) { item in
431 let name = item.id == "sun" ? "Sun" : (Library.page(id: item.id)?.title ?? item.id)
432 let tiny = item.radius < 3
433 ZStack {
434 if tiny {
435 Circle().stroke(item.id == selected ? accent : ink.opacity(0.7), lineWidth: 1.2).frame(width: 10, height: 10)
436 }
437 if item.showName {
438 Text(name).font(.caption2.weight(item.id == selected ? .semibold : .regular))
439 .foregroundStyle(item.id == selected ? accent : ink.opacity(0.85))
440 .shadow(color: .black, radius: 2)
441 .offset(y: -max(item.radius, 5) - 9)
442 }
443 }
444 .position(item.point)
445 }
446 }
447 }
448
449 private func tap(_ location: CGPoint, size: CGSize) {
450 let hit = screenPoints(size: size)
451 .map { ($0.id, hypot($0.point.x - location.x, $0.point.y - location.y) - max($0.radius, 0)) }
452 .filter { $0.1 < 28 }
453 .min { $0.1 < $1.1 }
454 guard let id = hit?.0 else { selected = nil; return }
455 selected = id
456 fly(to: id)
457 }
458
459 private func planetOrSun(_ id: String) -> LibraryPage? { Library.page(id: id) }
460
461 private func card(_ page: LibraryPage) -> some View {
462 let planet = SolarPlanet.all.first { $0.id == page.id }
463 let au = planet.map { p -> Double in let v = p.position(at: date); return (v * v).sum().squareRoot() }
464 return VStack(alignment: .leading, spacing: 6) {
465 HStack {
466 Text(page.title).font(.headline)
467 Spacer()
468 Button { selected = nil; resetView() } label: {
469 Image(systemName: "xmark").font(.footnote.weight(.semibold)).frame(width: 44, height: 44).contentShape(Rectangle())
470 }
471 .buttonStyle(.plain)
472 .padding(-12)
473 .accessibilityLabel("Close")
474 }
475 Text(page.subtitle).font(.footnote).dimmed(0.75)
476 if let au {
477 Text(String(format: "%.2f AU from the Sun · sunlight takes %@", au, lightTime(au))).font(.footnote.monospacedDigit()).dimmed(0.85)
478 }
479 Button("Read more") { onOpen(page) }.font(.subheadline.weight(.medium)).foregroundStyle(accent)
480 }
481 .padding(14)
482 .background(Color(white: 0.06), in: RoundedRectangle(cornerRadius: 14))
483 .contentShape(RoundedRectangle(cornerRadius: 14))
484 .overlay(RoundedRectangle(cornerRadius: 14).stroke(ink.opacity(0.15), lineWidth: 0.5))
485 }
486
487 private func lightTime(_ au: Double) -> String {
488 let s = au * 499.004784
489 let h = Int(s / 3600), m = Int(s.truncatingRemainder(dividingBy: 3600) / 60)
490 return h > 0 ? "\(h) h \(m) min" : "\(m) min"
491 }
492
493 // MARK: Camera
494
495 private var dragGesture: some Gesture {
496 DragGesture(minimumDistance: 4)
497 .onChanged { g in
498 flight?.cancel()
499 if dragStart == nil { dragStart = cam }
500 cam.yaw = dragStart!.yaw - Float(g.translation.width) * 0.008
501 cam.pitch = min(max(dragStart!.pitch + Float(g.translation.height) * 0.008, -1.45), 1.45)
502 }
503 .onEnded { _ in dragStart = nil }
504 }
505
506 private var pinchGesture: some Gesture {
507 MagnifyGesture()
508 .onChanged { g in
509 flight?.cancel()
510 if pinchStart == nil { pinchStart = cam.distance }
511 let limits: ClosedRange<Float> = scale == .compressed ? 0.25...60 : 1e-6...200
512 cam.distance = min(max(pinchStart! / Float(g.magnification), limits.lowerBound), limits.upperBound)
513 }
514 .onEnded { _ in pinchStart = nil }
515 }
516
517 private func resetView() {
518 animateCamera(to: SolarCamera(target: .zero, yaw: cam.yaw, pitch: 0.55, distance: fitDistance()))
519 }
520
521 /// Camera distance at which Neptune's orbit fits the narrower side of the view.
522 private func fitDistance() -> Float {
523 let neptune = Float(mapping.scale == .compressed ? 1.6 * pow(30.1, 0.4) : 30.1)
524 let tanV = tan(SolarCamera.fovDegrees * .pi / 360)
525 let tanH = tanV * Float(viewSize.width / max(viewSize.height, 1))
526 return neptune * 1.22 / min(tanV, tanH)
527 }
528
529 /// Centres on a body and zooms to a few times its drawn radius.
530 private func fly(to id: String) {
531 let target: SIMD3<Float>, radius: Float
532 if id == "sun" {
533 target = .zero
534 radius = mapping.sunRadius
535 } else if let p = SolarPlanet.all.first(where: { $0.id == id }) {
536 target = mapping.point(p.position(at: date))
537 radius = mapping.radius(km: p.radiusKm) * (p.id == "saturn" ? 2.3 : 1)
538 } else { return }
539 animateCamera(to: SolarCamera(target: target, yaw: cam.yaw, pitch: max(cam.pitch, 0.25), distance: radius * 7))
540 }
541
542 /// Moves the camera over about a second, interpolating distance on a log scale.
543 private func animateCamera(to end: SolarCamera) {
544 flight?.cancel()
545 let start = cam
546 flight = Task {
547 let frames = 50
548 for f in 1...frames {
549 try? await Task.sleep(for: .milliseconds(18))
550 if Task.isCancelled { return }
551 let x = Float(f) / Float(frames)
552 let t = x < 0.5 ? 4 * x * x * x : 1 - pow(-2 * x + 2, 3) / 2
553 cam.target = start.target + (end.target - start.target) * t
554 cam.pitch = start.pitch + (end.pitch - start.pitch) * t
555 cam.distance = exp(Foundation.log(start.distance) + (Foundation.log(end.distance) - Foundation.log(start.distance)) * t)
556 }
557 }
558 }
559
560 // MARK: Weather
561
562 private func loadWeather() async {
563 while !Task.isCancelled {
564 async let kp = try? SpaceWeather.fetch()
565 async let xray = try? XRay.fetch()
566 let (k, x) = await (kp, xray)
567 var w = SolarWeather(kp: k?.kpNow)
568 if let samples = x {
569 let recent = samples.filter { $0.time > .now.addingTimeInterval(-3 * 3600) }
570 if let peak = recent.max(by: { $0.flux < $1.flux }) { w.flare = (peak.flux, peak.time) }
571 }
572 #if DEBUG
573 if UserDefaults.standard.bool(forKey: "debugSolarEvents") { w.kp = 6.3; w.flare = (2.4e-5, .now) }
574 #endif
575 weather = w
576 try? await Task.sleep(for: .seconds(600))
577 }
578 }
579}
App/Sources/SystemScreen.swift +77 −29
@@ -7,6 +7,8 @@ struct SystemScreen: View {
77 @State private var offsetDays = 0.0
88 @State private var playing = false
99 @State private var libraryPage: LibraryPage?
10 @AppStorage("systemMode") private var mode = "2d"
11 @AppStorage("solarScale") private var solarScale = SolarScale.compressed
1012 @Environment(\.dynamicTypeSize) private var typeSize
1113
1214 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
@@ -21,12 +23,85 @@ struct SystemScreen: View {
2123 private var date: Date { .now.addingTimeInterval(offsetDays * 86_400) }
2224
2325 var body: some View {
26 Group {
27 if mode == "3d" { threeD } else { twoD }
28 }
29 .foregroundStyle(ink)
30 .background(background.ignoresSafeArea())
31 .sheet(item: $libraryPage) { page in
32 NavigationStack { LibraryPageView(page: page) }
33 }
34 .task(id: playing) {
35 while playing && !Task.isCancelled {
36 try? await Task.sleep(for: .milliseconds(33))
37 offsetDays = min(offsetDays + (mode == "3d" ? 2 : 30), range.upperBound)
38 if offsetDays >= range.upperBound { playing = false }
39 }
40 }
41 }
42
43 private var modePicker: some View {
44 Picker("View", selection: $mode) {
45 Text("Map").tag("2d")
46 Text("3D").tag("3d")
47 }
48 .pickerStyle(.segmented)
49 }
50
51 private var threeD: some View {
52 VStack(alignment: .leading, spacing: 0) {
53 VStack(alignment: .leading, spacing: 0) {
54 Text("Solar system").font(.title.weight(.medium))
55 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12)
56 modePicker.padding(.bottom, 8)
57 Picker("Scale", selection: $solarScale) {
58 ForEach(SolarScale.allCases) { Text($0.name).tag($0) }
59 }
60 .pickerStyle(.segmented)
61 Text(solarScale == .compressed
62 ? "Distances squeezed and planets enlarged so everything fits. Drag to turn, pinch to zoom, tap a planet."
63 : "Real distances and sizes: the planets are specks, so circles mark them. Tap one to fly to it.")
64 .font(.caption).dimmed(0.7).padding(.top, 8)
65 }
66 .padding(.horizontal, 20)
67 SolarSystem3DView(date: date, live: abs(offsetDays) < 1, scale: $solarScale, ink: ink, accent: accent) { libraryPage = $0 }
68 .frame(maxWidth: .infinity, maxHeight: .infinity)
69 .clipped()
70 .contentShape(Rectangle())
71 timeControls
72 .padding(.horizontal, 20)
73 .padding(.vertical, 6)
74 .background(background)
75 .zIndex(1)
76 }
77 }
78
79 private var timeControls: some View {
80 HStack(spacing: 12) {
81 Button { playing.toggle() } label: {
82 Image(systemName: playing ? "pause.fill" : "play.fill").frame(width: 32, height: 32)
83 }
84 .accessibilityLabel(playing ? "Pause" : "Play")
85 Text(date.formatted(.dateTime.year().month(.abbreviated)))
86 .font(.subheadline.weight(.medium).monospacedDigit())
87 .fixedSize()
88 Slider(value: $offsetDays, in: range).tint(ink)
89 Button { playing = false; offsetDays = 0 } label: {
90 Text("Today").frame(minWidth: 44, minHeight: 44).contentShape(Rectangle())
91 }
92 .font(.footnote.weight(.medium))
93 .opacity(abs(offsetDays) < 1 ? 0.6 : 1)
94 }
95 }
96
97 private var twoD: some View {
2498 ScrollView {
2599 VStack(alignment: .leading, spacing: 0) {
26100 Text("Solar system").font(.title.weight(.medium))
27101 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12)
28102 Text("The planets, and the five spacecraft travelling out of the solar system, seen from above. Drag the slider or press play to watch them move between 1958 and 2050.")
29 .font(.subheadline).dimmed(0.75).padding(.bottom, 16)
103 .font(.subheadline).dimmed(0.75).padding(.bottom, 12)
104 modePicker.padding(.bottom, 16)
30105
31106 LiveSection(ink: ink, accent: accent)
32107
@@ -36,22 +111,7 @@ struct SystemScreen: View {
36111 .accessibilityElement()
37112 .accessibilityLabel("Orrery for \(date.formatted(.dateTime.year().month(.wide))). Planets and spacecraft distances are listed below.")
38113
39 HStack(spacing: 12) {
40 Button { playing.toggle() } label: {
41 Image(systemName: playing ? "pause.fill" : "play.fill").frame(width: 32, height: 32)
42 }
43 .accessibilityLabel(playing ? "Pause" : "Play")
44 Text(date.formatted(.dateTime.year().month(.abbreviated)))
45 .font(.subheadline.weight(.medium).monospacedDigit())
46 .fixedSize()
47 Slider(value: $offsetDays, in: range).tint(ink)
48 Button { playing = false; offsetDays = 0 } label: {
49 Text("Today").frame(minWidth: 44, minHeight: 44).contentShape(Rectangle())
50 }
51 .font(.footnote.weight(.medium))
52 .opacity(abs(offsetDays) < 1 ? 0.6 : 1)
53 }
54 .padding(.top, 12)
114 timeControls.padding(.top, 12)
55115
56116 Text("Distances beyond 1 AU are drawn on a logarithmic scale. Rings: planet orbits, then 50, 100 and 200 AU.")
57117 .font(.caption2).dimmed(0.6).padding(.top, 8)
@@ -99,18 +159,6 @@ struct SystemScreen: View {
99159 .padding(.horizontal, 20)
100160 .readableWidth()
101161 }
102 .foregroundStyle(ink)
103 .background(background.ignoresSafeArea())
104 .sheet(item: $libraryPage) { page in
105 NavigationStack { LibraryPageView(page: page) }
106 }
107 .task(id: playing) {
108 while playing && !Task.isCancelled {
109 try? await Task.sleep(for: .milliseconds(33))
110 offsetDays = min(offsetDays + 30, range.upperBound)
111 if offsetDays >= range.upperBound { playing = false }
112 }
113 }
114162 }
115163
116164 @ViewBuilder
THIRD_PARTY.org +1
@@ -8,6 +8,7 @@
88| Stellarium sky cultures: Chinese, Indian, Mongolian, Japanese moon stations, Hawaiian starlines, Māori, Tongan, Boorong, Sámi, Norse (lines and names, converted to HR numbers by =scripts/make-skycultures.py=) | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/skycultures.json= | CC BY-SA (per culture, stated in each culture's description.md); the converted file is under the same licence | https://github.com/Stellarium/stellarium-skycultures |
99| Hipparcos main catalogue positions (used only to match HIP to HR when building skycultures.json; not bundled) | — | ESA | https://cdsarc.cds.unistra.fr/viz-bin/cat/I/239 |
1010| Global maps for globes (Mercury PIA15063; Venus Magellan C3-MDIR colourised topography; Earth Blue Marble Next Generation, December 2004; Moon SVS 4720 LROC colour 2K; Mars Viking MDIM 2.1 colour; Jupiter PIA07782; Pluto New Horizons global mosaic; Io, Europa, Ganymede, Callisto USGS Galileo/Voyager mosaics; Titan PIA22770; Enceladus Schenk 2024), downscaled to at most 2048 px | =App/Globes/*.jpg= | Public domain (NASA, USGS); credit per map in =App/Sources/GlobeMaps.swift= | https://science.nasa.gov/photojournal, https://svs.gsfc.nasa.gov/4720/, https://astrogeology.usgs.gov |
11| Sun texture for the 3D solar system (=App/Globes/sun.jpg=) | generated noise, made for this app | — | — |
1112| ConstellationLines v1.4, Copyright (c) 2005-2026 Marc van der Sluys | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/constellations.json= | CC BY 4.0 | https://github.com/MarcvdSluys/ConstellationLines, doi:10.5281/zenodo.10397192 |
1213| Natural Earth 1:110m coastline v5.1.2 | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/coastline.json= | Public domain | https://www.naturalearthdata.com |
1314| Launch Library 2 (The Space Devs) | fetched at runtime by =server/= | Free API; credit shown in the app | https://thespacedevs.com/llapi |