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

App/Sources/RocketLineup.swift

v1.0.0
moonrise/App/Sources/RocketLineup.swift history · blame · raw

115 lines · 5495 bytes

  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}