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

04b2b1d914788057bcd20945b876847386eb8f65
moonrise/App/Sources/SystemScreen.swift history · blame · raw

193 lines · 9167 bytes

5 symbols in this file
  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}