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/ScaleScreen.swift

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

103 lines · 4905 bytes

  1import Library
  2import SwiftUI
  3
  4/// Bodies, stars, nebulae and galaxies at true relative size. Zoom with the slider or a pinch;
  5/// objects between 1/100 of the shown width and the full width are drawn side by side.
  6struct ScaleScreen: View {
  7    var ink: Color
  8    var accent: Color
  9    /// log10 of the width shown, in metres.
 10    @State private var exponent = 7.4
 11    @State private var pinchStart: Double?
 12    @State private var selected: LibraryPage?
 13
 14    private let range = 2.5...27.0
 15
 16    private var width: Double { pow(10, exponent) }
 17
 18    /// Zooms by a factor of ten per tap, snapping to the nearest whole power of ten first.
 19    private func zoomButton(_ symbol: String, _ label: String, by step: Double) -> some View {
 20        Button {
 21            withAnimation(.easeInOut(duration: 0.35)) {
 22                let snapped = step < 0 ? ceil(exponent) - 1 : floor(exponent) + 1
 23                exponent = min(max(snapped, range.lowerBound), range.upperBound)
 24            }
 25        } label: {
 26            Image(systemName: symbol).font(.title3).frame(width: 48, height: 48)
 27                .background(ink.opacity(0.1), in: Circle())
 28        }
 29        .buttonStyle(.plain)
 30        .accessibilityLabel(label)
 31        .disabled(step < 0 ? exponent <= range.lowerBound : exponent >= range.upperBound)
 32    }
 33
 34    /// The width to two significant figures, for the label.
 35    private var roundedWidth: Double {
 36        let p = pow(10, floor(exponent) - 1)
 37        return (width / p).rounded() * p
 38    }
 39
 40    private var shown: [ScaleObject] {
 41        Library.scaleObjects.filter { $0.meters <= width && $0.meters >= width / 100 }
 42    }
 43
 44    var body: some View {
 45        VStack(alignment: .leading, spacing: 16) {
 46            Text("Width shown: \(Library.formatSize(roundedWidth))").font(.subheadline.monospacedDigit()).dimmed(0.8)
 47            GeometryReader { geo in
 48                let side = geo.size.width
 49                ScrollView(.horizontal, showsIndicators: false) {
 50                    HStack(alignment: .bottom, spacing: 16) {
 51                        if shown.isEmpty {
 52                            Text("Nothing in the Library at this scale.").font(.footnote).dimmed(0.6)
 53                        }
 54                        ForEach(shown) { o in
 55                            let d = max(o.meters / width * side, 2)
 56                            Button {
 57                                selected = o.page.flatMap { Library.page(id: $0) }
 58                            } label: {
 59                                VStack(spacing: 6) {
 60                                    Circle().fill(o.page == nil ? ink.opacity(0.18) : (Color(hex: o.page.flatMap { Library.page(id: $0) }?.drawing?.color) ?? ink.opacity(0.55)))
 61                                        .overlay(Circle().stroke(ink.opacity(0.5), lineWidth: 0.5))
 62                                        .frame(width: d, height: d)
 63                                    Text(o.name).font(.caption2).lineLimit(1)
 64                                    Text(Library.formatSize(o.meters)).font(.caption2.monospacedDigit()).dimmed(0.6)
 65                                }
 66                                .frame(minWidth: 64)
 67                            }
 68                            .buttonStyle(.plain)
 69                            .disabled(o.page == nil)
 70                            .accessibilityLabel("\(o.name), \(Library.formatSize(o.meters))")
 71                        }
 72                    }
 73                    .frame(minHeight: geo.size.height, alignment: .bottom)
 74                    .padding(.horizontal, 4)
 75                }
 76                .defaultScrollAnchor(.trailing)
 77                .scrollClipDisabled()
 78            }
 79            .frame(maxHeight: .infinity)
 80            .gesture(MagnifyGesture()
 81                .onChanged { g in
 82                    if pinchStart == nil { pinchStart = exponent }
 83                    exponent = min(max(pinchStart! - log10(g.magnification), range.lowerBound), range.upperBound)
 84                }
 85                .onEnded { _ in pinchStart = nil })
 86            HStack(spacing: 12) {
 87                zoomButton("plus.magnifyingglass", "Zoom in", by: -1)
 88                Slider(value: $exponent, in: range) { Text("Width shown") }
 89                    .accessibilityValue(Library.formatSize(roundedWidth))
 90                zoomButton("minus.magnifyingglass", "Zoom out", by: 1)
 91            }
 92            Text("Each tap of + or − is a factor of ten. You can also drag the slider or pinch. Objects without a Library page are drawn faint.")
 93                .font(.caption2).dimmed(0.5)
 94        }
 95        .padding(20)
 96        .foregroundStyle(ink)
 97        .tint(accent)
 98        .background(Color.black.ignoresSafeArea())
 99        .navigationTitle("Scale of the universe")
100        .navigationBarTitleDisplayMode(.inline)
101        .sheet(item: $selected) { page in NavigationStack { LibraryPageView(page: page) } }
102    }
103}