Commit a04837ac3f
Verified · cmc
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 @@ | |||
| 1 | import Library | 1 | import Library |
| 2 | import SwiftUI | 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 | struct RocketLineup: View { | 6 | struct 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. | ||
| 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 | } |