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 {
15 var body: some View { 15 var body: some View {
16 VStack(alignment: .leading, spacing: 10) { 16 VStack(alignment: .leading, spacing: 10) {
17 Text("Deep Space Network now").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6) 17 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)
18 if let status { 20 if let status {
19 ForEach(["Goldstone", "Madrid", "Canberra"], id: \.self) { station in 21 ForEach(["Goldstone", "Madrid", "Canberra"], id: \.self) { station in
20 let dishes = activeDishes.filter { $0.station == station } 22 let dishes = activeDishes.filter { $0.station == station }
App/Sources/EventsScreen.swift +61 −37
@@ -24,67 +24,91 @@ struct EventsScreen: View {
24 24
25 var body: some View { 25 var body: some View {
26 NavigationStack { 26 NavigationStack {
27 List { 27 ScrollView {
28 Section { 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))
29 Picker("Show", selection: $filter) { 33 Picker("Show", selection: $filter) {
30 Text("Notable").tag("notable") 34 Text("Highlights").tag("notable")
31 Text("Everything").tag("all") 35 Text("Everything").tag("all")
32 } 36 }
33 .pickerStyle(.segmented) 37 .pickerStyle(.segmented)
34 .listRowBackground(Color.clear) 38 .padding(.vertical, 16)
35 } footer: { 39
36 Text("Visibility is for \(placeName). Meteor shower peaks are typical dates and can shift by a day.") 40 if events.isEmpty {
37 } 41 ProgressView().tint(ink).frame(maxWidth: .infinity).padding(.top, 40)
38 if events.isEmpty { 42 }
39 ProgressView().frame(maxWidth: .infinity).listRowBackground(Color.clear) 43 ForEach(months, id: \.0) { month, items in
40 } 44 Section {
41 ForEach(months, id: \.0) { month, items in 45 ForEach(items) { e in
42 Section(month) { 46 if e.kind == .solarEclipse {
43 ForEach(items) { e in 47 Button { eclipse = e } label: { row(e, link: true) }
44 if e.kind == .solarEclipse { 48 .buttonStyle(.plain)
45 Button { eclipse = e } label: { row(e) } 49 .accessibilityHint("Shows the path and local times")
46 .buttonStyle(.plain) 50 } else {
47 .accessibilityHint("Shows the path and local times") 51 row(e, link: false)
48 } else { 52 }
49 row(e)
50 } 53 }
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)
51 } 60 }
52 } 61 }
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)
53 } 64 }
65 .padding(.horizontal, 20)
66 .readableWidth()
54 } 67 }
55 .listStyle(.insetGrouped)
56 .scrollContentBackground(.hidden)
57 .background(Color.black.ignoresSafeArea()) 68 .background(Color.black.ignoresSafeArea())
58 .navigationTitle("Events")
59 .navigationBarTitleDisplayMode(.inline)
60 .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } } 69 .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { dismiss() } } }
61 .sheet(item: $eclipse) { e in EclipseScreen(date: e.date, observer: observer, placeName: placeName) } 70 .sheet(item: $eclipse) { e in EclipseScreen(date: e.date, observer: observer, placeName: placeName) }
62 } 71 }
63 .foregroundStyle(ink) 72 .foregroundStyle(ink)
73 .tint(accent)
64 .task { 74 .task {
65 let observer = observer 75 let observer = observer
66 events = await Task.detached { SkyEngine.events(from: .now, observer: observer) }.value 76 events = await Task.detached { SkyEngine.events(from: .now, observer: observer) }.value
67 } 77 }
68 } 78 }
69 79
70 private func row(_ e: SkyEvent) -> some View { 80 private func row(_ e: SkyEvent, link: Bool) -> some View {
71 HStack(alignment: .firstTextBaseline, spacing: 12) { 81 HStack(alignment: .top, spacing: 14) {
72 Image(systemName: symbol(e.kind)).frame(width: 22).dimmed(0.8).accessibilityHidden(true) 82 VStack(spacing: 0) {
73 VStack(alignment: .leading, spacing: 3) { 83 Text(e.date.formatted(.dateTime.day())).font(.title2.weight(.medium).monospacedDigit())
74 Text(e.title).font(.body) 84 Text(e.date.formatted(.dateTime.weekday(.abbreviated))).font(.caption2).textCase(.uppercase).foregroundStyle(ink.opacity(0.6))
75 Text(e.date.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute())) 85 }
76 .font(.footnote.monospacedDigit()).dimmed(0.7) 86 .frame(width: 40)
77 if !e.detail.isEmpty { Text(e.detail).font(.footnote).dimmed(0.8) } 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)) }
78 if let v = e.visibleHere { 95 if let v = e.visibleHere {
79 Text(v ? "Visible from here" : "Not visible from here") 96 HStack(spacing: 5) {
80 .font(.caption.weight(.medium)) 97 Circle().fill(v ? accent : ink.opacity(0.35)).frame(width: 6, height: 6)
81 .foregroundStyle(v ? ink : ink.opacity(0.7)) 98 Text(v ? "Visible from here" : "Not visible from here").font(.caption.weight(.medium))
82 .padding(.horizontal, 6).padding(.vertical, 2) 99 .foregroundStyle(v ? ink : ink.opacity(0.6))
83 .overlay(RoundedRectangle(cornerRadius: 3).stroke(v ? accent : ink.opacity(0.4), lineWidth: 1)) 100 }
101 .padding(.top, 2)
84 } 102 }
85 } 103 }
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 }
86 } 108 }
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())
88 .accessibilityElement(children: .combine) 112 .accessibilityElement(children: .combine)
89 } 113 }
90 114
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 {
10 var body: some View { 10 var body: some View {
11 VStack(alignment: .leading, spacing: 10) { 11 VStack(alignment: .leading, spacing: 10) {
12 Text("Mars now").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6) 12 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)
13 VStack(alignment: .leading, spacing: 8) { 15 VStack(alignment: .leading, spacing: 8) {
14 ForEach(MarsTime.Rover.active) { rover in 16 ForEach(MarsTime.Rover.active) { rover in
15 TimelineView(.periodic(from: .now, by: 1)) { ctx in 17 TimelineView(.periodic(from: .now, by: 1)) { ctx in
App/Sources/MoonriseApp.swift +1
@@ -26,6 +26,7 @@ struct MoonriseApp: App {
26 // Red tab highlight while the Sky tab is in night mode. 26 // Red tab highlight while the Sky tab is in night mode.
27 .tint(router.tab == "sky" && nightActive ? SkyStyle.nightRed : nil) 27 .tint(router.tab == "sky" && nightActive ? SkyStyle.nightRed : nil)
28 .preferredColorScheme(.dark) 28 .preferredColorScheme(.dark)
29 .environment(\.openURL, InAppBrowser.action)
29 .fullScreenCover(isPresented: .constant(!onboarded)) { 30 .fullScreenCover(isPresented: .constant(!onboarded)) {
30 OnboardingScreen().preferredColorScheme(.dark) 31 OnboardingScreen().preferredColorScheme(.dark)
31 } 32 }
App/Sources/OnboardingScreen.swift +173 −15
@@ -1,30 +1,137 @@
1import SkyRenderer
1import SwiftUI 2import SwiftUI
3import UserNotifications
2 4
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.
4struct OnboardingScreen: View { 6struct OnboardingScreen: View {
5 @AppStorage(SettingsKey.onboarded) private var onboarded = false 7 @AppStorage(SettingsKey.onboarded) private var onboarded = false
6 @AppStorage(SettingsKey.locationMode) private var locationMode = "current" 8 @AppStorage(SettingsKey.locationMode) private var locationMode = "current"
7 @AppStorage(SettingsKey.cityName) private var cityName = "Chicago" 9 @AppStorage(SettingsKey.cityName) private var cityName = "Chicago"
8 @AppStorage(SettingsKey.cityLatitude) private var cityLatitude = 41.878 10 @AppStorage(SettingsKey.cityLatitude) private var cityLatitude = 41.878
9 @AppStorage(SettingsKey.cityLongitude) private var cityLongitude = -87.630 11 @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
10 @State private var choosingCity = false 15 @State private var choosingCity = false
16 @State private var notificationsDenied = false
11 17
12 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) 18 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) 19 private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
14 private let background = Color.black 20 private let pages = 5
15 21
16 var body: some View { 22 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 {
17 VStack(alignment: .leading, spacing: 0) { 68 VStack(alignment: .leading, spacing: 0) {
18 Spacer() 69 Spacer()
70 MoonDisc(fraction: 0.55, waxing: true)
71 .frame(width: 96, height: 96)
72 .padding(.bottom, 28)
73 .accessibilityHidden(true)
19 Text("Moonrise").font(.largeTitle.weight(.medium)) 74 Text("Moonrise").font(.largeTitle.weight(.medium))
20 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 10).padding(.bottom, 24) 75 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.vertical, 14)
21 VStack(alignment: .leading, spacing: 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.")
22 Text("The sky above you tonight, the Sun today, launches, the solar system, and a library of everything up there.") 77 .font(.title3)
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)
27 Spacer() 78 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)
28 if choosingCity { 135 if choosingCity {
29 Picker("City", selection: $cityName) { 136 Picker("City", selection: $cityName) {
30 ForEach(City.all) { Text($0.name).tag($0.name) } 137 ForEach(City.all) { Text($0.name).tag($0.name) }
@@ -39,6 +146,7 @@ struct OnboardingScreen: View {
39 locationMode = "city" 146 locationMode = "city"
40 onboarded = true 147 onboarded = true
41 } 148 }
149 button("Back", filled: false) { choosingCity = false }
42 } else { 150 } else {
43 button("Use my location", filled: true) { 151 button("Use my location", filled: true) {
44 locationMode = "current" 152 locationMode = "current"
@@ -48,11 +156,61 @@ struct OnboardingScreen: View {
48 } 156 }
49 } 157 }
50 .padding(.horizontal, 24) 158 .padding(.horizontal, 24)
51 .padding(.bottom, 24) 159 .padding(.top, 24)
52 .frame(maxWidth: 560) 160 .frame(maxWidth: 560, alignment: .leading)
53 .frame(maxWidth: .infinity) 161 .frame(maxWidth: .infinity)
54 .foregroundStyle(ink) 162 }
55 .background(background.ignoresSafeArea()) 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
56 } 214 }
57 215
58 private func button(_ title: String, filled: Bool, action: @escaping () -> Void) -> some View { 216 private func button(_ title: String, filled: Bool, action: @escaping () -> Void) -> some View {
@@ -62,8 +220,8 @@ struct OnboardingScreen: View {
62 .frame(maxWidth: .infinity) 220 .frame(maxWidth: .infinity)
63 .padding(.vertical, 14) 221 .padding(.vertical, 14)
64 .foregroundStyle(filled ? Color.black : ink) 222 .foregroundStyle(filled ? Color.black : ink)
65 .background(filled ? ink : .clear, in: .rect(cornerRadius: 8)) 223 .background(filled ? ink : .clear, in: .rect(cornerRadius: 12))
66 .overlay(RoundedRectangle(cornerRadius: 8).stroke(ink.opacity(filled ? 0 : 0.5), lineWidth: 0.5)) 224 .overlay(RoundedRectangle(cornerRadius: 12).stroke(ink.opacity(filled ? 0 : 0.5), lineWidth: 0.5))
67 } 225 }
68 .buttonStyle(.plain) 226 .buttonStyle(.plain)
69 .padding(.top, 10) 227 .padding(.top, 10)
App/Sources/SettingsScreen.swift +6
@@ -65,6 +65,7 @@ struct SettingsScreen: View {
65 @AppStorage(SettingsKey.cityLongitude) private var cityLongitude = -87.630 65 @AppStorage(SettingsKey.cityLongitude) private var cityLongitude = -87.630
66 @AppStorage(SettingsKey.nightMode) private var nightMode = "auto" 66 @AppStorage(SettingsKey.nightMode) private var nightMode = "auto"
67 @AppStorage(SettingsKey.skyCulture) private var skyCulture = "western" 67 @AppStorage(SettingsKey.skyCulture) private var skyCulture = "western"
68 @AppStorage(SettingsKey.onboarded) private var onboarded = true
68 @Environment(\.dismiss) private var dismiss 69 @Environment(\.dismiss) private var dismiss
69 @State private var alerts = AuroraAlerts.shared 70 @State private var alerts = AuroraAlerts.shared
70 @AppStorage(SettingsKey.dailySummary) private var dailySummary = false 71 @AppStorage(SettingsKey.dailySummary) private var dailySummary = false
@@ -137,6 +138,11 @@ struct SettingsScreen: View {
137 } 138 }
138 Section("About") { 139 Section("About") {
139 LabeledContent("Version", value: Bundle.main.versionString) 140 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 }
140 NavigationLink("Credits and licences") { CreditsScreen() } 146 NavigationLink("Credits and licences") { CreditsScreen() }
141 Link("Privacy policy", destination: privacyPolicyURL) 147 Link("Privacy policy", destination: privacyPolicyURL)
142 } 148 }
App/Sources/SkyScreen.swift +66 −50
@@ -23,6 +23,7 @@ struct SkyScreen: View {
23 @State private var showTours = false 23 @State private var showTours = false
24 @State private var tour: Tour? 24 @State private var tour: Tour?
25 @State private var tourStep = 0 25 @State private var tourStep = 0
26 @State private var turning: Task<Void, Never>?
26 @Environment(\.scenePhase) private var scenePhase 27 @Environment(\.scenePhase) private var scenePhase
27 @State private var libraryPage: LibraryPage? 28 @State private var libraryPage: LibraryPage?
28 @State private var selection: SkyObject? 29 @State private var selection: SkyObject?
@@ -41,7 +42,7 @@ struct SkyScreen: View {
41 style.background.ignoresSafeArea() 42 style.background.ignoresSafeArea()
42 if let scene = model.scene { 43 if let scene = model.scene {
43 let view = current(width: geo.size.width) 44 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
45 stats.record(d) 46 stats.record(d)
46 } 47 }
47 .ignoresSafeArea() 48 .ignoresSafeArea()
@@ -59,6 +60,16 @@ struct SkyScreen: View {
59 } 60 }
60 VStack(spacing: 0) { 61 VStack(spacing: 0) {
61 header 62 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 }
62 if let selection { card(for: selection).padding(.horizontal, 16).padding(.top, 8) } 73 if let selection { card(for: selection).padding(.horizontal, 16).padding(.top, 8) }
63 Spacer() 74 Spacer()
64 if let tour { 75 if let tour {
@@ -81,14 +92,13 @@ struct SkyScreen: View {
81 .sheet(isPresented: $showEvents) { 92 .sheet(isPresented: $showEvents) {
82 EventsScreen(observer: model.observer, placeName: model.placeName) 93 EventsScreen(observer: model.observer, placeName: model.placeName)
83 .preferredColorScheme(.dark) 94 .preferredColorScheme(.dark)
84 .tint(.blue)
85 } 95 }
86 .sheet(isPresented: $showTours) { 96 .sheet(isPresented: $showTours) {
87 TourPicker { tour = $0; tourStep = 0; aim(at: $0.steps[0].target) }.preferredColorScheme(.dark).tint(.blue) 97 TourPicker { tour = $0; tourStep = 0; aim(at: $0.steps[0].target) }.preferredColorScheme(.dark).tint(.blue)
88 } 98 }
89 .onChange(of: tourStep) { if let tour { aim(at: tour.steps[tourStep].target) } } 99 .onChange(of: tourStep) { if let tour { aim(at: tour.steps[tourStep].target) } }
90 .sheet(isPresented: $showYear) { 100 .sheet(isPresented: $showYear) {
91 YearSheet(year: $model.historicalYear).preferredColorScheme(.dark).tint(.blue) 101 YearSheet(year: $model.historicalYear, south: model.observer.latitude < 0).preferredColorScheme(.dark)
92 } 102 }
93 .sheet(isPresented: $showSettings, onDismiss: rescheduleSummary) { 103 .sheet(isPresented: $showSettings, onDismiss: rescheduleSummary) {
94 SettingsScreen { model.applyLocationSettings() } 104 SettingsScreen { model.applyLocationSettings() }
@@ -135,14 +145,33 @@ struct SkyScreen: View {
135 return Horizontal(altitude: asin(v.z / (v * v).sum().squareRoot()) * 180 / .pi, azimuth: az) 145 return Horizontal(altitude: asin(v.z / (v * v).sum().squareRoot()) * 180 / .pi, azimuth: az)
136 } 146 }
137 147
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.
139 private func aim(at t: Tour.Target) { 157 private func aim(at t: Tour.Target) {
140 guard let h = tourTarget(t) else { return } 158 guard let h = tourTarget(t) else { return }
141 pointer.stop() 159 pointer.stop()
142 withAnimation(.easeInOut(duration: 0.6)) { 160 turning?.cancel()
143 azimuth = h.azimuth 161 let from = (az: azimuth, alt: altitude, fov: fieldOfView)
144 altitude = max(h.altitude, 10) 162 let dAz = (h.azimuth - from.az + 540).truncatingRemainder(dividingBy: 360) - 180
145 fieldOfView = 80 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 }
146 } 175 }
147 } 176 }
148 177
@@ -170,6 +199,7 @@ struct SkyScreen: View {
170 private func panGesture(width: CGFloat) -> some Gesture { 199 private func panGesture(width: CGFloat) -> some Gesture {
171 DragGesture(minimumDistance: 6) 200 DragGesture(minimumDistance: 6)
172 .onChanged { _ in 201 .onChanged { _ in
202 turning?.cancel()
173 if pointer.active { 203 if pointer.active {
174 azimuth = pointer.direction.azimuth 204 azimuth = pointer.direction.azimuth
175 altitude = min(max(pointer.direction.altitude, 0), 90) 205 altitude = min(max(pointer.direction.altitude, 0), 90)
@@ -213,51 +243,34 @@ struct SkyScreen: View {
213 #endif 243 #endif
214 } 244 }
215 Spacer() 245 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 }
226 Menu { 246 Menu {
227 Button { showTours = true } label: { Label("Guided tours", systemImage: "figure.walk") } 247 Section("Tonight") {
228 Button { showYear = true } label: { Label("Sky in another year", systemImage: "hourglass") } 248 Button { showTours = true } label: { Label("Guided tours", systemImage: "figure.walk") }
229 } label: { 249 Button { showEvents = true } label: { Label("Events and eclipses", systemImage: "calendar") }
230 Image(systemName: "ellipsis.circle") 250 if pointer.available {
231 .font(.body) 251 Button { pointer.active ? pointer.stop() : pointer.start() } label: {
232 .frame(width: 44, height: 44) 252 Label(pointer.active ? "Stop following the phone" : "Point the phone at the sky", systemImage: "iphone.gen3.radiowaves.left.and.right")
233 } 253 }
234 .accessibilityLabel("More") 254 }
235 Button { showLog = true } label: { 255 }
236 Image(systemName: "book.closed") 256 Section("Explore") {
237 .font(.body) 257 Button { showYear = true } label: { Label("Sky in another year", systemImage: "hourglass") }
238 .frame(width: 44, height: 44) 258 Button { showLog = true } label: { Label("Observing log", systemImage: "book.closed") }
239 } 259 }
240 .accessibilityLabel("Observing log") 260 Section {
241 Button { showEvents = true } label: { 261 Button { nightMode = night ? "off" : "on" } label: {
242 Image(systemName: "calendar") 262 Label(night ? "Turn off night mode" : "Night mode (red light)", systemImage: night ? "eye.slash" : "eye")
243 .font(.body) 263 }
244 .frame(width: 44, height: 44) 264 Button { showSettings = true } label: { Label("Settings", systemImage: "gearshape") }
245 } 265 }
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"
255 } label: { 266 } label: {
256 Image(systemName: night ? "eye.fill" : "eye") 267 Label("Explore", systemImage: "sparkle.magnifyingglass")
257 .font(.body) 268 .font(.subheadline.weight(.medium))
258 .frame(width: 44, height: 44) 269 .padding(.horizontal, 14)
270 .frame(minHeight: 44)
271 .glassEffect(in: .capsule)
259 } 272 }
260 .accessibilityLabel(night ? "Turn off night mode" : "Turn on night mode") 273 .accessibilityHint("Tours, events, night mode, settings and more")
261 } 274 }
262 .padding(.horizontal, 20) 275 .padding(.horizontal, 20)
263 .padding(.top, 8) 276 .padding(.top, 8)
@@ -378,3 +391,6 @@ final class DrawStats: @unchecked Sendable {
378 return String(format: "draw %.1f ms median, %.1f ms max · scene %.0f ms", median, worst, b) 391 return String(format: "draw %.1f ms median, %.1f ms max · scene %.0f ms", median, worst, b)
379 } 392 }
380} 393}
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 {
20 ScrollView { 20 ScrollView {
21 VStack(alignment: .leading, spacing: 0) { 21 VStack(alignment: .leading, spacing: 0) {
22 Text("Sun").font(.title.weight(.medium)) 22 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)
24 26
25 image 27 image
26 Picker("Wavelength", selection: $wavelength) { 28 Picker("Wavelength", selection: $wavelength) {
@@ -31,9 +33,9 @@ struct SunScreen: View {
31 Text(wavelength.caption).font(.footnote).dimmed(0.75).padding(.top, 8) 33 Text(wavelength.caption).font(.footnote).dimmed(0.75).padding(.top, 8)
32 Text(SolarImage.credit).font(.caption2).dimmed(0.5).padding(.top, 4) 34 Text(SolarImage.credit).font(.caption2).dimmed(0.5).padding(.top, 4)
33 35
34 section("Aurora") { auroraMap } 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 }
35 section("Solar flares, last 24 h") { flares } 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 }
36 section("Geomagnetic activity (Kp)") { kpChart } 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 }
37 39
38 if failed { 40 if failed {
39 Text("Couldn't load space weather. Pull to retry.").font(.footnote).foregroundStyle(accent).padding(.top, 12) 41 Text("Couldn't load space weather. Pull to retry.").font(.footnote).foregroundStyle(accent).padding(.top, 12)
@@ -74,9 +76,10 @@ struct SunScreen: View {
74 .accessibilityLabel("The Sun at \(wavelength.name)") 76 .accessibilityLabel("The Sun at \(wavelength.name)")
75 } 77 }
76 78
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 {
78 VStack(alignment: .leading, spacing: 10) { 80 VStack(alignment: .leading, spacing: 10) {
79 Text(title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6) 81 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) }
80 content() 83 content()
81 } 84 }
82 .padding(.top, 24) 85 .padding(.top, 24)
App/Sources/SystemScreen.swift +6 −3
@@ -24,7 +24,9 @@ struct SystemScreen: View {
24 ScrollView { 24 ScrollView {
25 VStack(alignment: .leading, spacing: 0) { 25 VStack(alignment: .leading, spacing: 0) {
26 Text("Solar system").font(.title.weight(.medium)) 26 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)
28 30
29 LiveSection(ink: ink, accent: accent) 31 LiveSection(ink: ink, accent: accent)
30 32
@@ -54,7 +56,7 @@ struct SystemScreen: View {
54 Text("Distances beyond 1 AU are drawn on a logarithmic scale. Rings: planet orbits, then 50, 100 and 200 AU.") 56 Text("Distances beyond 1 AU are drawn on a logarithmic scale. Rings: planet orbits, then 50, 100 and 200 AU.")
55 .font(.caption2).dimmed(0.6).padding(.top, 8) 57 .font(.caption2).dimmed(0.6).padding(.top, 8)
56 58
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.") {
58 if typeSize.isAccessibilitySize { 60 if typeSize.isAccessibilitySize {
59 VStack(alignment: .leading, spacing: 12) { 61 VStack(alignment: .leading, spacing: 12) {
60 ForEach(Spacecraft.all) { craft in 62 ForEach(Spacecraft.all) { craft in
@@ -132,9 +134,10 @@ struct SystemScreen: View {
132 return h > 0 ? "\(h) h \(m) m" : "\(m) m" 134 return h > 0 ? "\(h) h \(m) m" : "\(m) m"
133 } 135 }
134 136
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 {
136 VStack(alignment: .leading, spacing: 10) { 138 VStack(alignment: .leading, spacing: 10) {
137 Text(title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6) 139 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) }
138 content() 141 content()
139 } 142 }
140 .padding(.top, 24) 143 .padding(.top, 24)
App/Sources/TourViews.swift +5 −3
@@ -61,14 +61,16 @@ struct TourCard: View {
61 Button("Read more") { onOpen(page) } 61 Button("Read more") { onOpen(page) }
62 Spacer() 62 Spacer()
63 } 63 }
64 Button { step += 1 } label: { Label("Next", systemImage: "chevron.right").labelStyle(.titleAndIcon) } 64 if step < tour.steps.count - 1 {
65 .disabled(step == tour.steps.count - 1) 65 Button { step += 1 } label: { Label("Next", systemImage: "chevron.right").labelStyle(.titleAndIcon) }
66 }
66 } 67 }
67 .font(.subheadline.weight(.medium)) 68 .font(.subheadline.weight(.medium))
68 .padding(.top, 2) 69 .padding(.top, 2)
69 } 70 }
70 .padding(16) 71 .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))
72 .foregroundStyle(ink) 74 .foregroundStyle(ink)
73 .padding(.horizontal, 12) 75 .padding(.horizontal, 12)
74 .padding(.bottom, 8) 76 .padding(.bottom, 8)
App/Sources/YearSheet.swift +94 −18
@@ -3,11 +3,15 @@ import SwiftUI
3/// Picks a year for the historical sky, from 3000 BCE to 3000 CE. 3/// Picks a year for the historical sky, from 3000 BCE to 3000 CE.
4struct YearSheet: View { 4struct YearSheet: View {
5 @Binding var year: Int? 5 @Binding var year: Int?
6 var south = false
6 @State private var draft: Int 7 @State private var draft: Int
7 @Environment(\.dismiss) private var dismiss 8 @Environment(\.dismiss) private var dismiss
8 9
9 static let range = -2999...3000 10 static let range = -2999...3000
10 11
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
11 private let moments: [(String, Int)] = [ 15 private let moments: [(String, Int)] = [
12 ("Great Pyramid built, Thuban at the pole", -2559), 16 ("Great Pyramid built, Thuban at the pole", -2559),
13 ("Ptolemy's Almagest", 150), 17 ("Ptolemy's Almagest", 150),
@@ -17,34 +21,42 @@ struct YearSheet: View {
17 ("Polaris closest to the pole", 2100), 21 ("Polaris closest to the pole", 2100),
18 ] 22 ]
19 23
20 init(year: Binding<Int?>) { 24 init(year: Binding<Int?>, south: Bool = false) {
21 _year = year 25 _year = year
26 self.south = south
22 _draft = State(initialValue: year.wrappedValue ?? Calendar.current.component(.year, from: .now)) 27 _draft = State(initialValue: year.wrappedValue ?? Calendar.current.component(.year, from: .now))
23 } 28 }
24 29
25 static func label(_ y: Int) -> String { y > 0 ? "\(y) CE" : "\(1 - y) BCE" } 30 static func label(_ y: Int) -> String { y > 0 ? "\(y) CE" : "\(1 - y) BCE" }
26 31
32 private var poleStar: (name: String, degrees: Double)? {
33 SkyModel.poleStar(at: SkyModel.date(.now, inYear: draft), south: south)
34 }
35
27 var body: some View { 36 var body: some View {
28 NavigationStack { 37 NavigationStack {
29 Form { 38 ScrollView {
30 Section { 39 VStack(alignment: .leading, spacing: 0) {
31 Stepper(value: $draft, in: Self.range, step: 100) { 40 Text("Sky in another year").font(.title.weight(.medium))
32 Text(Self.label(draft)).font(.title2.monospacedDigit()) 41 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12)
33 } 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.")
34 Stepper("Year by year", value: $draft, in: Self.range) 43 .font(.subheadline).foregroundStyle(ink.opacity(0.75))
35 } footer: { 44
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.") 45 yearPicker.padding(.top, 28)
37 } 46
38 Section("Moments") { 47 Text("Moments").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8)
39 ForEach(moments, id: \.1) { m in 48 .foregroundStyle(ink.opacity(0.6)).padding(.top, 32).padding(.bottom, 4)
40 Button { draft = m.1 } label: { 49 ForEach(moments, id: \.1) { m in moment(m.0, m.1) }
41 LabeledContent(m.0, value: Self.label(m.1)) 50
42 } 51 Text("Dates before 1582 are in the Julian calendar. Planet positions far from today are approximate.")
43 } 52 .font(.caption2).foregroundStyle(ink.opacity(0.5)).padding(.top, 20)
44 } 53 }
54 .padding(20)
55 .readableWidth()
45 } 56 }
46 .navigationTitle("Sky in another year") 57 .background(Color.black.ignoresSafeArea())
47 .navigationBarTitleDisplayMode(.inline) 58 .foregroundStyle(ink)
59 .tint(accent)
48 .toolbar { 60 .toolbar {
49 ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } 61 ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
50 ToolbarItem(placement: .confirmationAction) { 62 ToolbarItem(placement: .confirmationAction) {
@@ -52,8 +64,72 @@ struct YearSheet: View {
52 year = draft == Calendar.current.component(.year, from: .now) ? nil : draft 64 year = draft == Calendar.current.component(.year, from: .now) ? nil : draft
53 dismiss() 65 dismiss()
54 } 66 }
67 .fontWeight(.semibold)
55 } 68 }
56 } 69 }
57 } 70 }
58 } 71 }
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 }
59} 135}
Packages/MoonriseKit/Sources/SkyRenderer/SkyView.swift +43 −1
@@ -40,6 +40,8 @@ public struct SkyStyle: Sendable {
40 public var moonLight = Color(red: 0.93, green: 0.91, blue: 0.85) 40 public var moonLight = Color(red: 0.93, green: 0.91, blue: 0.85)
41 public var moonDark = Color(red: 0.16, green: 0.20, blue: 0.27) 41 public var moonDark = Color(red: 0.16, green: 0.20, blue: 0.27)
42 public var planet = Color(red: 0.95, green: 0.84, blue: 0.60) 42 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)
43 /// Draw stars in a single red, for dark-adapted eyes. 45 /// Draw stars in a single red, for dark-adapted eyes.
44 public var monochromeStars: Color? 46 public var monochromeStars: Color?
45 47
@@ -58,6 +60,7 @@ public struct SkyStyle: Sendable {
58 s.label = red 60 s.label = red
59 s.muted = red.opacity(0.9) 61 s.muted = red.opacity(0.9)
60 s.moonLight = red 62 s.moonLight = red
63 s.highlight = red
61 s.moonDark = Color(red: 0.12, green: 0, blue: 0) 64 s.moonDark = Color(red: 0.12, green: 0, blue: 0)
62 s.planet = red 65 s.planet = red
63 s.monochromeStars = red 66 s.monochromeStars = red
@@ -72,6 +75,15 @@ public struct SkyStyle: Sendable {
72 } 75 }
73} 76}
74 77
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
75public struct SkyView: View { 87public struct SkyView: View {
76 public var scene: SkyScene 88 public var scene: SkyScene
77 public var centerAzimuth: Double 89 public var centerAzimuth: Double
@@ -80,13 +92,15 @@ public struct SkyView: View {
80 public var style: SkyStyle 92 public var style: SkyStyle
81 /// Figures to draw instead of the IAU constellation lines. 93 /// Figures to draw instead of the IAU constellation lines.
82 public var culture: SkyCulture? 94 public var culture: SkyCulture?
95 public var highlight: SkyHighlight?
83 /// Called after each draw with the time the draw took. 96 /// Called after each draw with the time the draw took.
84 public var onDraw: (@Sendable (Duration) -> Void)? 97 public var onDraw: (@Sendable (Duration) -> Void)?
85 98
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) {
87 self.scene = scene 100 self.scene = scene
88 self.style = style 101 self.style = style
89 self.culture = culture 102 self.culture = culture
103 self.highlight = highlight
90 self.centerAzimuth = centerAzimuth 104 self.centerAzimuth = centerAzimuth
91 self.centerAltitude = centerAltitude 105 self.centerAltitude = centerAltitude
92 self.fieldOfView = fieldOfView 106 self.fieldOfView = fieldOfView
@@ -101,6 +115,7 @@ public struct SkyView: View {
101 drawConstellations(&context, p) 115 drawConstellations(&context, p)
102 drawStars(&context, p) 116 drawStars(&context, p)
103 drawBodies(&context, p) 117 drawBodies(&context, p)
118 if let highlight { drawHighlight(highlight, &context, p) }
104 drawHorizon(&context, p, size: size) 119 drawHorizon(&context, p, size: size)
105 onDraw?(ContinuousClock.now - start) 120 onDraw?(ContinuousClock.now - start)
106 } 121 }
@@ -151,6 +166,33 @@ public struct SkyView: View {
151 context.stroke(path, with: .color(style.line), lineWidth: 0.6) 166 context.stroke(path, with: .color(style.line), lineWidth: 0.6)
152 } 167 }
153 168
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
154 func drawStars(_ context: inout GraphicsContext, _ p: Projection) { 196 func drawStars(_ context: inout GraphicsContext, _ p: Projection) {
155 for s in scene.stars where s.position.altitude > 0 { 197 for s in scene.stars where s.position.altitude > 0 {
156 guard let pt = p.point(s.position) else { continue } 198 guard let pt = p.point(s.position) else { continue }