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 ebc43cb37c

ebc43cb37c1d35d2693c5f3e7a9808f271f92bed

parent: 7b2ee0839a

Verified · cmc

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

Add a scale-of-the-universe view to the Library

Layout: unified · split

App/Sources/LibraryScreen.swift +2
@@ -15,6 +15,8 @@ struct LibraryScreen: View {
1515 Section {
1616 NavigationLink("Timeline of exploration") { ExplorationTimeline() }
1717 .listRowBackground(pageBackground)
18 NavigationLink("Scale of the universe") { ScaleScreen(ink: ink, accent: accent) }
19 .listRowBackground(pageBackground)
1820 if !Library.pages(in: .rockets).isEmpty {
1921 NavigationLink("Rockets to scale") {
2022 ScrollView { RocketLineup(highlight: nil, ink: ink, accent: accent).frame(height: 420).padding(.vertical, 20) }
App/Sources/ScaleScreen.swift added +87
@@ -0,0 +1,87 @@
1import Library
2import SwiftUI
3
4/// Bodies, stars, nebulae and galaxies at true relative size. Zoom with the slider or a pinch;
5/// objects between 1/100 of the shown width and the full width are drawn side by side.
6struct ScaleScreen: View {
7 var ink: Color
8 var accent: Color
9 /// log10 of the width shown, in metres.
10 @State private var exponent = 7.4
11 @State private var pinchStart: Double?
12 @State private var selected: LibraryPage?
13
14 private let range = 2.5...27.0
15
16 private var width: Double { pow(10, exponent) }
17
18 /// The width to two significant figures, for the label.
19 private var roundedWidth: Double {
20 let p = pow(10, floor(exponent) - 1)
21 return (width / p).rounded() * p
22 }
23
24 private var shown: [ScaleObject] {
25 Library.scaleObjects.filter { $0.meters <= width && $0.meters >= width / 100 }
26 }
27
28 var body: some View {
29 VStack(alignment: .leading, spacing: 16) {
30 Text("Width shown: \(Library.formatSize(roundedWidth))").font(.subheadline.monospacedDigit()).dimmed(0.8)
31 GeometryReader { geo in
32 let side = geo.size.width
33 ScrollView(.horizontal, showsIndicators: false) {
34 HStack(alignment: .bottom, spacing: 16) {
35 if shown.isEmpty {
36 Text("Nothing in the Library at this scale.").font(.footnote).dimmed(0.6)
37 }
38 ForEach(shown) { o in
39 let d = max(o.meters / width * side, 2)
40 Button {
41 selected = o.page.flatMap { Library.page(id: $0) }
42 } label: {
43 VStack(spacing: 6) {
44 Circle().fill(o.page == nil ? ink.opacity(0.18) : ink.opacity(0.55))
45 .overlay(Circle().stroke(ink.opacity(0.5), lineWidth: 0.5))
46 .frame(width: d, height: d)
47 Text(o.name).font(.caption2).lineLimit(1)
48 Text(Library.formatSize(o.meters)).font(.caption2.monospacedDigit()).dimmed(0.6)
49 }
50 .frame(minWidth: 64)
51 }
52 .buttonStyle(.plain)
53 .disabled(o.page == nil)
54 .accessibilityLabel("\(o.name), \(Library.formatSize(o.meters))")
55 }
56 }
57 .frame(minHeight: geo.size.height, alignment: .bottom)
58 .padding(.horizontal, 4)
59 }
60 .defaultScrollAnchor(.trailing)
61 .scrollClipDisabled()
62 }
63 .frame(maxHeight: .infinity)
64 .gesture(MagnifyGesture()
65 .onChanged { g in
66 if pinchStart == nil { pinchStart = exponent }
67 exponent = min(max(pinchStart! - log10(g.magnification), range.lowerBound), range.upperBound)
68 }
69 .onEnded { _ in pinchStart = nil })
70 Slider(value: $exponent, in: range) { Text("Width shown") } minimumValueLabel: {
71 Image(systemName: "plus.magnifyingglass")
72 } maximumValueLabel: {
73 Image(systemName: "minus.magnifyingglass")
74 }
75 .accessibilityValue(Library.formatSize(roundedWidth))
76 Text("The slider spans 300 m to the observable universe; each tenth of it is a factor of ten. Pinch to zoom. Objects without a Library page are drawn faint.")
77 .font(.caption2).dimmed(0.5)
78 }
79 .padding(20)
80 .foregroundStyle(ink)
81 .tint(accent)
82 .background(Color.black.ignoresSafeArea())
83 .navigationTitle("Scale of the universe")
84 .navigationBarTitleDisplayMode(.inline)
85 .sheet(item: $selected) { page in NavigationStack { LibraryPageView(page: page) } }
86 }
87}
PLAN.org +1 −1
@@ -401,7 +401,7 @@ Sky, System, Sun, Launches, Library core (above).
401401- Observing log stored as plain text or Org.
402402- Mars: latest rover raw images, local time at each rover.
403403- Library: exoplanets, rockets to scale, exploration timeline, scale
404 view.
404 view. Done.
405405- iPad, Mac, Apple Watch complications.
406406
407407** v3: across time and cultures
Packages/MoonriseKit/Sources/Library/Scale.swift added +77
@@ -0,0 +1,77 @@
1import Foundation
2
3/// Something with a size, for the scale view: a body, star, nebula, galaxy or orbit.
4public struct ScaleObject: Sendable, Identifiable, Hashable {
5 public var name: String
6 /// Diameter, or end-to-end length, in metres.
7 public var meters: Double
8 /// Library page to open, if there is one.
9 public var page: String?
10 public var id: String { name }
11}
12
13extension Library {
14 static let lightYear = 9.4607e15
15 static let au = 1.495978707e11
16
17 /// Everything in the scale view, smallest first. Sizes come from the Library pages where they give one.
18 public static let scaleObjects: [ScaleObject] = {
19 var out: [ScaleObject] = [ScaleObject(name: "ISS", meters: 109, page: "international-space-station")]
20
21 // Sun, planets, dwarf planets and moons: the drawing's mean radius.
22 for section in [LibrarySection.sun, .planets, .dwarfPlanets, .moons] {
23 for p in pages(in: section) {
24 if let r = p.drawing?.radiusKm { out.append(ScaleObject(name: p.title, meters: 2 * r * 1000, page: p.id)) }
25 }
26 }
27
28 // Stars whose page gives a radius as "N × Sun".
29 for p in pages(in: .stars) {
30 guard let fact = p.facts.first(where: { $0.label == "Radius" }), let r = solarRadii(fact.value) else { continue }
31 out.append(ScaleObject(name: p.title, meters: 2 * r * 6.957e8, page: p.id))
32 }
33
34 // Deep-sky objects: the page's distance times its apparent size (major axis).
35 let deepSky: [(String, Double, Double)] = [ // id, light-years, arcminutes
36 ("m57", 2_000, 1.4), ("m1", 6_500, 6), ("m45", 445, 110), ("m42", 1_500, 85),
37 ("m13", 25_000, 20), ("m33", 3_000_000, 73), ("m87", 54_000_000, 7), ("m31", 2_500_000, 178),
38 ]
39 for (id, ly, arcmin) in deepSky {
40 guard let p = page(id: id) else { continue }
41 out.append(ScaleObject(name: p.title, meters: ly * lightYear * arcmin / 60 * .pi / 180, page: id))
42 }
43
44 out += [
45 ScaleObject(name: "Moon's orbit", meters: 2 * 384_400e3),
46 ScaleObject(name: "Earth's orbit", meters: 2 * au),
47 ScaleObject(name: "Neptune's orbit", meters: 2 * 30.07 * au),
48 // Voyager 1 crossed the heliopause at about 121 AU.
49 ScaleObject(name: "Heliosphere", meters: 2 * 121 * au),
50 ScaleObject(name: "Milky Way", meters: 100_000 * lightYear),
51 ScaleObject(name: "Local Group", meters: 10e6 * lightYear),
52 // Tully et al. 2014.
53 ScaleObject(name: "Laniakea Supercluster", meters: 520e6 * lightYear),
54 ScaleObject(name: "Observable universe", meters: 93e9 * lightYear),
55 ]
56 return out.sorted { $0.meters < $1.meters }
57 }()
58
59 /// 25.4 from "25.4 ± 0.2 × Sun".
60 static func solarRadii(_ s: String) -> Double? {
61 guard s.contains("× Sun"), let first = s.split(separator: " ").first else { return nil }
62 return Double(first.replacingOccurrences(of: ",", with: ""))
63 }
64
65 /// "109 m", "12,742 km", "60.1 AU", "11 light-years", "2.5 million light-years".
66 public static func formatSize(_ meters: Double) -> String {
67 func n(_ v: Double) -> String { v.formatted(.number.precision(.significantDigits(1...(v < 10 ? 2 : 3)))) }
68 switch meters {
69 case ..<1_000: return "\(Int(meters.rounded())) m"
70 case ..<1e10: return "\(Int((meters / 1000).rounded()).formatted()) km"
71 case ..<(0.1 * lightYear): return "\(n(meters / au)) AU"
72 case ..<(1e6 * lightYear): return "\(n(meters / lightYear)) light-years"
73 case ..<(1e9 * lightYear): return "\(n(meters / lightYear / 1e6)) million light-years"
74 default: return "\(n(meters / lightYear / 1e9)) billion light-years"
75 }
76 }
77}
Packages/MoonriseKit/Tests/LibraryTests/LibraryTests.swift +19
@@ -47,3 +47,22 @@ func linksPointAtThingsTheAppKnows(_ p: LibraryPage) {
4747 #expect(Library.search(first.title).first?.id == first.id)
4848 #expect(Library.search(" ").isEmpty)
4949}
50
51@Test func scaleObjectsComeFromPages() {
52 let objects = Library.scaleObjects
53 #expect(objects.first?.name == "ISS")
54 #expect(objects.last?.name == "Observable universe")
55 #expect(objects.contains { $0.name == "Earth" && abs($0.meters - 12_742_000) < 1 })
56 #expect(objects.contains { $0.page == "arcturus" })
57 #expect(objects.contains { $0.page == "m31" })
58 #expect(Set(objects.map(\.name)).count == objects.count)
59 for o in objects where o.page != nil { #expect(Library.page(id: o.page!) != nil, "\(o.name)") }
60}
61
62@Test func scaleSizeFormats() {
63 #expect(Library.formatSize(109) == "109 m")
64 #expect(Library.formatSize(12_742_000) == "12,742 km")
65 #expect(Library.formatSize(2 * 30.07 * 1.495978707e11) == "60.1 AU")
66 #expect(Library.formatSize(100_000 * 9.4607e15) == "100,000 light-years")
67 #expect(Library.formatSize(93e9 * 9.4607e15) == "93 billion light-years")
68}