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 a04837ac3f

a04837ac3fff1b2e689bbe9489f9b24c4f63e9ef

parent: 17229b9367

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-19 05:37 UTC

Rockets to scale: rocket silhouettes in labelled columns with a height axis

Layout: unified · split

App/Sources/LibraryScreen.swift +6 −1
@@ -178,7 +178,12 @@ struct LibraryScreen: View {
178 } 178 }
179 if !Library.pages(in: .rockets).isEmpty { 179 if !Library.pages(in: .rockets).isEmpty {
180 ExploreCard(title: "Rockets to scale", subtitle: "\(Library.pages(in: .rockets).count) rockets side by side", symbol: "chart.bar.xaxis", tint: Color(red: 0.2, green: 0.1, blue: 0.08)) { 180 ExploreCard(title: "Rockets to scale", subtitle: "\(Library.pages(in: .rockets).count) rockets side by side", symbol: "chart.bar.xaxis", tint: Color(red: 0.2, green: 0.1, blue: 0.08)) {
181 ScrollView { RocketLineup(highlight: nil, ink: ink, accent: accent).frame(height: 420).padding(.vertical, 20) } 181 VStack(alignment: .leading, spacing: 8) {
182 Text("Every rocket in the Library at the same scale, shortest to tallest. Scroll sideways; tap one to read about it.")
183 .font(.subheadline).dimmed(0.75).padding(.horizontal, 20)
184 RocketLineup(highlight: nil, ink: ink, accent: accent)
185 }
186 .padding(.vertical, 12)
182 .background(Color.black.ignoresSafeArea()) 187 .background(Color.black.ignoresSafeArea())
183 .navigationTitle("Rockets to scale") 188 .navigationTitle("Rockets to scale")
184 } 189 }
App/Sources/RocketLineup.swift +95 −24
@@ -1,42 +1,113 @@
1import Library 1import Library
2import SwiftUI 2import SwiftUI
3 3
4/// Every rocket in the Library drawn side by side at the same scale, tallest last. 4/// Every rocket in the Library drawn side by side at the same scale, shortest first.
5/// Each rocket is a column (height above, name below), so labels never overlap; tapping one opens its page.
5struct RocketLineup: View { 6struct RocketLineup: View {
6 var highlight: String? 7 var highlight: String?
7 var ink: Color 8 var ink: Color
8 var accent: Color 9 var accent: Color
9 10
11 private let column: CGFloat = 84
12 private let top: CGFloat = 20
13 private let bottom: CGFloat = 36
14 private let axis: CGFloat = 40
15
10 private var rockets: [LibraryPage] { 16 private var rockets: [LibraryPage] {
11 Library.pages(in: .rockets).filter { $0.drawing?.heightM != nil }.sorted { ($0.drawing?.heightM ?? 0) < ($1.drawing?.heightM ?? 0) } 17 Library.pages(in: .rockets).filter { $0.drawing?.heightM != nil }.sorted { ($0.drawing?.heightM ?? 0) < ($1.drawing?.heightM ?? 0) }
12 } 18 }
13 19
14 var body: some View { 20 var body: some View {
15 ScrollView(.horizontal, showsIndicators: false) { 21 GeometryReader { geo in
16 Canvas { context, size in 22 let tallest = rockets.compactMap(\.drawing?.heightM).max() ?? 1
17 let tallest = rockets.compactMap(\.drawing?.heightM).max() ?? 1 23 let scale = max(geo.size.height - top - bottom - 8, 40) / tallest
18 let scale = (size.height - 36) / tallest 24 // Ground: below the name (bottom - 6), the line and two 4-point gaps.
19 var x = 12.0 25 let ground = geo.size.height - (bottom - 6) - 8.5
20 for r in rockets { 26 ForEach(Array(stride(from: 25.0, through: tallest, by: 25)), id: \.self) { m in
21 let h = r.drawing!.heightM! * scale 27 let y = ground - m * scale
22 let w = max((r.drawing?.diameterM ?? 3) * scale, 3) 28 Path { p in p.move(to: CGPoint(x: axis, y: y)); p.addLine(to: CGPoint(x: geo.size.width, y: y)) }
23 let rect = CGRect(x: x, y: size.height - 24 - h, width: w, height: h) 29 .stroke(ink.opacity(0.12), style: StrokeStyle(lineWidth: 0.5, dash: [3, 4]))
24 let mine = r.id == highlight 30 }
25 var body = Path(roundedRect: rect, cornerRadius: w / 2) 31 .accessibilityHidden(true)
26 body.addRect(rect.insetBy(dx: 0, dy: w / 2)) 32 ScrollViewReader { proxy in
27 context.fill(body, with: .color(mine ? accent : ink.opacity(0.55))) 33 ScrollView(.horizontal, showsIndicators: false) {
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)) 34 HStack(alignment: .bottom, spacing: 0) {
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)) 35 ForEach(rockets) { r in
30 x += max(w, 64) + 10 36 Group {
37 if r.id == highlight {
38 rocketColumn(r, scale: scale)
39 } else {
40 NavigationLink { LibraryPageView(page: r) } label: { rocketColumn(r, scale: scale) }
41 .buttonStyle(.plain)
42 }
43 }
44 .id(r.id)
45 }
46 }
47 .padding(.leading, axis)
48 .padding(.trailing, 12)
49 .frame(minHeight: geo.size.height, alignment: .bottom)
31 } 50 }
32 var ground = Path() 51 .onAppear { if let highlight { proxy.scrollTo(highlight, anchor: .center) } }
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 } 52 }
37 .frame(width: CGFloat(rockets.count) * 74 + 24) 53 // A fixed axis on the left that the rockets scroll beneath.
54 ZStack(alignment: .topLeading) {
55 Color.black.frame(width: axis)
56 ForEach(Array(stride(from: 25.0, through: tallest, by: 25)), id: \.self) { m in
57 Text("\(Int(m)) m").font(.caption2.monospacedDigit()).foregroundStyle(ink.opacity(0.5))
58 .position(x: axis / 2, y: ground - m * scale)
59 }
60 }
61 .frame(width: axis, height: geo.size.height)
62 .accessibilityHidden(true)
63 }
64 .accessibilityElement(children: .contain)
65 .accessibilityLabel("Rockets to scale")
66 }
67
68 private func rocketColumn(_ r: LibraryPage, scale: Double) -> some View {
69 let d = r.drawing!
70 let mine = r.id == highlight
71 let h = d.heightM! * scale
72 let w = max((d.diameterM ?? 3) * scale, 4)
73 return VStack(spacing: 4) {
74 Text(String(format: "%.0f m", d.heightM!)).font(.caption2.monospacedDigit()).foregroundStyle(ink.opacity(0.7))
75 .frame(height: top - 4)
76 RocketShape()
77 .fill(mine ? accent : ink.opacity(0.75))
78 .frame(width: w, height: h)
79 Rectangle().fill(ink.opacity(0.35)).frame(width: column, height: 0.5)
80 Text(r.title).font(.caption2.weight(mine ? .semibold : .regular))
81 .foregroundStyle(mine ? accent : ink.opacity(0.85))
82 .multilineTextAlignment(.center)
83 .lineLimit(2)
84 .frame(width: column - 6, height: bottom - 6, alignment: .top)
38 } 85 }
39 .accessibilityElement() 86 .frame(width: column)
40 .accessibilityLabel("Rockets to scale: " + rockets.map { "\($0.title) \(Int($0.drawing!.heightM!)) metres" }.joined(separator: ", ")) 87 .contentShape(Rectangle())
88 .accessibilityElement(children: .ignore)
89 .accessibilityLabel("\(r.title), \(Int(d.heightM!)) metres")
90 }
91}
92
93/// A generic rocket: ogive nose, straight body, small fins at the base. Drawn to the frame,
94/// with fins reaching a little outside the body's width.
95struct RocketShape: Shape {
96 func path(in rect: CGRect) -> Path {
97 let w = rect.width, h = rect.height
98 let nose = min(h * 0.14, w * 2.4)
99 let fin = min(h * 0.08, w * 1.2)
100 let spread = w * 0.35
101 var p = Path()
102 p.move(to: CGPoint(x: rect.midX, y: rect.minY))
103 p.addQuadCurve(to: CGPoint(x: rect.maxX, y: rect.minY + nose), control: CGPoint(x: rect.maxX, y: rect.minY + nose * 0.25))
104 p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - fin))
105 p.addLine(to: CGPoint(x: rect.maxX + spread, y: rect.maxY))
106 p.addLine(to: CGPoint(x: rect.minX - spread, y: rect.maxY))
107 p.addLine(to: CGPoint(x: rect.minX, y: rect.maxY - fin))
108 p.addLine(to: CGPoint(x: rect.minX, y: rect.minY + nose))
109 p.addQuadCurve(to: CGPoint(x: rect.midX, y: rect.minY), control: CGPoint(x: rect.minX, y: rect.minY + nose * 0.25))
110 p.closeSubpath()
111 return p
41 } 112 }
42} 113}