Commit 913cbaccdb
Unsigned
Layout: unified · split
App/Globes/sun.jpg added
Binary file not shown.
App/Sources/SolarSystem3D.swift added +579
| @@ -0,0 +1,579 @@ | ||
| 1 | import Feeds | |
| 2 | import Library | |
| 3 | import RealityKit | |
| 4 | import SkyEngine | |
| 5 | import SwiftUI | |
| 6 | import UIKit | |
| 7 | ||
| 8 | /// How distances and sizes are drawn in the 3D solar system. | |
| 9 | enum 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. | |
| 19 | struct 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. | |
| 43 | struct 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. | |
| 65 | struct 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. | |
| 93 | struct 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 | |
| 121 | final 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 | ||
| 133 | struct 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 { | ||
| 7 | 7 | @State private var offsetDays = 0.0 |
| 8 | 8 | @State private var playing = false |
| 9 | 9 | @State private var libraryPage: LibraryPage? |
| 10 | @AppStorage("systemMode") private var mode = "2d" | |
| 11 | @AppStorage("solarScale") private var solarScale = SolarScale.compressed | |
| 10 | 12 | @Environment(\.dynamicTypeSize) private var typeSize |
| 11 | 13 | |
| 12 | 14 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) |
| @@ -21,12 +23,85 @@ struct SystemScreen: View { | ||
| 21 | 23 | private var date: Date { .now.addingTimeInterval(offsetDays * 86_400) } |
| 22 | 24 | |
| 23 | 25 | 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 { | |
| 24 | 98 | ScrollView { |
| 25 | 99 | VStack(alignment: .leading, spacing: 0) { |
| 26 | 100 | Text("Solar system").font(.title.weight(.medium)) |
| 27 | 101 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12) |
| 28 | 102 | 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) | |
| 30 | 105 | |
| 31 | 106 | LiveSection(ink: ink, accent: accent) |
| 32 | 107 | |
| @@ -36,22 +111,7 @@ struct SystemScreen: View { | ||
| 36 | 111 | .accessibilityElement() |
| 37 | 112 | .accessibilityLabel("Orrery for \(date.formatted(.dateTime.year().month(.wide))). Planets and spacecraft distances are listed below.") |
| 38 | 113 | |
| 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) | |
| 55 | 115 | |
| 56 | 116 | Text("Distances beyond 1 AU are drawn on a logarithmic scale. Rings: planet orbits, then 50, 100 and 200 AU.") |
| 57 | 117 | .font(.caption2).dimmed(0.6).padding(.top, 8) |
| @@ -99,18 +159,6 @@ struct SystemScreen: View { | ||
| 99 | 159 | .padding(.horizontal, 20) |
| 100 | 160 | .readableWidth() |
| 101 | 161 | } |
| 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 | } | |
| 114 | 162 | } |
| 115 | 163 | |
| 116 | 164 | @ViewBuilder |
THIRD_PARTY.org +1
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | | 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 | |
| 9 | 9 | | 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 | |
| 10 | 10 | | 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 | — | — | | |
| 11 | 12 | | 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 | |
| 12 | 13 | | Natural Earth 1:110m coastline v5.1.2 | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/coastline.json= | Public domain | https://www.naturalearthdata.com | |
| 13 | 14 | | Launch Library 2 (The Space Devs) | fetched at runtime by =server/= | Free API; credit shown in the app | https://thespacedevs.com/llapi | |