App/Sources/ScaleScreen.swift
103 lines · 4905 bytes
2 symbols in this file
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}