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 | @State private var offsetDays = 0.0 | 7 | @State private var offsetDays = 0.0 |
| 8 | @State private var playing = false | 8 | @State private var playing = false |
| 9 | @State private var libraryPage: LibraryPage? | 9 | @State private var libraryPage: LibraryPage? |
| 10 | @AppStorage("systemMode") private var mode = "2d" | ||
| 11 | @AppStorage("solarScale") private var solarScale = SolarScale.compressed | ||
| 10 | @Environment(\.dynamicTypeSize) private var typeSize | 12 | @Environment(\.dynamicTypeSize) private var typeSize |
| 11 | 13 | ||
| 12 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) | 14 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) |
| @@ -21,12 +23,85 @@ struct SystemScreen: View { | |||
| 21 | private var date: Date { .now.addingTimeInterval(offsetDays * 86_400) } | 23 | private var date: Date { .now.addingTimeInterval(offsetDays * 86_400) } |
| 22 | 24 | ||
| 23 | var body: some View { | 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 | ScrollView { | 98 | ScrollView { |
| 25 | VStack(alignment: .leading, spacing: 0) { | 99 | VStack(alignment: .leading, spacing: 0) { |
| 26 | Text("Solar system").font(.title.weight(.medium)) | 100 | Text("Solar system").font(.title.weight(.medium)) |
| 27 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12) | 101 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12) |
| 28 | 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.") | 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 | LiveSection(ink: ink, accent: accent) | 106 | LiveSection(ink: ink, accent: accent) |
| 32 | 107 | ||
| @@ -36,22 +111,7 @@ struct SystemScreen: View { | |||
| 36 | .accessibilityElement() | 111 | .accessibilityElement() |
| 37 | .accessibilityLabel("Orrery for \(date.formatted(.dateTime.year().month(.wide))). Planets and spacecraft distances are listed below.") | 112 | .accessibilityLabel("Orrery for \(date.formatted(.dateTime.year().month(.wide))). Planets and spacecraft distances are listed below.") |
| 38 | 113 | ||
| 39 | HStack(spacing: 12) { | 114 | timeControls.padding(.top, 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) | ||
| 55 | 115 | ||
| 56 | Text("Distances beyond 1 AU are drawn on a logarithmic scale. Rings: planet orbits, then 50, 100 and 200 AU.") | 116 | Text("Distances beyond 1 AU are drawn on a logarithmic scale. Rings: planet orbits, then 50, 100 and 200 AU.") |
| 57 | .font(.caption2).dimmed(0.6).padding(.top, 8) | 117 | .font(.caption2).dimmed(0.6).padding(.top, 8) |
| @@ -99,18 +159,6 @@ struct SystemScreen: View { | |||
| 99 | .padding(.horizontal, 20) | 159 | .padding(.horizontal, 20) |
| 100 | .readableWidth() | 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 | @ViewBuilder | 164 | @ViewBuilder |
THIRD_PARTY.org +1
| @@ -8,6 +8,7 @@ | |||
| 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | Natural Earth 1:110m coastline v5.1.2 | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/coastline.json= | Public domain | https://www.naturalearthdata.com | | 13 | | Natural Earth 1:110m coastline v5.1.2 | =Packages/MoonriseKit/Sources/SkyRenderer/Resources/coastline.json= | Public domain | https://www.naturalearthdata.com | |
| 13 | | Launch Library 2 (The Space Devs) | fetched at runtime by =server/= | Free API; credit shown in the app | https://thespacedevs.com/llapi | | 14 | | Launch Library 2 (The Space Devs) | fetched at runtime by =server/= | Free API; credit shown in the app | https://thespacedevs.com/llapi | |