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 e3e43611b0

e3e43611b06a261d41c9d2359d30c51d1b26d0ae

parent: 98c962118c

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-18 21:54 UTC

Add globes and a visionOS app (maps to follow)

Layout: unified · split

App/Sources/ColorHex.swift added +8
@@ -0,0 +1,8 @@
1import SwiftUI
2
3extension Color {
4 init?(hex: String?) {
5 guard let hex, hex.hasPrefix("#"), hex.count == 7, let v = UInt32(hex.dropFirst(), radix: 16) else { return nil }
6 self.init(red: Double(v >> 16 & 0xFF) / 255, green: Double(v >> 8 & 0xFF) / 255, blue: Double(v & 0xFF) / 255)
7 }
8}
App/Sources/GlobeMaps.swift added +4
@@ -0,0 +1,4 @@
1/// Credits for the global maps in Globes/, by Library page id.
2enum GlobeMaps {
3 static let credit: [String: String] = [:]
4}
App/Sources/GlobeView.swift added +89
@@ -0,0 +1,89 @@
1import Library
2import RealityKit
3import SwiftUI
4
5/// A Library body as a sphere you can turn with a drag: a global map from the app bundle
6/// (Globes/<page id>.jpg) when there is one, otherwise the page's colour. Tilted by its axial tilt.
7struct GlobeView: View {
8 var page: LibraryPage
9 var credit: String?
10 @State private var yaw: Float = 0
11 @State private var pitch: Float = 0
12 @State private var dragStart: (Float, Float)?
13
14 /// Pitch from the drag, then the axial tilt, then the spin.
15 private var orientation: simd_quatf {
16 let degrees: Double = page.drawing?.axialTiltDeg ?? 0
17 let tilt = simd_quatf(angle: Float(degrees * .pi / 180), axis: SIMD3<Float>(0, 0, 1))
18 let spin = simd_quatf(angle: yaw, axis: SIMD3<Float>(0, 1, 0))
19 return simd_quatf(angle: pitch, axis: SIMD3<Float>(1, 0, 0)) * tilt * spin
20 }
21
22 static func mapURL(_ id: String) -> URL? { Bundle.main.url(forResource: id, withExtension: "jpg", subdirectory: "Globes") }
23
24 var body: some View {
25 VStack(spacing: 12) {
26 RealityView { content in
27 let globe = await Self.makeGlobe(page)
28 #if os(visionOS)
29 // A 30 cm globe in the window; the room's lighting shades it.
30 globe.scale = .init(repeating: 0.3)
31 content.add(globe)
32 #else
33 content.camera = .virtual
34 content.add(globe)
35
36 let camera = PerspectiveCamera()
37 camera.position = [0, 0, 1.9]
38 content.add(camera)
39
40 let sun = DirectionalLight()
41 sun.light.intensity = 4000
42 sun.look(at: .zero, from: [-1.5, 0.3, 0.5], relativeTo: nil)
43 content.add(sun)
44 #endif
45 } update: { content in
46 guard let globe = content.entities.first(where: { $0.name == "globe" }) else { return }
47 globe.orientation = orientation
48 }
49 .gesture(DragGesture()
50 .onChanged { g in
51 if dragStart == nil { dragStart = (yaw, pitch) }
52 yaw = dragStart!.0 + Float(g.translation.width) * 0.01
53 pitch = min(max(dragStart!.1 + Float(g.translation.height) * 0.01, -1.2), 1.2)
54 }
55 .onEnded { _ in dragStart = nil })
56 .accessibilityLabel("\(page.title) as a globe. Drag to turn it.")
57 Text(Self.mapURL(page.id) == nil
58 ? "No global map yet; shown in its average colour. Drag to turn."
59 : "Drag to turn. Map: \(credit ?? "NASA").")
60 .font(.caption2).foregroundStyle(.secondary)
61 .padding(.horizontal)
62 }
63 .background(Color.black.ignoresSafeArea())
64 .navigationTitle(page.title)
65 .navigationBarTitleDisplayMode(.inline)
66 }
67
68 @MainActor
69 static func makeGlobe(_ page: LibraryPage) async -> Entity {
70 var material = PhysicallyBasedMaterial()
71 material.roughness = .init(floatLiteral: 1)
72 material.metallic = .init(floatLiteral: 0)
73 if let url = mapURL(page.id), let texture = try? await TextureResource(contentsOf: url) {
74 material.baseColor = .init(texture: .init(texture))
75 } else {
76 material.baseColor = .init(tint: UIColor(Color(hex: page.drawing?.color ?? "") ?? .gray))
77 }
78 if page.id == "sun" {
79 var glow = UnlitMaterial(color: UIColor(Color(hex: page.drawing?.color ?? "") ?? .yellow))
80 if let url = mapURL(page.id), let texture = try? await TextureResource(contentsOf: url) { glow.color = .init(texture: .init(texture)) }
81 let sun = ModelEntity(mesh: .generateSphere(radius: 0.5), materials: [glow])
82 sun.name = "globe"
83 return sun
84 }
85 let globe = ModelEntity(mesh: .generateSphere(radius: 0.5), materials: [material])
86 globe.name = "globe"
87 return globe
88 }
89}
App/Sources/LibraryScreen.swift +6
@@ -98,6 +98,12 @@ struct LibraryPageView: View {
9898 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 10).padding(.bottom, 16)
9999
100100 PageDrawing(page: page, ink: ink, accent: accent)
101 if page.drawing?.kind == "planet" {
102 NavigationLink { GlobeView(page: page, credit: GlobeMaps.credit[page.id]) } label: {
103 Label("View as a globe", systemImage: "globe").font(.subheadline)
104 }
105 .padding(.top, 10)
106 }
101107
102108 facts.padding(.top, 16)
103109
App/Sources/PageDrawing.swift −7
@@ -228,10 +228,3 @@ struct PageDrawing: View {
228228 }
229229 }
230230}
231
232extension Color {
233 init?(hex: String?) {
234 guard let hex, hex.hasPrefix("#"), hex.count == 7, let v = UInt32(hex.dropFirst(), radix: 16) else { return nil }
235 self.init(red: Double(v >> 16 & 0xFF) / 255, green: Double(v >> 8 & 0xFF) / 255, blue: Double(v & 0xFF) / 255)
236 }
237}
App/Vision/MoonriseVisionApp.swift added +103
@@ -0,0 +1,103 @@
1import Library
2import RealityKit
3import SkyEngine
4import SkyRenderer
5import SwiftUI
6
7@main
8struct MoonriseVisionApp: App {
9 var body: some SwiftUI.Scene {
10 WindowGroup { VisionHome() }
11
12 WindowGroup(id: "orrery") { OrreryVolume() }
13 .windowStyle(.volumetric)
14 .defaultSize(width: 0.8, height: 0.4, depth: 0.8, in: .meters)
15
16 ImmersiveSpace(id: "sky") { ImmersiveSky() }
17 }
18}
19
20/// Library globes, plus buttons for the orrery volume and the immersive sky.
21struct VisionHome: View {
22 @Environment(\.openWindow) private var openWindow
23 @Environment(\.openImmersiveSpace) private var openImmersiveSpace
24 @Environment(\.dismissImmersiveSpace) private var dismissImmersiveSpace
25 @State private var immersed = false
26
27 private var bodies: [LibraryPage] {
28 [LibrarySection.sun, .planets, .dwarfPlanets, .moons].flatMap { Library.pages(in: $0) }
29 }
30
31 var body: some View {
32 NavigationSplitView {
33 List(bodies) { page in
34 NavigationLink(page.title) { GlobeView(page: page, credit: GlobeMaps.credit[page.id]) }
35 }
36 .navigationTitle("Moonrise")
37 .toolbar {
38 ToolbarItem(placement: .bottomOrnament) {
39 HStack {
40 Button("Orrery") { openWindow(id: "orrery") }
41 Button(immersed ? "Leave the sky" : "Tonight's sky") {
42 Task {
43 if immersed { await dismissImmersiveSpace() } else { _ = await openImmersiveSpace(id: "sky") }
44 immersed.toggle()
45 }
46 }
47 }
48 }
49 }
50 } detail: {
51 Text("Choose a body to see it as a globe.").foregroundStyle(.secondary)
52 }
53 }
54}
55
56/// Outside the view, where `Body` is SkyEngine's rather than the view's body type.
57private let orreryPlanets = Body.planets
58
59/// The Sun and planets at today's heliocentric positions. Distances are square-rooted so Neptune fits.
60struct OrreryVolume: View {
61 var body: some View {
62 RealityView { content in
63 let sun = ModelEntity(mesh: .generateSphere(radius: 0.025), materials: [UnlitMaterial(color: .yellow)])
64 content.add(sun)
65 for body in orreryPlanets {
66 guard let p = try? SkyEngine.heliocentricEcliptic(body, at: .now) else { continue }
67 let au = (p * p).sum().squareRoot()
68 let scale = 0.065 * au.squareRoot() / au
69 let page = Library.page(body: body.rawValue)
70 let colour = UIColor(Color(hex: page?.drawing?.color) ?? .white)
71 let planet = ModelEntity(mesh: .generateSphere(radius: 0.008), materials: [SimpleMaterial(color: colour, isMetallic: false)])
72 // Ecliptic x, y in the horizontal plane; ecliptic north up.
73 planet.position = [Float(p.x * scale), Float(p.z * scale), Float(-p.y * scale)]
74 content.add(planet)
75 }
76 }
77 }
78}
79
80/// Stars brighter than magnitude 4.5 on a 20 m sphere, placed for now from Chicago,
81/// with the horizon as a faint disc.
82struct ImmersiveSky: View {
83 var body: some View {
84 RealityView { content in
85 let observer = Observer(latitude: 41.8781, longitude: -87.6298)
86 let stars = Catalog.stars.filter { $0.magnitude < 4.5 }
87 let positions = SkyEngine.horizontal(j2000: stars.map { $0.position(at: .now) }, at: .now, from: observer)
88 let mesh = MeshResource.generateSphere(radius: 0.04)
89 for (star, h) in zip(stars, positions) where h.altitude > -2 {
90 let alt = Float(h.altitude * .pi / 180), az = Float(h.azimuth * .pi / 180)
91 let r: Float = 20
92 let s = ModelEntity(mesh: mesh, materials: [UnlitMaterial(color: .white)])
93 s.scale = .init(repeating: max(0.3, Float(2.2 - 0.4 * star.magnitude)))
94 // Azimuth from north (−z) towards east (+x).
95 s.position = [r * cos(alt) * sin(az), r * sin(alt), -r * cos(alt) * cos(az)]
96 content.add(s)
97 }
98 let ground = ModelEntity(mesh: .generatePlane(width: 40, depth: 40), materials: [UnlitMaterial(color: UIColor(white: 0.05, alpha: 0.9))])
99 ground.position.y = -0.01
100 content.add(ground)
101 }
102 }
103}
App/project.yml +34 −1
@@ -4,6 +4,7 @@ options:
44 deploymentTarget:
55 iOS: "26.0"
66 watchOS: "26.0"
7 visionOS: "26.0"
78packages:
89 MoonriseKit:
910 path: ../Packages/MoonriseKit
@@ -13,7 +14,11 @@ targets:
1314 platform: iOS
1415 scheme:
1516 testTargets: [MoonriseUITests]
16 sources: [Sources, Shared]
17 sources:
18 - Sources
19 - Shared
20 - path: Globes
21 type: folder
1722 dependencies:
1823 - package: MoonriseKit
1924 product: SkyEngine
@@ -141,6 +146,34 @@ targets:
141146 SWIFT_VERSION: "6.0"
142147 MARKETING_VERSION: "1.0"
143148 CURRENT_PROJECT_VERSION: "1"
149 MoonriseVision:
150 type: application
151 platform: visionOS
152 sources:
153 - Vision
154 - Sources/GlobeView.swift
155 - Sources/GlobeMaps.swift
156 - Sources/ColorHex.swift
157 - path: Globes
158 type: folder
159 dependencies:
160 - package: MoonriseKit
161 product: SkyEngine
162 - package: MoonriseKit
163 product: SkyRenderer
164 - package: MoonriseKit
165 product: Library
166 settings:
167 base:
168 PRODUCT_BUNDLE_IDENTIFIER: sh.krz.moonrise.vision
169 DEVELOPMENT_TEAM: ZCNAX3VL9D
170 CODE_SIGN_STYLE: Automatic
171 SWIFT_VERSION: "6.0"
172 MARKETING_VERSION: "1.0"
173 CURRENT_PROJECT_VERSION: "1"
174 GENERATE_INFOPLIST_FILE: YES
175 INFOPLIST_KEY_CFBundleDisplayName: Moonrise
176 INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES
144177 MoonriseUITests:
145178 type: bundle.ui-testing
146179 platform: iOS
Packages/MoonriseKit/Package.swift +1 −1
@@ -3,7 +3,7 @@ import PackageDescription
33
44let package = Package(
55 name: "MoonriseKit",
6 platforms: [.iOS(.v26), .macOS(.v26), .watchOS(.v26)],
6 platforms: [.iOS(.v26), .macOS(.v26), .watchOS(.v26), .visionOS(.v26)],
77 products: [
88 .library(name: "SkyEngine", targets: ["SkyEngine"]),
99 .library(name: "SkyRenderer", targets: ["SkyRenderer"]),