App/Sources/SystemScreen.swift
193 lines · 9167 bytes
1import Library
2import SkyRenderer
3import SwiftUI
4
5struct SystemScreen: View {
6 /// Days from now shown by the slider.
7 @State private var offsetDays = 0.0
8 @State private var playing = false
9 @State private var libraryPage: LibraryPage?
10 @AppStorage("systemMode") private var mode = "2d"
11 @AppStorage("solarScale") private var solarScale = SolarScale.compressed
12 @Environment(\.dynamicTypeSize) private var typeSize
13
14 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
15 private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
16 private let background = Color.black
17
18 private static let start = Date(timeIntervalSince1970: -378_691_200) // 1958-01-01
19 private static let end = Date(timeIntervalSince1970: 2_524_608_000) // 2050-01-01
20 private var range: ClosedRange<Double> {
21 (Self.start.timeIntervalSinceNow / 86_400)...(Self.end.timeIntervalSinceNow / 86_400)
22 }
23 private var date: Date { .now.addingTimeInterval(offsetDays * 86_400) }
24
25 var body: some View {
26 Group {
27 if mode == "3d" { threeD } else { twoD }
28 }
29 .foregroundStyle(ink)
30 .background(background.ignoresSafeArea())
31 .sheet(item: $libraryPage) { page in
32 NavigationStack { LibraryPageView(page: page) }
33 }
34 .task(id: playing) {
35 while playing && !Task.isCancelled {
36 try? await Task.sleep(for: .milliseconds(33))
37 offsetDays = min(offsetDays + (mode == "3d" ? 2 : 30), range.upperBound)
38 if offsetDays >= range.upperBound { playing = false }
39 }
40 }
41 }
42
43 private var modePicker: some View {
44 Picker("View", selection: $mode) {
45 Text("Map").tag("2d")
46 Text("3D").tag("3d")
47 }
48 .pickerStyle(.segmented)
49 }
50
51 private var threeD: some View {
52 VStack(alignment: .leading, spacing: 0) {
53 VStack(alignment: .leading, spacing: 0) {
54 Text("Solar system").font(.title.weight(.medium))
55 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12)
56 modePicker.padding(.bottom, 8)
57 Picker("Scale", selection: $solarScale) {
58 ForEach(SolarScale.allCases) { Text($0.name).tag($0) }
59 }
60 .pickerStyle(.segmented)
61 Text(solarScale == .compressed
62 ? "Distances squeezed and bodies enlarged so everything fits. Drag to turn, pinch to zoom, tap anything. Moons appear as you zoom in to their planet."
63 : "Real distances and sizes: the planets are specks, so circles mark them. Tap one to fly to it.")
64 .font(.caption).dimmed(0.7).padding(.top, 8)
65 }
66 .padding(.horizontal, 20)
67 SolarSystem3DView(date: date, live: abs(offsetDays) < 1, scale: $solarScale, ink: ink, accent: accent) { libraryPage = $0 }
68 .frame(maxWidth: .infinity, maxHeight: .infinity)
69 .clipped()
70 .contentShape(Rectangle())
71 timeControls
72 .padding(.horizontal, 20)
73 .padding(.vertical, 6)
74 .background(background)
75 .zIndex(1)
76 }
77 }
78
79 private var timeControls: some View {
80 HStack(spacing: 12) {
81 Button { playing.toggle() } label: {
82 Image(systemName: playing ? "pause.fill" : "play.fill").frame(width: 32, height: 32)
83 }
84 .accessibilityLabel(playing ? "Pause" : "Play")
85 Text(date.formatted(.dateTime.year().month(.abbreviated)))
86 .font(.subheadline.weight(.medium).monospacedDigit())
87 .fixedSize()
88 Slider(value: $offsetDays, in: range).tint(ink)
89 Button { playing = false; offsetDays = 0 } label: {
90 Text("Today").frame(minWidth: 44, minHeight: 44).contentShape(Rectangle())
91 }
92 .font(.footnote.weight(.medium))
93 .opacity(abs(offsetDays) < 1 ? 0.6 : 1)
94 }
95 }
96
97 private var twoD: some View {
98 ScrollView {
99 VStack(alignment: .leading, spacing: 0) {
100 Text("Solar system").font(.title.weight(.medium))
101 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12)
102 Text("The planets, and the five spacecraft travelling out of the solar system, seen from above. Drag the slider or press play to watch them move between 1958 and 2050.")
103 .font(.subheadline).dimmed(0.75).padding(.bottom, 12)
104 modePicker.padding(.bottom, 16)
105
106 LiveSection(ink: ink, accent: accent)
107
108 // Labels in this dense diagram stop growing at xxLarge; the table below scales fully.
109 OrreryView(date: date, ink: ink, accent: accent)
110 .dynamicTypeSize(...DynamicTypeSize.xxLarge)
111 .accessibilityElement()
112 .accessibilityLabel("Orrery for \(date.formatted(.dateTime.year().month(.wide))). Planets and spacecraft distances are listed below.")
113
114 timeControls.padding(.top, 12)
115
116 Text("Distances beyond 1 AU are drawn on a logarithmic scale. Rings: planet orbits, then 50, 100 and 200 AU.")
117 .font(.caption2).dimmed(0.6).padding(.top, 8)
118
119 section("Spacecraft", note: "How far each probe is from the Sun, and how long its radio signal takes to reach us. Tap one to read about it.") {
120 if typeSize.isAccessibilitySize {
121 VStack(alignment: .leading, spacing: 12) {
122 ForEach(Spacecraft.all) { craft in
123 VStack(alignment: .leading, spacing: 2) {
124 craftName(craft)
125 Text(craftDistance(craft)).monospacedDigit().dimmed(0.7)
126 }
127 .font(.subheadline)
128 }
129 }
130 } else {
131 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
132 GridRow {
133 Text("").gridColumnAlignment(.leading)
134 Text("From Sun").dimmed(0.6)
135 Text("Light time").dimmed(0.6)
136 }
137 .font(.caption2)
138 ForEach(Spacecraft.all) { craft in
139 GridRow {
140 craftName(craft)
141 if let d = craft.distance(at: date) {
142 Text(String(format: "%.1f AU", d)).monospacedDigit()
143 Text(lightTime(au: d)).monospacedDigit()
144 } else {
145 Text("launch \(craft.launch.prefix(4))").dimmed(0.5)
146 Text("")
147 }
148 }
149 .font(.subheadline)
150 }
151 }
152 }
153 }
154 DSNSection(ink: ink, accent: accent).padding(.top, 24)
155 MarsSection().padding(.top, 24)
156 Text("Spacecraft positions: NASA/JPL Horizons. Pioneer 10 and 11 positions after contact was lost are predictions.")
157 .font(.caption2).dimmed(0.5).padding(.vertical, 16)
158 }
159 .padding(.horizontal, 20)
160 .readableWidth()
161 }
162 }
163
164 @ViewBuilder
165 private func craftName(_ craft: Spacecraft) -> some View {
166 if let page = Library.page(spacecraft: craft.name) {
167 Button { libraryPage = page } label: { Text(craft.name).underline().frame(minHeight: 44, alignment: .leading).contentShape(Rectangle()) }.buttonStyle(.plain)
168 } else {
169 Text(craft.name)
170 }
171 }
172
173 private func craftDistance(_ craft: Spacecraft) -> String {
174 guard let d = craft.distance(at: date) else { return "launch \(craft.launch.prefix(4))" }
175 return String(format: "%.1f AU from the Sun · light time %@", d, lightTime(au: d))
176 }
177
178 /// One-way light time from the Sun's distance, as hours and minutes.
179 private func lightTime(au: Double) -> String {
180 let seconds = au * 499.004784
181 let h = Int(seconds / 3600), m = Int(seconds.truncatingRemainder(dividingBy: 3600) / 60)
182 return h > 0 ? "\(h) h \(m) m" : "\(m) m"
183 }
184
185 private func section(_ title: String, note: String? = nil, @ViewBuilder content: () -> some View) -> some View {
186 VStack(alignment: .leading, spacing: 10) {
187 Text(title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
188 if let note { Text(note).font(.footnote).dimmed(0.75) }
189 content()
190 }
191 .padding(.top, 24)
192 }
193}