Polish: tours, toolbar, introduction, sheets, explanations, in-app links !41

merged merged by cmc on 2026-10-19 04:53 UTC · krz/moonrise:polish-1 into main

13 files changed, +491 −132

Layout: unified · split

App/Sources/DSNSection.swift +2
@@ -15,6 +15,8 @@ struct DSNSection: View {
1515 var body: some View {
1616 VStack(alignment: .leading, spacing: 10) {
1717 Text("Deep Space Network now").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
18 Text("NASA talks to its deep-space probes through giant dishes at three sites spaced around the world, so one always faces any spacecraft. This is who each site is talking to now.")
19 .font(.footnote).dimmed(0.75)
1820 if let status {
1921 ForEach(["Goldstone", "Madrid", "Canberra"], id: \.self) { station in
2022 let dishes = activeDishes.filter { $0.station == station }
App/Sources/EventsScreen.swift +61 −37
@@ -24,67 +24,91 @@ struct EventsScreen: View {
2424
2525 var body: some View {
2626 NavigationStack {
27 List {
28 Section {
27 ScrollView {
28 LazyVStack(alignment: .leading, spacing: 0, pinnedViews: .sectionHeaders) {
29 Text("Events").font(.title.weight(.medium))
30 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12)
31 Text("Eclipses, meteor showers and planets at their best over the next year, as seen \(placeName == "Your location" ? "from where you are" : "from \(placeName)"). Tap a solar eclipse for its path and local times.")
32 .font(.subheadline).foregroundStyle(ink.opacity(0.75))
2933 Picker("Show", selection: $filter) {
30 Text("Notable").tag("notable")
34 Text("Highlights").tag("notable")
3135 Text("Everything").tag("all")
3236 }
3337 .pickerStyle(.segmented)
34 .listRowBackground(Color.clear)
35 } footer: {
36 Text("Visibility is for \(placeName). Meteor shower peaks are typical dates and can shift by a day.")
37 }
38 if events.isEmpty {
39 ProgressView().frame(maxWidth: .infinity).listRowBackground(Color.clear)
40 }
41 ForEach(months, id: \.0) { month, items in
42 Section(month) {
43 ForEach(items) { e in
44 if e.kind == .solarEclipse {
45 Button { eclipse = e } label: { row(e) }
46 .buttonStyle(.plain)
47 .accessibilityHint("Shows the path and local times")
48 } else {
49 row(e)
38 .padding(.vertical, 16)
39
40 if events.isEmpty {
41 ProgressView().tint(ink).frame(maxWidth: .infinity).padding(.top, 40)
42 }
43 ForEach(months, id: \.0) { month, items in
44 Section {
45 ForEach(items) { e in
46 if e.kind == .solarEclipse {
47 Button { eclipse = e } label: { row(e, link: true) }
48 .buttonStyle(.plain)
49 .accessibilityHint("Shows the path and local times")
50 } else {
51 row(e, link: false)
52 }
5053 }
54 } header: {
55 Text(month).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8)
56 .foregroundStyle(ink.opacity(0.6))
57 .frame(maxWidth: .infinity, alignment: .leading)
58 .padding(.top, 20).padding(.bottom, 6)
59 .background(Color.black)
5160 }
5261 }
62 Text("Meteor shower peaks are typical dates and can shift by a day.")
63 .font(.caption2).foregroundStyle(ink.opacity(0.5)).padding(.vertical, 20)
5364 }
65 .padding(.horizontal, 20)
66 .readableWidth()
5467 }
55 .listStyle(.insetGrouped)
56 .scrollContentBackground(.hidden)
5768 .background(Color.black.ignoresSafeArea())
58 .navigationTitle("Events")
59 .navigationBarTitleDisplayMode(.inline)
6069 .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
6170 .sheet(item: $eclipse) { e in EclipseScreen(date: e.date, observer: observer, placeName: placeName) }
6271 }
6372 .foregroundStyle(ink)
73 .tint(accent)
6474 .task {
6575 let observer = observer
6676 events = await Task.detached { SkyEngine.events(from: .now, observer: observer) }.value
6777 }
6878 }
6979
70 private func row(_ e: SkyEvent) -> some View {
71 HStack(alignment: .firstTextBaseline, spacing: 12) {
72 Image(systemName: symbol(e.kind)).frame(width: 22).dimmed(0.8).accessibilityHidden(true)
73 VStack(alignment: .leading, spacing: 3) {
74 Text(e.title).font(.body)
75 Text(e.date.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute()))
76 .font(.footnote.monospacedDigit()).dimmed(0.7)
77 if !e.detail.isEmpty { Text(e.detail).font(.footnote).dimmed(0.8) }
80 private func row(_ e: SkyEvent, link: Bool) -> some View {
81 HStack(alignment: .top, spacing: 14) {
82 VStack(spacing: 0) {
83 Text(e.date.formatted(.dateTime.day())).font(.title2.weight(.medium).monospacedDigit())
84 Text(e.date.formatted(.dateTime.weekday(.abbreviated))).font(.caption2).textCase(.uppercase).foregroundStyle(ink.opacity(0.6))
85 }
86 .frame(width: 40)
87 VStack(alignment: .leading, spacing: 4) {
88 HStack(alignment: .firstTextBaseline, spacing: 6) {
89 Image(systemName: symbol(e.kind)).font(.subheadline).foregroundStyle(e.visibleHere == true ? accent : ink.opacity(0.7))
90 .accessibilityHidden(true)
91 Text(e.title).font(.body.weight(.medium))
92 }
93 Text(e.date.formatted(date: .omitted, time: .shortened)).font(.footnote.monospacedDigit()).foregroundStyle(ink.opacity(0.6))
94 if !e.detail.isEmpty { Text(e.detail).font(.footnote).foregroundStyle(ink.opacity(0.8)) }
7895 if let v = e.visibleHere {
79 Text(v ? "Visible from here" : "Not visible from here")
80 .font(.caption.weight(.medium))
81 .foregroundStyle(v ? ink : ink.opacity(0.7))
82 .padding(.horizontal, 6).padding(.vertical, 2)
83 .overlay(RoundedRectangle(cornerRadius: 3).stroke(v ? accent : ink.opacity(0.4), lineWidth: 1))
96 HStack(spacing: 5) {
97 Circle().fill(v ? accent : ink.opacity(0.35)).frame(width: 6, height: 6)
98 Text(v ? "Visible from here" : "Not visible from here").font(.caption.weight(.medium))
99 .foregroundStyle(v ? ink : ink.opacity(0.6))
100 }
101 .padding(.top, 2)
84102 }
85103 }
104 Spacer(minLength: 0)
105 if link {
106 Image(systemName: "chevron.right").font(.footnote.weight(.semibold)).foregroundStyle(ink.opacity(0.4)).padding(.top, 6)
107 }
86108 }
87 .padding(.vertical, 4)
109 .padding(.vertical, 12)
110 .overlay(alignment: .bottom) { Rectangle().fill(ink.opacity(0.12)).frame(height: 0.5) }
111 .contentShape(Rectangle())
88112 .accessibilityElement(children: .combine)
89113 }
90114
App/Sources/InAppBrowser.swift added +24
@@ -0,0 +1,24 @@
1import SafariServices
2import SwiftUI
3import UIKit
4
5/// Opens web links in Safari's in-app browser over the current screen, so people stay in the app.
6/// Other schemes (mailto:, settings) go to the system as usual.
7@MainActor
8enum InAppBrowser {
9 static let action = OpenURLAction { url in
10 guard url.scheme == "https" || url.scheme == "http", let top = topViewController() else { return .systemAction }
11 let safari = SFSafariViewController(url: url)
12 safari.preferredControlTintColor = UIColor(red: 0.88, green: 0.24, blue: 0.19, alpha: 1)
13 safari.dismissButtonStyle = .done
14 top.present(safari, animated: true)
15 return .handled
16 }
17
18 private static func topViewController() -> UIViewController? {
19 let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first { $0.activationState == .foregroundActive }
20 var top = scene?.keyWindow?.rootViewController
21 while let presented = top?.presentedViewController { top = presented }
22 return top
23 }
24}
App/Sources/MarsSection.swift +2
@@ -10,6 +10,8 @@ struct MarsSection: View {
1010 var body: some View {
1111 VStack(alignment: .leading, spacing: 10) {
1212 Text("Mars now").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
13 Text("The time of day where NASA's two rovers are working, and their newest raw photos, usually a few hours old.")
14 .font(.footnote).dimmed(0.75)
1315 VStack(alignment: .leading, spacing: 8) {
1416 ForEach(MarsTime.Rover.active) { rover in
1517 TimelineView(.periodic(from: .now, by: 1)) { ctx in
App/Sources/MoonriseApp.swift +1
@@ -26,6 +26,7 @@ struct MoonriseApp: App {
2626 // Red tab highlight while the Sky tab is in night mode.
2727 .tint(router.tab == "sky" && nightActive ? SkyStyle.nightRed : nil)
2828 .preferredColorScheme(.dark)
29 .environment(\.openURL, InAppBrowser.action)
2930 .fullScreenCover(isPresented: .constant(!onboarded)) {
3031 OnboardingScreen().preferredColorScheme(.dark)
3132 }
App/Sources/OnboardingScreen.swift +173 −15
@@ -1,30 +1,137 @@
1import SkyRenderer
12import SwiftUI
3import UserNotifications
24
3/// First launch: what the app does and why it asks for location, before iOS asks.
5/// First launch: what the app does, optional alerts, and location. Can be shown again from Settings.
46struct OnboardingScreen: View {
57 @AppStorage(SettingsKey.onboarded) private var onboarded = false
68 @AppStorage(SettingsKey.locationMode) private var locationMode = "current"
79 @AppStorage(SettingsKey.cityName) private var cityName = "Chicago"
810 @AppStorage(SettingsKey.cityLatitude) private var cityLatitude = 41.878
911 @AppStorage(SettingsKey.cityLongitude) private var cityLongitude = -87.630
12 @AppStorage(SettingsKey.dailySummary) private var dailySummary = false
13 @State private var alerts = AuroraAlerts.shared
14 @State private var page = 0
1015 @State private var choosingCity = false
16 @State private var notificationsDenied = false
1117
1218 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
1319 private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
14 private let background = Color.black
20 private let pages = 5
1521
1622 var body: some View {
23 VStack(spacing: 0) {
24 HStack {
25 Spacer()
26 if page < pages - 1 {
27 Button("Skip") { withAnimation { page = pages - 1 } }
28 .font(.subheadline.weight(.medium))
29 .foregroundStyle(ink.opacity(0.7))
30 .frame(minHeight: 44)
31 }
32 }
33 .padding(.horizontal, 24)
34 .frame(height: 44)
35
36 TabView(selection: $page) {
37 welcome.tag(0)
38 sky.tag(1)
39 beyond.tag(2)
40 loop.tag(3)
41 location.tag(4)
42 }
43 .tabViewStyle(.page(indexDisplayMode: .never))
44
45 HStack(spacing: 6) {
46 ForEach(0..<pages, id: \.self) { i in
47 Capsule().fill(i == page ? ink : ink.opacity(0.25)).frame(width: i == page ? 18 : 6, height: 6)
48 }
49 }
50 .animation(.easeInOut(duration: 0.2), value: page)
51 .accessibilityHidden(true)
52 .padding(.bottom, 16)
53
54 if page < pages - 1 {
55 button("Continue", filled: true) { withAnimation { page += 1 } }
56 .padding(.horizontal, 24)
57 }
58 }
59 .padding(.bottom, 24)
60 .foregroundStyle(ink)
61 .background(Color.black.ignoresSafeArea())
62 .tint(accent)
63 }
64
65 // MARK: Pages
66
67 private var welcome: some View {
1768 VStack(alignment: .leading, spacing: 0) {
1869 Spacer()
70 MoonDisc(fraction: 0.55, waxing: true)
71 .frame(width: 96, height: 96)
72 .padding(.bottom, 28)
73 .accessibilityHidden(true)
1974 Text("Moonrise").font(.largeTitle.weight(.medium))
20 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 10).padding(.bottom, 24)
21 VStack(alignment: .leading, spacing: 14) {
22 Text("The sky above you tonight, the Sun today, launches, the solar system, and a library of everything up there.")
23 Text("To draw the sky for where you are, Moonrise uses your location. It is used on this device to work out positions and is not sent to us or anyone else.")
24 .foregroundStyle(ink.opacity(0.75))
25 }
26 .font(.body)
75 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.vertical, 14)
76 Text("The night sky, the Sun and spaceflight in one place: what's up tonight, what's coming, and the story behind it.")
77 .font(.title3)
2778 Spacer()
79 Spacer()
80 }
81 .padding(.horizontal, 24)
82 .frame(maxWidth: 560, alignment: .leading)
83 .frame(maxWidth: .infinity)
84 }
85
86 private var sky: some View {
87 pageLayout {
88 heading("Your sky, tonight", "The Sky tab shows the stars and planets above you right now.")
89 point("hand.tap", "Tap anything", "A star, planet or constellation shows what it is and opens its Library page.")
90 point("hand.draw", "Look around", "Drag to turn and pinch to zoom, or point your phone at the sky from the Explore menu.")
91 point("clock", "Change the time", "Slide along the bottom to see the sky later tonight.")
92 point("figure.walk", "Take a tour", "Guided tours walk you through the brightest sights of the season, one at a time.")
93 }
94 }
95
96 private var beyond: some View {
97 pageLayout {
98 heading("Beyond tonight", "Four more tabs, each answering a different question.")
99 point("circle.circle", "System", "Where the planets and the spacecraft heading out of the solar system are, from 1958 to 2050.")
100 point("sun.max", "Sun", "Today's Sun from NASA, solar flares, and where the aurora may be visible tonight.")
101 point("airplane.departure", "News & launches", "Upcoming rocket launches with countdowns, and the day's space news.")
102 point("books.vertical", "Library", "370 illustrated pages on planets, stars, missions, rockets and more.")
103 }
104 }
105
106 private var loop: some View {
107 pageLayout {
108 heading("Stay in the loop", "The sky changes every night. Moonrise can tell you when something is worth stepping outside for. All optional; change them any time in Settings.")
109 toggle("Daily summary", "Each evening: the Moon, the planets up, the best satellite pass and any events. Worked out on your phone.", isOn: Binding(
110 get: { dailySummary },
111 set: { on in Task {
112 if on, !(await permission()) { return }
113 dailySummary = on
114 } }
115 ))
116 toggle("Aurora alerts", "When the aurora may be visible from your area at night.", isOn: Binding(
117 get: { alerts.enabled },
118 set: { on in Task { await alerts.setEnabled(on) } }
119 ))
120 toggle("Space news", "Three of the day's headlines at 9 a.m.", isOn: Binding(
121 get: { alerts.news },
122 set: { on in Task { await alerts.setNews(on) } }
123 ))
124 if notificationsDenied || alerts.status != nil {
125 Text("Notifications are off for Moonrise. You can turn them on in the Settings app.")
126 .font(.footnote).foregroundStyle(accent)
127 }
128 }
129 }
130
131 private var location: some View {
132 VStack(alignment: .leading, spacing: 0) {
133 heading("Where are you?", "Moonrise draws the sky for where you are. Your location is used on this phone to work out positions and is never sent to us or anyone else.")
134 Spacer(minLength: 0)
28135 if choosingCity {
29136 Picker("City", selection: $cityName) {
30137 ForEach(City.all) { Text($0.name).tag($0.name) }
@@ -39,6 +146,7 @@ struct OnboardingScreen: View {
39146 locationMode = "city"
40147 onboarded = true
41148 }
149 button("Back", filled: false) { choosingCity = false }
42150 } else {
43151 button("Use my location", filled: true) {
44152 locationMode = "current"
@@ -48,11 +156,61 @@ struct OnboardingScreen: View {
48156 }
49157 }
50158 .padding(.horizontal, 24)
51 .padding(.bottom, 24)
52 .frame(maxWidth: 560)
159 .padding(.top, 24)
160 .frame(maxWidth: 560, alignment: .leading)
53161 .frame(maxWidth: .infinity)
54 .foregroundStyle(ink)
55 .background(background.ignoresSafeArea())
162 }
163
164 // MARK: Parts
165
166 private func pageLayout(@ViewBuilder _ content: () -> some View) -> some View {
167 ScrollView {
168 VStack(alignment: .leading, spacing: 0) {
169 content()
170 }
171 .padding(.horizontal, 24)
172 .padding(.top, 24)
173 .frame(maxWidth: 560, alignment: .leading)
174 .frame(maxWidth: .infinity)
175 }
176 .scrollBounceBehavior(.basedOnSize)
177 }
178
179 private func heading(_ title: String, _ text: String) -> some View {
180 VStack(alignment: .leading, spacing: 10) {
181 Text(title).font(.title.weight(.medium))
182 Rectangle().fill(accent).frame(width: 48, height: 5)
183 Text(text).font(.body).foregroundStyle(ink.opacity(0.75))
184 }
185 .padding(.bottom, 24)
186 }
187
188 private func point(_ symbol: String, _ title: String, _ text: String) -> some View {
189 HStack(alignment: .top, spacing: 14) {
190 Image(systemName: symbol).font(.title3).frame(width: 30).foregroundStyle(accent).accessibilityHidden(true)
191 VStack(alignment: .leading, spacing: 3) {
192 Text(title).font(.headline)
193 Text(text).font(.subheadline).foregroundStyle(ink.opacity(0.75))
194 }
195 }
196 .padding(.bottom, 18)
197 .accessibilityElement(children: .combine)
198 }
199
200 private func toggle(_ title: String, _ text: String, isOn: Binding<Bool>) -> some View {
201 Toggle(isOn: isOn) {
202 VStack(alignment: .leading, spacing: 3) {
203 Text(title).font(.headline)
204 Text(text).font(.subheadline).foregroundStyle(ink.opacity(0.75))
205 }
206 }
207 .padding(.bottom, 18)
208 }
209
210 private func permission() async -> Bool {
211 let granted = (try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound])) ?? false
212 notificationsDenied = !granted
213 return granted
56214 }
57215
58216 private func button(_ title: String, filled: Bool, action: @escaping () -> Void) -> some View {
@@ -62,8 +220,8 @@ struct OnboardingScreen: View {
62220 .frame(maxWidth: .infinity)
63221 .padding(.vertical, 14)
64222 .foregroundStyle(filled ? Color.black : ink)
65 .background(filled ? ink : .clear, in: .rect(cornerRadius: 8))
66 .overlay(RoundedRectangle(cornerRadius: 8).stroke(ink.opacity(filled ? 0 : 0.5), lineWidth: 0.5))
223 .background(filled ? ink : .clear, in: .rect(cornerRadius: 12))
224 .overlay(RoundedRectangle(cornerRadius: 12).stroke(ink.opacity(filled ? 0 : 0.5), lineWidth: 0.5))
67225 }
68226 .buttonStyle(.plain)
69227 .padding(.top, 10)
App/Sources/SettingsScreen.swift +6
@@ -65,6 +65,7 @@ struct SettingsScreen: View {
6565 @AppStorage(SettingsKey.cityLongitude) private var cityLongitude = -87.630
6666 @AppStorage(SettingsKey.nightMode) private var nightMode = "auto"
6767 @AppStorage(SettingsKey.skyCulture) private var skyCulture = "western"
68 @AppStorage(SettingsKey.onboarded) private var onboarded = true
6869 @Environment(\.dismiss) private var dismiss
6970 @State private var alerts = AuroraAlerts.shared
7071 @AppStorage(SettingsKey.dailySummary) private var dailySummary = false
@@ -137,6 +138,11 @@ struct SettingsScreen: View {
137138 }
138139 Section("About") {
139140 LabeledContent("Version", value: Bundle.main.versionString)
141 Button("Show the introduction again") {
142 dismiss()
143 // After the sheet has gone, so the full-screen introduction can present.
144 Task { try? await Task.sleep(for: .milliseconds(600)); onboarded = false }
145 }
140146 NavigationLink("Credits and licences") { CreditsScreen() }
141147 Link("Privacy policy", destination: privacyPolicyURL)
142148 }
App/Sources/SkyScreen.swift +66 −50
@@ -23,6 +23,7 @@ struct SkyScreen: View {
2323 @State private var showTours = false
2424 @State private var tour: Tour?
2525 @State private var tourStep = 0
26 @State private var turning: Task<Void, Never>?
2627 @Environment(\.scenePhase) private var scenePhase
2728 @State private var libraryPage: LibraryPage?
2829 @State private var selection: SkyObject?
@@ -41,7 +42,7 @@ struct SkyScreen: View {
4142 style.background.ignoresSafeArea()
4243 if let scene = model.scene {
4344 let view = current(width: geo.size.width)
44 SkyView(scene: scene, centerAzimuth: view.azimuth, centerAltitude: view.altitude, fieldOfView: view.fieldOfView, style: style, culture: Catalog.skyCultures.first { $0.id == skyCulture }) { [stats] d in
45 SkyView(scene: scene, centerAzimuth: view.azimuth, centerAltitude: view.altitude, fieldOfView: view.fieldOfView, style: style, culture: Catalog.skyCultures.first { $0.id == skyCulture }, highlight: tour.flatMap { highlight($0.steps[tourStep].target) }) { [stats] d in
4546 stats.record(d)
4647 }
4748 .ignoresSafeArea()
@@ -59,6 +60,16 @@ struct SkyScreen: View {
5960 }
6061 VStack(spacing: 0) {
6162 header
63 if pointer.active {
64 Button { pointer.stop() } label: {
65 Label("Following your phone · Stop", systemImage: "iphone.gen3.radiowaves.left.and.right")
66 .font(.caption.weight(.medium))
67 .padding(.horizontal, 12).padding(.vertical, 8)
68 .glassEffect(in: .capsule)
69 }
70 .buttonStyle(.plain)
71 .padding(.top, 4)
72 }
6273 if let selection { card(for: selection).padding(.horizontal, 16).padding(.top, 8) }
6374 Spacer()
6475 if let tour {
@@ -81,14 +92,13 @@ struct SkyScreen: View {
8192 .sheet(isPresented: $showEvents) {
8293 EventsScreen(observer: model.observer, placeName: model.placeName)
8394 .preferredColorScheme(.dark)
84 .tint(.blue)
8595 }
8696 .sheet(isPresented: $showTours) {
8797 TourPicker { tour = $0; tourStep = 0; aim(at: $0.steps[0].target) }.preferredColorScheme(.dark).tint(.blue)
8898 }
8999 .onChange(of: tourStep) { if let tour { aim(at: tour.steps[tourStep].target) } }
90100 .sheet(isPresented: $showYear) {
91 YearSheet(year: $model.historicalYear).preferredColorScheme(.dark).tint(.blue)
101 YearSheet(year: $model.historicalYear, south: model.observer.latitude < 0).preferredColorScheme(.dark)
92102 }
93103 .sheet(isPresented: $showSettings, onDismiss: rescheduleSummary) {
94104 SettingsScreen { model.applyLocationSettings() }
@@ -135,14 +145,33 @@ struct SkyScreen: View {
135145 return Horizontal(altitude: asin(v.z / (v * v).sum().squareRoot()) * 180 / .pi, azimuth: az)
136146 }
137147
138 /// Turns the view to a tour target, keeping it at least 10° up so the horizon stays in view.
148 private func highlight(_ t: Tour.Target) -> SkyHighlight? {
149 if let c = t.constellation { return .constellation(c) }
150 if let hr = t.hr { return .star(hr) }
151 if let b = t.body.flatMap(SkyBody.init(rawValue:)) { return .body(b) }
152 return nil
153 }
154
155 /// Pans the view to a tour target over a second, the short way round, keeping it at least
156 /// 10° up so the horizon stays in view. The Canvas redraws each frame, so the turn is stepped by hand.
139157 private func aim(at t: Tour.Target) {
140158 guard let h = tourTarget(t) else { return }
141159 pointer.stop()
142 withAnimation(.easeInOut(duration: 0.6)) {
143 azimuth = h.azimuth
144 altitude = max(h.altitude, 10)
145 fieldOfView = 80
160 turning?.cancel()
161 let from = (az: azimuth, alt: altitude, fov: fieldOfView)
162 let dAz = (h.azimuth - from.az + 540).truncatingRemainder(dividingBy: 360) - 180
163 let to = (alt: max(h.altitude, 10), fov: 80.0)
164 turning = Task {
165 let frames = 60
166 for f in 1...frames {
167 try? await Task.sleep(for: .milliseconds(16))
168 if Task.isCancelled { return }
169 let x = Double(f) / Double(frames)
170 let e = x < 0.5 ? 4 * x * x * x : 1 - pow(-2 * x + 2, 3) / 2
171 azimuth = (from.az + dAz * e + 360).truncatingRemainder(dividingBy: 360)
172 altitude = from.alt + (to.alt - from.alt) * e
173 fieldOfView = from.fov + (to.fov - from.fov) * e
174 }
146175 }
147176 }
148177
@@ -170,6 +199,7 @@ struct SkyScreen: View {
170199 private func panGesture(width: CGFloat) -> some Gesture {
171200 DragGesture(minimumDistance: 6)
172201 .onChanged { _ in
202 turning?.cancel()
173203 if pointer.active {
174204 azimuth = pointer.direction.azimuth
175205 altitude = min(max(pointer.direction.altitude, 0), 90)
@@ -213,51 +243,34 @@ struct SkyScreen: View {
213243 #endif
214244 }
215245 Spacer()
216 if pointer.available {
217 Button {
218 pointer.active ? pointer.stop() : pointer.start()
219 } label: {
220 Image(systemName: pointer.active ? "scope" : "hand.draw")
221 .font(.body)
222 .frame(width: 44, height: 44)
223 }
224 .accessibilityLabel(pointer.active ? "Stop following the phone" : "Point the phone at the sky")
225 }
226246 Menu {
227 Button { showTours = true } label: { Label("Guided tours", systemImage: "figure.walk") }
228 Button { showYear = true } label: { Label("Sky in another year", systemImage: "hourglass") }
229 } label: {
230 Image(systemName: "ellipsis.circle")
231 .font(.body)
232 .frame(width: 44, height: 44)
233 }
234 .accessibilityLabel("More")
235 Button { showLog = true } label: {
236 Image(systemName: "book.closed")
237 .font(.body)
238 .frame(width: 44, height: 44)
239 }
240 .accessibilityLabel("Observing log")
241 Button { showEvents = true } label: {
242 Image(systemName: "calendar")
243 .font(.body)
244 .frame(width: 44, height: 44)
245 }
246 .accessibilityLabel("Events")
247 Button { showSettings = true } label: {
248 Image(systemName: "gearshape")
249 .font(.body)
250 .frame(width: 44, height: 44)
251 }
252 .accessibilityLabel("Settings")
253 Button {
254 nightMode = night ? "off" : "on"
247 Section("Tonight") {
248 Button { showTours = true } label: { Label("Guided tours", systemImage: "figure.walk") }
249 Button { showEvents = true } label: { Label("Events and eclipses", systemImage: "calendar") }
250 if pointer.available {
251 Button { pointer.active ? pointer.stop() : pointer.start() } label: {
252 Label(pointer.active ? "Stop following the phone" : "Point the phone at the sky", systemImage: "iphone.gen3.radiowaves.left.and.right")
253 }
254 }
255 }
256 Section("Explore") {
257 Button { showYear = true } label: { Label("Sky in another year", systemImage: "hourglass") }
258 Button { showLog = true } label: { Label("Observing log", systemImage: "book.closed") }
259 }
260 Section {
261 Button { nightMode = night ? "off" : "on" } label: {
262 Label(night ? "Turn off night mode" : "Night mode (red light)", systemImage: night ? "eye.slash" : "eye")
263 }
264 Button { showSettings = true } label: { Label("Settings", systemImage: "gearshape") }
265 }
255266 } label: {
256 Image(systemName: night ? "eye.fill" : "eye")
257 .font(.body)
258 .frame(width: 44, height: 44)
267 Label("Explore", systemImage: "sparkle.magnifyingglass")
268 .font(.subheadline.weight(.medium))
269 .padding(.horizontal, 14)
270 .frame(minHeight: 44)
271 .glassEffect(in: .capsule)
259272 }
260 .accessibilityLabel(night ? "Turn off night mode" : "Turn on night mode")
273 .accessibilityHint("Tours, events, night mode, settings and more")
261274 }
262275 .padding(.horizontal, 20)
263276 .padding(.top, 8)
@@ -378,3 +391,6 @@ final class DrawStats: @unchecked Sendable {
378391 return String(format: "draw %.1f ms median, %.1f ms max · scene %.0f ms", median, worst, b)
379392 }
380393}
394
395/// SkyEngine's Body, named so it isn't shadowed by View.Body.
396private typealias SkyBody = Body
App/Sources/SunScreen.swift +8 −5
@@ -20,7 +20,9 @@ struct SunScreen: View {
2020 ScrollView {
2121 VStack(alignment: .leading, spacing: 0) {
2222 Text("Sun").font(.title.weight(.medium))
23 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 16)
23 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12)
24 Text("The Sun as NASA's Solar Dynamics Observatory sees it now, and the space weather it sends our way: flares, magnetic storms and the aurora.")
25 .font(.subheadline).dimmed(0.75).padding(.bottom, 16)
2426
2527 image
2628 Picker("Wavelength", selection: $wavelength) {
@@ -31,9 +33,9 @@ struct SunScreen: View {
3133 Text(wavelength.caption).font(.footnote).dimmed(0.75).padding(.top, 8)
3234 Text(SolarImage.credit).font(.caption2).dimmed(0.5).padding(.top, 4)
3335
34 section("Aurora") { auroraMap }
35 section("Solar flares, last 24 h") { flares }
36 section("Geomagnetic activity (Kp)") { kpChart }
36 section("Aurora tonight", note: "NOAA's forecast of where the aurora may be overhead in the next hour; brighter green is more likely. From a few hundred kilometres further from the pole it can still be seen low on the horizon.") { auroraMap }
37 section("Solar flares, last 24 h", note: "X-rays from the Sun, measured by NOAA's GOES satellites. Spikes are flares: M and X class can disrupt radio and GPS, and the biggest can bring a storm a day or two later.") { flares }
38 section("Geomagnetic activity (Kp)", note: "How disturbed Earth's magnetic field is, from 0 (quiet) to 9. At 5 or more it is a storm and the aurora reaches lower latitudes.") { kpChart }
3739
3840 if failed {
3941 Text("Couldn't load space weather. Pull to retry.").font(.footnote).foregroundStyle(accent).padding(.top, 12)
@@ -74,9 +76,10 @@ struct SunScreen: View {
7476 .accessibilityLabel("The Sun at \(wavelength.name)")
7577 }
7678
77 private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View {
79 private func section(_ title: String, note: String? = nil, @ViewBuilder content: () -> some View) -> some View {
7880 VStack(alignment: .leading, spacing: 10) {
7981 Text(title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
82 if let note { Text(note).font(.footnote).dimmed(0.75) }
8083 content()
8184 }
8285 .padding(.top, 24)
App/Sources/SystemScreen.swift +6 −3
@@ -24,7 +24,9 @@ struct SystemScreen: View {
2424 ScrollView {
2525 VStack(alignment: .leading, spacing: 0) {
2626 Text("Solar system").font(.title.weight(.medium))
27 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 16)
27 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12)
28 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.")
29 .font(.subheadline).dimmed(0.75).padding(.bottom, 16)
2830
2931 LiveSection(ink: ink, accent: accent)
3032
@@ -54,7 +56,7 @@ struct SystemScreen: View {
5456 Text("Distances beyond 1 AU are drawn on a logarithmic scale. Rings: planet orbits, then 50, 100 and 200 AU.")
5557 .font(.caption2).dimmed(0.6).padding(.top, 8)
5658
57 section("Spacecraft") {
59 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.") {
5860 if typeSize.isAccessibilitySize {
5961 VStack(alignment: .leading, spacing: 12) {
6062 ForEach(Spacecraft.all) { craft in
@@ -132,9 +134,10 @@ struct SystemScreen: View {
132134 return h > 0 ? "\(h) h \(m) m" : "\(m) m"
133135 }
134136
135 private func section(_ title: String, @ViewBuilder content: () -> some View) -> some View {
137 private func section(_ title: String, note: String? = nil, @ViewBuilder content: () -> some View) -> some View {
136138 VStack(alignment: .leading, spacing: 10) {
137139 Text(title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
140 if let note { Text(note).font(.footnote).dimmed(0.75) }
138141 content()
139142 }
140143 .padding(.top, 24)
App/Sources/TourViews.swift +5 −3
@@ -61,14 +61,16 @@ struct TourCard: View {
6161 Button("Read more") { onOpen(page) }
6262 Spacer()
6363 }
64 Button { step += 1 } label: { Label("Next", systemImage: "chevron.right").labelStyle(.titleAndIcon) }
65 .disabled(step == tour.steps.count - 1)
64 if step < tour.steps.count - 1 {
65 Button { step += 1 } label: { Label("Next", systemImage: "chevron.right").labelStyle(.titleAndIcon) }
66 }
6667 }
6768 .font(.subheadline.weight(.medium))
6869 .padding(.top, 2)
6970 }
7071 .padding(16)
71 .background(background.opacity(0.92), in: RoundedRectangle(cornerRadius: 16))
72 .background(background, in: RoundedRectangle(cornerRadius: 16))
73 .overlay(RoundedRectangle(cornerRadius: 16).stroke(ink.opacity(0.15), lineWidth: 0.5))
7274 .foregroundStyle(ink)
7375 .padding(.horizontal, 12)
7476 .padding(.bottom, 8)
App/Sources/YearSheet.swift +94 −18
@@ -3,11 +3,15 @@ import SwiftUI
33/// Picks a year for the historical sky, from 3000 BCE to 3000 CE.
44struct YearSheet: View {
55 @Binding var year: Int?
6 var south = false
67 @State private var draft: Int
78 @Environment(\.dismiss) private var dismiss
89
910 static let range = -2999...3000
1011
12 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
13 private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
14
1115 private let moments: [(String, Int)] = [
1216 ("Great Pyramid built, Thuban at the pole", -2559),
1317 ("Ptolemy's Almagest", 150),
@@ -17,34 +21,42 @@ struct YearSheet: View {
1721 ("Polaris closest to the pole", 2100),
1822 ]
1923
20 init(year: Binding<Int?>) {
24 init(year: Binding<Int?>, south: Bool = false) {
2125 _year = year
26 self.south = south
2227 _draft = State(initialValue: year.wrappedValue ?? Calendar.current.component(.year, from: .now))
2328 }
2429
2530 static func label(_ y: Int) -> String { y > 0 ? "\(y) CE" : "\(1 - y) BCE" }
2631
32 private var poleStar: (name: String, degrees: Double)? {
33 SkyModel.poleStar(at: SkyModel.date(.now, inYear: draft), south: south)
34 }
35
2736 var body: some View {
2837 NavigationStack {
29 Form {
30 Section {
31 Stepper(value: $draft, in: Self.range, step: 100) {
32 Text(Self.label(draft)).font(.title2.monospacedDigit())
33 }
34 Stepper("Year by year", value: $draft, in: Self.range)
35 } footer: {
36 Text("Shows the sky for this date and time in another year, with precession and the stars' own motion. Dates before 1582 are in the Julian calendar. Planet positions are approximate far from today.")
37 }
38 Section("Moments") {
39 ForEach(moments, id: \.1) { m in
40 Button { draft = m.1 } label: {
41 LabeledContent(m.0, value: Self.label(m.1))
42 }
43 }
38 ScrollView {
39 VStack(alignment: .leading, spacing: 0) {
40 Text("Sky in another year").font(.title.weight(.medium))
41 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12)
42 Text("Tonight's date and time in any year from 3000 BCE to 3000 CE. The stars drift with precession and their own motion.")
43 .font(.subheadline).foregroundStyle(ink.opacity(0.75))
44
45 yearPicker.padding(.top, 28)
46
47 Text("Moments").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8)
48 .foregroundStyle(ink.opacity(0.6)).padding(.top, 32).padding(.bottom, 4)
49 ForEach(moments, id: \.1) { m in moment(m.0, m.1) }
50
51 Text("Dates before 1582 are in the Julian calendar. Planet positions far from today are approximate.")
52 .font(.caption2).foregroundStyle(ink.opacity(0.5)).padding(.top, 20)
4453 }
54 .padding(20)
55 .readableWidth()
4556 }
46 .navigationTitle("Sky in another year")
47 .navigationBarTitleDisplayMode(.inline)
57 .background(Color.black.ignoresSafeArea())
58 .foregroundStyle(ink)
59 .tint(accent)
4860 .toolbar {
4961 ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
5062 ToolbarItem(placement: .confirmationAction) {
@@ -52,8 +64,72 @@ struct YearSheet: View {
5264 year = draft == Calendar.current.component(.year, from: .now) ? nil : draft
5365 dismiss()
5466 }
67 .fontWeight(.semibold)
5568 }
5669 }
5770 }
5871 }
72
73 private var yearPicker: some View {
74 VStack(spacing: 14) {
75 VStack(spacing: 2) {
76 Text(String(draft > 0 ? draft : 1 - draft))
77 .font(.system(size: 64, weight: .light).monospacedDigit())
78 .contentTransition(.numericText())
79 Text(draft > 0 ? "CE" : "BCE").font(.subheadline.weight(.medium)).tracking(2).foregroundStyle(ink.opacity(0.7))
80 }
81 .frame(maxWidth: .infinity)
82 .accessibilityElement(children: .combine)
83 .accessibilityLabel(Self.label(draft))
84
85 if let p = poleStar {
86 Text(String(format: "Pole star: %@, %.1f° from the pole", p.name, p.degrees))
87 .font(.footnote).foregroundStyle(ink.opacity(0.75))
88 }
89
90 Slider(value: Binding(get: { Double(draft) }, set: { draft = Int($0.rounded()) }), in: Double(Self.range.lowerBound)...Double(Self.range.upperBound))
91 .accessibilityLabel("Year")
92 .accessibilityValue(Self.label(draft))
93 HStack {
94 Text("3000 BCE"); Spacer(); Text("1 CE"); Spacer(); Text("3000 CE")
95 }
96 .font(.caption2).foregroundStyle(ink.opacity(0.5))
97 .accessibilityHidden(true)
98
99 HStack(spacing: 8) {
100 step("−100", -100); step("−1", -1); step("+1", 1); step("+100", 100)
101 }
102 .padding(.top, 4)
103 }
104 .padding(20)
105 .background(ink.opacity(0.06), in: RoundedRectangle(cornerRadius: 20))
106 .animation(.snappy, value: draft)
107 }
108
109 private func step(_ title: String, _ by: Int) -> some View {
110 Button { draft = min(max(draft + by, Self.range.lowerBound), Self.range.upperBound) } label: {
111 Text(title).font(.subheadline.weight(.medium).monospacedDigit())
112 .frame(maxWidth: .infinity, minHeight: 40)
113 .background(ink.opacity(0.08), in: Capsule())
114 }
115 .buttonStyle(.plain)
116 .accessibilityLabel(by > 0 ? "\(by) years later" : "\(-by) years earlier")
117 }
118
119 private func moment(_ title: String, _ y: Int) -> some View {
120 Button { draft = y } label: {
121 HStack(alignment: .firstTextBaseline, spacing: 14) {
122 Text(Self.label(y)).font(.subheadline.weight(.medium).monospacedDigit())
123 .foregroundStyle(draft == y ? accent : ink.opacity(0.7))
124 .frame(width: 84, alignment: .leading)
125 Text(title).font(.subheadline)
126 Spacer(minLength: 0)
127 if draft == y { Image(systemName: "checkmark").font(.footnote.weight(.semibold)).foregroundStyle(accent) }
128 }
129 .padding(.vertical, 12)
130 .overlay(alignment: .bottom) { Rectangle().fill(ink.opacity(0.12)).frame(height: 0.5) }
131 .contentShape(Rectangle())
132 }
133 .buttonStyle(.plain)
134 }
59135}
Packages/MoonriseKit/Sources/SkyRenderer/SkyView.swift +43 −1
@@ -40,6 +40,8 @@ public struct SkyStyle: Sendable {
4040 public var moonLight = Color(red: 0.93, green: 0.91, blue: 0.85)
4141 public var moonDark = Color(red: 0.16, green: 0.20, blue: 0.27)
4242 public var planet = Color(red: 0.95, green: 0.84, blue: 0.60)
43 /// Rings and lines for whatever a tour is pointing at.
44 public var highlight = Color(red: 1.0, green: 0.78, blue: 0.35)
4345 /// Draw stars in a single red, for dark-adapted eyes.
4446 public var monochromeStars: Color?
4547
@@ -58,6 +60,7 @@ public struct SkyStyle: Sendable {
5860 s.label = red
5961 s.muted = red.opacity(0.9)
6062 s.moonLight = red
63 s.highlight = red
6164 s.moonDark = Color(red: 0.12, green: 0, blue: 0)
6265 s.planet = red
6366 s.monochromeStars = red
@@ -72,6 +75,15 @@ public struct SkyStyle: Sendable {
7275 }
7376}
7477
78/// Something to draw attention to, e.g. the current stop of a guided tour.
79public enum SkyHighlight: Sendable, Equatable {
80 /// IAU abbreviation.
81 case constellation(String)
82 /// Bright Star Catalogue HR number.
83 case star(Int)
84 case body(Body)
85}
86
7587public struct SkyView: View {
7688 public var scene: SkyScene
7789 public var centerAzimuth: Double
@@ -80,13 +92,15 @@ public struct SkyView: View {
8092 public var style: SkyStyle
8193 /// Figures to draw instead of the IAU constellation lines.
8294 public var culture: SkyCulture?
95 public var highlight: SkyHighlight?
8396 /// Called after each draw with the time the draw took.
8497 public var onDraw: (@Sendable (Duration) -> Void)?
8598
86 public init(scene: SkyScene, centerAzimuth: Double = 180, centerAltitude: Double = 55, fieldOfView: Double = 110, style: SkyStyle = SkyStyle(), culture: SkyCulture? = nil, onDraw: (@Sendable (Duration) -> Void)? = nil) {
99 public init(scene: SkyScene, centerAzimuth: Double = 180, centerAltitude: Double = 55, fieldOfView: Double = 110, style: SkyStyle = SkyStyle(), culture: SkyCulture? = nil, highlight: SkyHighlight? = nil, onDraw: (@Sendable (Duration) -> Void)? = nil) {
87100 self.scene = scene
88101 self.style = style
89102 self.culture = culture
103 self.highlight = highlight
90104 self.centerAzimuth = centerAzimuth
91105 self.centerAltitude = centerAltitude
92106 self.fieldOfView = fieldOfView
@@ -101,6 +115,7 @@ public struct SkyView: View {
101115 drawConstellations(&context, p)
102116 drawStars(&context, p)
103117 drawBodies(&context, p)
118 if let highlight { drawHighlight(highlight, &context, p) }
104119 drawHorizon(&context, p, size: size)
105120 onDraw?(ContinuousClock.now - start)
106121 }
@@ -151,6 +166,33 @@ public struct SkyView: View {
151166 context.stroke(path, with: .color(style.line), lineWidth: 0.6)
152167 }
153168
169 /// A constellation's IAU lines drawn bright and thick; a star or body ringed.
170 func drawHighlight(_ h: SkyHighlight, _ context: inout GraphicsContext, _ p: Projection) {
171 func ring(_ position: Horizontal?, radius: Double) {
172 guard let position, let pt = p.point(position) else { return }
173 context.stroke(Path(ellipseIn: CGRect(x: pt.x - radius, y: pt.y - radius, width: 2 * radius, height: 2 * radius)),
174 with: .color(style.highlight), lineWidth: 2)
175 }
176 switch h {
177 case .star(let hr):
178 ring(scene.starsByHR[hr], radius: 14)
179 case .body(let body):
180 ring(scene.bodies.first { $0.body == body }?.position, radius: 18)
181 case .constellation(let id):
182 guard let figure = Catalog.constellations.first(where: { $0.id == id }) else { return }
183 var path = Path()
184 for line in figure.lines {
185 for (a, b) in zip(line, line.dropFirst()) {
186 guard let ha = scene.starsByHR[a], let hb = scene.starsByHR[b],
187 let pa = p.point(ha), let pb = p.point(hb) else { continue }
188 path.move(to: pa)
189 path.addLine(to: pb)
190 }
191 }
192 context.stroke(path, with: .color(style.highlight.opacity(0.9)), style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
193 }
194 }
195
154196 func drawStars(_ context: inout GraphicsContext, _ p: Projection) {
155197 for s in scene.stars where s.position.altitude > 0 {
156198 guard let pt = p.point(s.position) else { continue }