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 e9392d0a32

e9392d0a3270dc0bf71f52f248bd22939f5fccd6

parent: 17229b9367

Unsigned

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

Rockets to scale: silhouettes, columns, height axis (!45)

- Each rocket is a column (height above, name below in up to two lines, 84 pt wide), so labels can't overlap.
- Rounded bars replaced by a rocket silhouette (=RocketShape=: ogive nose, body, fins), still drawn to height and diameter at one scale.
- Dashed height lines every 25 m, labelled on a fixed axis at the left that the rockets scroll beneath.
- Tapping a rocket opens its page. On a rocket's own page that rocket is red, not a link, and scrolled into view.
- The Library's Rockets to scale screen gets a one-line intro and uses the full height.
- Photos were not used: they are launch photos with backgrounds, angles and framing that don't line up to a common scale.

Layout: unified · split

App/Sources/LibraryScreen.swift +6 −1
@@ -178,7 +178,12 @@ struct LibraryScreen: View {
178178 }
179179 if !Library.pages(in: .rockets).isEmpty {
180180 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)
182187 .background(Color.black.ignoresSafeArea())
183188 .navigationTitle("Rockets to scale")
184189 }
App/Sources/RocketLineup.swift +95 −24
@@ -1,42 +1,113 @@
11import Library
22import SwiftUI
33
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.
56struct RocketLineup: View {
67 var highlight: String?
78 var ink: Color
89 var accent: Color
910
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
1016 private var rockets: [LibraryPage] {
1117 Library.pages(in: .rockets).filter { $0.drawing?.heightM != nil }.sorted { ($0.drawing?.heightM ?? 0) < ($1.drawing?.heightM ?? 0) }
1218 }
1319
1420 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
21 GeometryReader { geo in
22 let tallest = rockets.compactMap(\.drawing?.heightM).max() ?? 1
23 let scale = max(geo.size.height - top - bottom - 8, 40) / tallest
24 // Ground: below the name (bottom - 6), the line and two 4-point gaps.
25 let ground = geo.size.height - (bottom - 6) - 8.5
26 ForEach(Array(stride(from: 25.0, through: tallest, by: 25)), id: \.self) { m in
27 let y = ground - m * scale
28 Path { p in p.move(to: CGPoint(x: axis, y: y)); p.addLine(to: CGPoint(x: geo.size.width, y: y)) }
29 .stroke(ink.opacity(0.12), style: StrokeStyle(lineWidth: 0.5, dash: [3, 4]))
30 }
31 .accessibilityHidden(true)
32 ScrollViewReader { proxy in
33 ScrollView(.horizontal, showsIndicators: false) {
34 HStack(alignment: .bottom, spacing: 0) {
35 ForEach(rockets) { r in
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)
3150 }
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)
51 .onAppear { if let highlight { proxy.scrollTo(highlight, anchor: .center) } }
3652 }
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)
3885 }
39 .accessibilityElement()
40 .accessibilityLabel("Rockets to scale: " + rockets.map { "\($0.title) \(Int($0.drawing!.heightM!)) metres" }.joined(separator: ", "))
86 .frame(width: column)
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
41112 }
42113}