App/Sources/RocketLineup.swift
115 lines · 5495 bytes
4 symbols in this file
1import Library
2import SwiftUI
3
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.
6struct RocketLineup: View {
7 var highlight: String?
8 var ink: Color
9 var accent: Color
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
16 private var rockets: [LibraryPage] {
17 Library.pages(in: .rockets).filter { $0.drawing?.heightM != nil }.sorted { ($0.drawing?.heightM ?? 0) < ($1.drawing?.heightM ?? 0) }
18 }
19
20 var body: some View {
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)
50 }
51 .onAppear { if let highlight { proxy.scrollTo(highlight, anchor: .center) } }
52 }
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 // Fins reach 0.35 × width past each side, so wide rockets get a wider column.
74 let width = max(column, w * 1.7 + 14)
75 return VStack(spacing: 4) {
76 Text(String(format: "%.0f m", d.heightM!)).font(.caption2.monospacedDigit()).foregroundStyle(ink.opacity(0.7))
77 .frame(height: top - 4)
78 RocketShape()
79 .fill(mine ? accent : ink.opacity(0.75))
80 .frame(width: w, height: h)
81 Rectangle().fill(ink.opacity(0.35)).frame(width: width, height: 0.5)
82 Text(r.title).font(.caption2.weight(mine ? .semibold : .regular))
83 .foregroundStyle(mine ? accent : ink.opacity(0.85))
84 .multilineTextAlignment(.center)
85 .lineLimit(2)
86 .frame(width: width - 6, height: bottom - 6, alignment: .top)
87 }
88 .frame(width: width)
89 .contentShape(Rectangle())
90 .accessibilityElement(children: .ignore)
91 .accessibilityLabel("\(r.title), \(Int(d.heightM!)) metres")
92 }
93}
94
95/// A generic rocket: ogive nose, straight body, small fins at the base. Drawn to the frame,
96/// with fins reaching a little outside the body's width.
97struct RocketShape: Shape {
98 func path(in rect: CGRect) -> Path {
99 let w = rect.width, h = rect.height
100 let nose = min(h * 0.14, w * 2.4)
101 let fin = min(h * 0.08, w * 1.2)
102 let spread = w * 0.35
103 var p = Path()
104 p.move(to: CGPoint(x: rect.midX, y: rect.minY))
105 p.addQuadCurve(to: CGPoint(x: rect.maxX, y: rect.minY + nose), control: CGPoint(x: rect.maxX, y: rect.minY + nose * 0.25))
106 p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY - fin))
107 p.addLine(to: CGPoint(x: rect.maxX + spread, y: rect.maxY))
108 p.addLine(to: CGPoint(x: rect.minX - spread, y: rect.maxY))
109 p.addLine(to: CGPoint(x: rect.minX, y: rect.maxY - fin))
110 p.addLine(to: CGPoint(x: rect.minX, y: rect.minY + nose))
111 p.addQuadCurve(to: CGPoint(x: rect.midX, y: rect.minY), control: CGPoint(x: rect.minX, y: rect.minY + nose * 0.25))
112 p.closeSubpath()
113 return p
114 }
115}