Commit a04837ac3f
Verified · cmc
Layout: unified · split
App/Sources/LibraryScreen.swift +6 −1
| @@ -178,7 +178,12 @@ struct LibraryScreen: View { | ||
| 178 | 178 | } |
| 179 | 179 | if !Library.pages(in: .rockets).isEmpty { |
| 180 | 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 | 187 | .background(Color.black.ignoresSafeArea()) |
| 183 | 188 | .navigationTitle("Rockets to scale") |
| 184 | 189 | } |
App/Sources/RocketLineup.swift +95 −24
| @@ -1,42 +1,113 @@ | ||
| 1 | 1 | import Library |
| 2 | 2 | import 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. | |
| 5 | 6 | struct RocketLineup: View { |
| 6 | 7 | var highlight: String? |
| 7 | 8 | var ink: Color |
| 8 | 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 | 16 | private var rockets: [LibraryPage] { |
| 11 | 17 | Library.pages(in: .rockets).filter { $0.drawing?.heightM != nil }.sorted { ($0.drawing?.heightM ?? 0) < ($1.drawing?.heightM ?? 0) } |
| 12 | 18 | } |
| 13 | 19 | |
| 14 | 20 | 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) | |
| 31 | 50 | } |
| 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) } } | |
| 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() | |
| 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. | |
| 95 | struct 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 | } |