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 6d858a843d

6d858a843dc7da2ee251de40c9f76f689ded8c61

parent: 7f96d43a4e

Verified · cmc

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

Add timeline, rockets-to-scale and exoplanet drawings to the Library

- Library sections for exoplanets and rockets; drawing fields
  radiusEarth, orbitAU, starTempK, heightM, diameterM; content brief
  updated.
- Exploration timeline: mission pages by decade from their launch dates.
- RocketLineup: every rocket page at one scale, on rocket pages (current
  rocket highlighted) and as Rockets to scale in the Library.
- Exoplanet drawing: size beside Earth and Jupiter, orbit beside
  Mercury's and Earth's, star tinted by temperature.

Layout: unified · split

App/Sources/LibraryScreen.swift +12
@@ -12,6 +12,18 @@ struct LibraryScreen: View {
1212 NavigationStack {
1313 List {
1414 if query.isEmpty {
15 Section {
16 NavigationLink("Timeline of exploration") { ExplorationTimeline() }
17 .listRowBackground(pageBackground)
18 if !Library.pages(in: .rockets).isEmpty {
19 NavigationLink("Rockets to scale") {
20 ScrollView { RocketLineup(highlight: nil, ink: ink, accent: accent).frame(height: 420).padding(.vertical, 20) }
21 .background(Color.black.ignoresSafeArea())
22 .navigationTitle("Rockets to scale")
23 }
24 .listRowBackground(pageBackground)
25 }
26 }
1527 ForEach(LibrarySection.allCases) { section in
1628 let pages = Library.pages(in: section)
1729 if !pages.isEmpty {
App/Sources/PageDrawing.swift +55
@@ -17,6 +17,8 @@ struct PageDrawing: View {
1717 case "star": star
1818 case "deep-sky": deepSky
1919 case "mission": mission
20 case "exoplanet": exoplanet
21 case "rocket": RocketLineup(highlight: page.id, ink: ink, accent: accent).frame(height: 260)
2022 default: EmptyView()
2123 }
2224 }
@@ -158,6 +160,59 @@ struct PageDrawing: View {
158160 .accessibilityLabel("\(page.title) apparent size compared with the full Moon")
159161 }
160162
163 // MARK: Exoplanets
164
165 /// The planet beside Earth and Jupiter to scale, and its orbit beside Mercury's and Earth's.
166 private var exoplanet: some View {
167 let d = page.drawing!
168 let star = starColor(d.starTempK)
169 return HStack(alignment: .bottom, spacing: 24) {
170 if let r = d.radiusEarth {
171 Canvas { context, size in
172 let maxR = max(r, 11.2)
173 let scale = (size.height - 22) / (2 * maxR)
174 let bodies: [(String, Double, Color)] = [("Earth", 1, Color(red: 0.35, green: 0.6, blue: 0.95)), (page.title, r, ink), ("Jupiter", 11.2, Color(red: 0.85, green: 0.72, blue: 0.55))]
175 var x = 8.0
176 for (name, radius, color) in bodies.sorted(by: { $0.1 < $1.1 }) {
177 let rr = max(radius * scale, 1.5)
178 let c = CGPoint(x: x + rr, y: size.height - 18 - rr)
179 context.fill(Path(ellipseIn: CGRect(x: c.x - rr, y: c.y - rr, width: 2 * rr, height: 2 * rr)), with: .color(name == page.title ? accent : color))
180 context.draw(Text(name).font(.caption2).foregroundStyle(ink.opacity(0.7)), at: CGPoint(x: c.x, y: size.height - 6))
181 x += 2 * rr + 34
182 }
183 }
184 .frame(height: 140)
185 .accessibilityLabel("\(page.title), \(String(format: "%.1f", r)) times Earth's radius")
186 }
187 if let a = d.orbitAU {
188 Canvas { context, size in
189 let c = CGPoint(x: size.width / 2, y: size.height / 2 - 6)
190 let outer = max(a, 1)
191 let scale = (min(size.width, size.height) / 2 - 14) / outer
192 for (name, au) in [("Mercury", 0.387), ("Earth", 1.0)] where au <= outer {
193 let r = au * scale
194 context.stroke(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)), with: .color(ink.opacity(0.3)), style: StrokeStyle(lineWidth: 0.6, dash: [2, 3]))
195 context.draw(Text(name).font(.caption2).foregroundStyle(ink.opacity(0.6)), at: CGPoint(x: c.x, y: c.y - r - 7))
196 }
197 let r = a * scale
198 context.stroke(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)), with: .color(accent), lineWidth: 1)
199 context.fill(Path(ellipseIn: CGRect(x: c.x - 4, y: c.y - 4, width: 8, height: 8)), with: .color(star))
200 context.draw(Text(String(format: "%.3g AU", a)).font(.caption2).foregroundStyle(ink), at: CGPoint(x: c.x, y: size.height - 6))
201 }
202 .frame(width: 150, height: 150)
203 .accessibilityLabel("Orbit \(String(format: "%.3g", a)) astronomical units from its star")
204 }
205 }
206 }
207
208 private func starColor(_ t: Double?) -> Color {
209 guard let t else { return ink }
210 if t < 3700 { return Color(red: 1, green: 0.6, blue: 0.4) }
211 if t < 5200 { return Color(red: 1, green: 0.8, blue: 0.55) }
212 if t < 6000 { return Color(red: 1, green: 0.95, blue: 0.8) }
213 return Color(red: 0.8, green: 0.88, blue: 1)
214 }
215
161216 // MARK: Missions
162217
163218 private var mission: some View {
App/Sources/RocketLineup.swift added +42
@@ -0,0 +1,42 @@
1import Library
2import SwiftUI
3
4/// Every rocket in the Library drawn side by side at the same scale, tallest last.
5struct RocketLineup: View {
6 var highlight: String?
7 var ink: Color
8 var accent: Color
9
10 private var rockets: [LibraryPage] {
11 Library.pages(in: .rockets).filter { $0.drawing?.heightM != nil }.sorted { ($0.drawing?.heightM ?? 0) < ($1.drawing?.heightM ?? 0) }
12 }
13
14 var body: some View {
15 ScrollView(.horizontal, showsIndicators: false) {
16 Canvas { context, size in
17 let tallest = rockets.compactMap(\.drawing?.heightM).max() ?? 1
18 let scale = (size.height - 36) / tallest
19 var x = 12.0
20 for r in rockets {
21 let h = r.drawing!.heightM! * scale
22 let w = max((r.drawing?.diameterM ?? 3) * scale, 3)
23 let rect = CGRect(x: x, y: size.height - 24 - h, width: w, height: h)
24 let mine = r.id == highlight
25 var body = Path(roundedRect: rect, cornerRadius: w / 2)
26 body.addRect(rect.insetBy(dx: 0, dy: w / 2))
27 context.fill(body, with: .color(mine ? accent : ink.opacity(0.55)))
28 context.draw(Text(String(format: "%.0f m", r.drawing!.heightM!)).font(.caption2).foregroundStyle(ink.opacity(0.7)), at: CGPoint(x: x + w / 2, y: rect.minY - 8))
29 context.draw(Text(r.title).font(.caption2).foregroundStyle(mine ? accent : ink.opacity(0.8)), at: CGPoint(x: x + w / 2, y: size.height - 10))
30 x += max(w, 64) + 10
31 }
32 var ground = Path()
33 ground.move(to: CGPoint(x: 0, y: size.height - 24))
34 ground.addLine(to: CGPoint(x: size.width, y: size.height - 24))
35 context.stroke(ground, with: .color(ink.opacity(0.4)), lineWidth: 0.5)
36 }
37 .frame(width: CGFloat(rockets.count) * 74 + 24)
38 }
39 .accessibilityElement()
40 .accessibilityLabel("Rockets to scale: " + rockets.map { "\($0.title) \(Int($0.drawing!.heightM!)) metres" }.joined(separator: ", "))
41 }
42}
App/Sources/TimelineScreen.swift added +51
@@ -0,0 +1,51 @@
1import Library
2import SwiftUI
3
4/// Missions in the Library by launch year, from Sputnik to today.
5struct ExplorationTimeline: View {
6 @State private var selected: LibraryPage?
7 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
8 private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
9
10 private var decades: [(Int, [LibraryPage])] {
11 let missions = Library.pages(in: .missions).filter { $0.drawing?.launch != nil }.sorted { $0.drawing!.launch! < $1.drawing!.launch! }
12 let grouped = Dictionary(grouping: missions) { (Int($0.drawing!.launch!.prefix(4)) ?? 0) / 10 * 10 }
13 return grouped.keys.sorted().map { ($0, grouped[$0]!) }
14 }
15
16 var body: some View {
17 List {
18 ForEach(decades, id: \.0) { decade, missions in
19 Section("\(String(decade))s") {
20 ForEach(missions) { m in
21 Button { selected = m } label: { row(m) }
22 .buttonStyle(.plain)
23 .listRowBackground(Color.black)
24 }
25 }
26 }
27 }
28 .listStyle(.plain)
29 .scrollContentBackground(.hidden)
30 .background(Color.black.ignoresSafeArea())
31 .navigationTitle("Timeline")
32 .foregroundStyle(ink)
33 .sheet(item: $selected) { page in NavigationStack { LibraryPageView(page: page) } }
34 }
35
36 private func row(_ m: LibraryPage) -> some View {
37 let year = String(m.drawing?.launch?.prefix(4) ?? "")
38 let active = m.drawing?.status == "active"
39 return HStack(alignment: .firstTextBaseline, spacing: 12) {
40 Text(year).font(.footnote.monospacedDigit()).dimmed(0.7)
41 VStack(alignment: .leading, spacing: 2) {
42 Text(m.title).font(.body)
43 Text(m.subtitle).font(.footnote).dimmed(0.7)
44 }
45 Spacer()
46 if active {
47 Circle().fill(accent).frame(width: 7, height: 7).accessibilityLabel("Active")
48 }
49 }
50 }
51}
Packages/MoonriseKit/Sources/Library/Library.swift +8 −1
@@ -36,6 +36,11 @@ public struct LibraryPage: Codable, Sendable, Identifiable, Hashable {
3636 public var sizeArcmin: Double?
3737 public var status: String?
3838 public var launch: String?
39 public var radiusEarth: Double?
40 public var orbitAU: Double?
41 public var starTempK: Double?
42 public var heightM: Double?
43 public var diameterM: Double?
3944 }
4045
4146 /// What the page describes elsewhere in the app.
@@ -68,7 +73,7 @@ public struct LibraryPage: Codable, Sendable, Identifiable, Hashable {
6873}
6974
7075public enum LibrarySection: String, Codable, Sendable, CaseIterable, Identifiable {
71 case sun, planets, dwarfPlanets = "dwarf-planets", moons, constellations, stars, deepSky = "deep-sky", missions
76 case sun, planets, dwarfPlanets = "dwarf-planets", moons, constellations, stars, deepSky = "deep-sky", exoplanets, missions, rockets
7277
7378 public var id: String { rawValue }
7479
@@ -81,7 +86,9 @@ public enum LibrarySection: String, Codable, Sendable, CaseIterable, Identifiabl
8186 case .constellations: "Constellations"
8287 case .stars: "Stars"
8388 case .deepSky: "Deep sky"
89 case .exoplanets: "Exoplanets"
8490 case .missions: "Missions"
91 case .rockets: "Rockets"
8592 }
8693 }
8794}
docs/library-content.org +3 −1
@@ -30,7 +30,7 @@ one file per section, each a JSON array of pages.
3030#+end_src
3131
3232- =id=: lowercase, hyphenated, unique across all sections (e.g. =m42=, =ori=, =sirius=, =voyager-1=).
33- =section=: one of =sun=, =planets=, =dwarf-planets=, =moons=, =constellations=, =stars=, =deep-sky=, =missions=.
33- =section=: one of =sun=, =planets=, =dwarf-planets=, =moons=, =constellations=, =stars=, =deep-sky=, =missions=, =exoplanets=, =rockets=.
3434- =facts=: 4–8 items. Every fact has =source=, an index into =sources=. Units in SI or AU, with conversions where helpful. Use tabular style: "10 h 33 m", "29.4 Earth years".
3535- =sources=: 1–5 items, primary or authoritative (NASA NSSDC fact sheets, NASA science pages, JPL, IAU, ESA, NOIRLab, SIMBAD). Every URL must return HTTP 200 when checked.
3636- =image=: optional only for constellations, stars and missions when no verifiable image exists (set to =null=).
@@ -40,6 +40,8 @@ one file per section, each a JSON array of pages.
4040 - stars: ={"kind": "star", "spectralType": "A1V", "colorIndex": 0.0}= (B−V).
4141 - deep sky: ={"kind": "deep-sky", "type": "galaxy|globular|open|nebula|planetary|supernova-remnant", "sizeArcmin": 90}=.
4242 - missions: ={"kind": "mission", "status": "active|complete|lost", "launch": "1977-09-05"}=.
43 - exoplanets: ={"kind": "exoplanet", "radiusEarth": 1.07, "orbitAU": 0.0485, "starTempK": 2566}= (omit a field if no measured value).
44 - rockets: ={"kind": "rocket", "heightM": 70.0, "diameterM": 3.7, "status": "active|retired|in development"}=.
4345- =link=: what the page describes in the rest of the app: ={"body": "mars"}=, ={"constellation": "Ori"}= (IAU abbreviation), ={"hr": 2491}= (Bright Star Catalogue number), ={"messier": 42}=, ={"spacecraft": "Voyager 1"}=, or ={}=.
4446
4547* Essay voice