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

Commit 187427ef9f

187427ef9f0310c84aea7f8564a3da9c9e35f838

parent: 3b8c40e96a

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-19 21:38 UTC

Siri, Shortcuts and Spotlight, phase 1

- MoonriseKit `Answers` target: moon, tonight, satellite pass, next launch, aurora/Kp, probe distance and Library look-up answers, each with a spoken sentence and up to four result rows; `Place`, the saved observer. Tests in AnswersTests.
- `Geomagnetic.ovalEdge`, `visibleFrom` (ported from server/src/aurora.ts) and `kpNeeded`.
- Nine App Shortcuts with "Moonrise" phrases; answer intents run in the background and return a dialog and a result view in the Today sheet's style.
- `LibraryPageEntity` (IndexedEntity) with all 370 pages indexed in Spotlight once per build, and an OpenIntent for Spotlight taps.
- Show in Sky and Search the Library open the app via `Router`; the Sky tab turns to and highlights the object.
- SkyModel saves the observer (rounded to 0.1°) on every fix or city change for intents to read.

Layout: unified · split

App/Sources/Intents/AnswerIntents.swift added +172
@@ -0,0 +1,172 @@
1import Answers
2import AppIntents
3import Feeds
4import Foundation
5import Library
6import SkyEngine
7
8extension Place {
9 /// The Sky tab's last observer, else the city chosen in Settings, else Chicago.
10 static var current: Place {
11 let d = UserDefaults.standard
12 if let saved = Place.saved(in: d) { return saved }
13 if d.string(forKey: SettingsKey.locationMode) == "city", let name = d.string(forKey: SettingsKey.cityName), d.object(forKey: SettingsKey.cityLatitude) != nil {
14 return Place(latitude: d.double(forKey: SettingsKey.cityLatitude), longitude: d.double(forKey: SettingsKey.cityLongitude), name: name, kind: .city)
15 }
16 return .fallback
17 }
18}
19
20enum AnswerError: Error, CustomLocalizedStringResourceConvertible {
21 case offline
22
23 var localizedStringResource: LocalizedStringResource {
24 "Moonrise couldn't reach the network. Try again when you're online."
25 }
26}
27
28/// The whole answer for AirPods and HomePod; its first sentence alongside the result view.
29private func dialog(_ spoken: String) -> IntentDialog {
30 let first = spoken.components(separatedBy: ". ").first.map { $0.hasSuffix(".") ? $0 : $0 + "." } ?? spoken
31 return IntentDialog(full: "\(spoken)", supporting: "\(first)")
32}
33
34struct MoonPhaseIntent: AppIntent {
35 static let title: LocalizedStringResource = "Moon Phase"
36 static let description = IntentDescription("The Moon's phase, how much of it is lit, and the next quarter.")
37 static let supportedModes: IntentModes = .background
38
39 func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView {
40 let a = try MoonAnswer(at: .now)
41 return .result(dialog: dialog(a.spoken), view: AnswerSnippet(heading: "Moon", rows: a.rows, moon: (a.illumination, a.waxing)))
42 }
43}
44
45struct TonightIntent: AppIntent {
46 static let title: LocalizedStringResource = "Tonight's Sky"
47 static let description = IntentDescription("The bright planets well placed tonight, with sunset and sunrise.")
48 static let supportedModes: IntentModes = .background
49
50 func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView {
51 let a = try TonightAnswer(at: .now, place: .current)
52 return .result(dialog: dialog(a.spoken), view: AnswerSnippet(heading: a.heading, rows: a.rows))
53 }
54}
55
56enum SatelliteOption: String, AppEnum {
57 case iss, tiangong, hubble
58
59 static let typeDisplayRepresentation: TypeDisplayRepresentation = "Satellite"
60 static let caseDisplayRepresentations: [SatelliteOption: DisplayRepresentation] = [
61 .iss: DisplayRepresentation(title: "ISS", synonyms: ["the ISS", "the space station", "the International Space Station"]),
62 .tiangong: DisplayRepresentation(title: "Tiangong", synonyms: ["the Chinese space station"]),
63 .hubble: DisplayRepresentation(title: "Hubble", synonyms: ["the Hubble Space Telescope", "Hubble telescope"]),
64 ]
65
66 var satellite: Satellite {
67 switch self {
68 case .iss: .iss
69 case .tiangong: .tiangong
70 case .hubble: .hubble
71 }
72 }
73}
74
75struct NextPassIntent: AppIntent {
76 static let title: LocalizedStringResource = "Next Satellite Pass"
77 static let description = IntentDescription("When the ISS, Tiangong or Hubble next crosses your sky, preferring a pass you can see.")
78 static let supportedModes: IntentModes = .background
79
80 @Parameter(title: "Satellite", default: .iss)
81 var satellite: SatelliteOption
82
83 private struct SatelliteList: Decodable { var satellites: [OrbitalElements] }
84
85 func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView {
86 let s = satellite.satellite
87 guard let apiBase,
88 let (data, _) = try? await URLSession.shared.data(from: apiBase.appending(path: "v1/satellites")),
89 let list = try? JSONDecoder().decode(SatelliteList.self, from: data) else { throw AnswerError.offline }
90 let place = Place.current
91 let passes = list.satellites.first { $0.NORAD_CAT_ID == s.rawValue }
92 .map { SkyEngine.passes(of: $0, from: .now, hours: 72, observer: place.observer) } ?? []
93 let a = PassAnswer(satellite: s, passes: passes, at: .now, place: place)
94 return .result(dialog: dialog(a.spoken), view: AnswerSnippet(heading: "\(s.name) · \(place.label)", rows: a.rows))
95 }
96}
97
98struct NextLaunchIntent: AppIntent {
99 static let title: LocalizedStringResource = "Next Launch"
100 static let description = IntentDescription("The next rocket launch: rocket, mission, launch site and countdown.")
101 static let supportedModes: IntentModes = .background
102
103 func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView {
104 guard let apiBase, let list = try? await LaunchFeed.fetch(base: apiBase) else { throw AnswerError.offline }
105 let a = LaunchAnswer(launches: list.launches, at: .now)
106 return .result(dialog: dialog(a.spoken), view: AnswerSnippet(heading: "Next launch", rows: a.rows))
107 }
108}
109
110struct SpaceWeatherIntent: AppIntent {
111 static let title: LocalizedStringResource = "Aurora and Kp"
112 static let description = IntentDescription("The planetary Kp index now and over the next day, and whether aurora may be seen from your location.")
113 static let supportedModes: IntentModes = .background
114
115 func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView {
116 guard let weather = try? await SpaceWeather.fetch() else { throw AnswerError.offline }
117 let place = Place.current
118 let a = AuroraAnswer(weather: weather, place: place)
119 return .result(dialog: dialog(a.spoken), view: AnswerSnippet(heading: "Space weather · \(place.label)", rows: a.rows, footnote: a.footnote))
120 }
121}
122
123enum ProbeOption: String, AppEnum {
124 case voyager1, voyager2, pioneer10, pioneer11, newHorizons
125
126 static let typeDisplayRepresentation: TypeDisplayRepresentation = "Probe"
127 static let caseDisplayRepresentations: [ProbeOption: DisplayRepresentation] = [
128 .voyager1: DisplayRepresentation(title: "Voyager 1", synonyms: ["Voyager", "Voyager one"]),
129 .voyager2: DisplayRepresentation(title: "Voyager 2", synonyms: ["Voyager two"]),
130 .pioneer10: DisplayRepresentation(title: "Pioneer 10", synonyms: ["Pioneer ten", "Pioneer"]),
131 .pioneer11: DisplayRepresentation(title: "Pioneer 11", synonyms: ["Pioneer eleven"]),
132 .newHorizons: DisplayRepresentation(title: "New Horizons"),
133 ]
134
135 var probe: Probe {
136 switch self {
137 case .voyager1: .voyager1
138 case .voyager2: .voyager2
139 case .pioneer10: .pioneer10
140 case .pioneer11: .pioneer11
141 case .newHorizons: .newHorizons
142 }
143 }
144}
145
146struct ProbeDistanceIntent: AppIntent {
147 static let title: LocalizedStringResource = "Probe Distance"
148 static let description = IntentDescription("How far a deep-space probe is from Earth, and how long its signal takes to reach us.")
149 static let supportedModes: IntentModes = .background
150
151 @Parameter(title: "Probe", default: .voyager1)
152 var probe: ProbeOption
153
154 func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView {
155 let a = try ProbeAnswer(probe: probe.probe, at: .now)
156 return .result(dialog: dialog(a.spoken), view: AnswerSnippet(heading: a.probe.rawValue, rows: a.rows))
157 }
158}
159
160struct LookUpIntent: AppIntent {
161 static let title: LocalizedStringResource = "Look Up"
162 static let description = IntentDescription("A Library page's description and key facts.")
163 static let supportedModes: IntentModes = .background
164
165 @Parameter(title: "Object", requestValueDialog: "Which object?")
166 var page: LibraryPageEntity
167
168 func perform() async throws -> some IntentResult & ProvidesDialog & ShowsSnippetView {
169 let a = LookUpAnswer(page: page.page)
170 return .result(dialog: dialog(a.spoken), view: AnswerSnippet(heading: page.page.title, lead: a.subtitle, rows: a.rows))
171 }
172}
App/Sources/Intents/AnswerSnippet.swift added +44
@@ -0,0 +1,44 @@
1import Answers
2import SkyRenderer
3import SwiftUI
4
5/// Result view for Siri and Shortcuts, in the Today sheet's style: section label, hairline, label/value grid.
6struct AnswerSnippet: View {
7 var heading: String
8 var lead: String?
9 var rows: [AnswerRow]
10 var footnote: String?
11 var moon: (fraction: Double, waxing: Bool)?
12
13 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
14 private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
15
16 var body: some View {
17 VStack(alignment: .leading, spacing: 8) {
18 Text(heading).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
19 if let lead { Text(lead).font(.subheadline) }
20 Rectangle().fill(ink.opacity(0.25)).frame(height: 0.5)
21 HStack(alignment: .center, spacing: 16) {
22 if let moon { MoonDisc(fraction: moon.fraction, waxing: moon.waxing).frame(width: 52) }
23 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 6) {
24 ForEach(rows, id: \.self) { row in
25 GridRow {
26 Text(row.label).dimmed(0.7)
27 Text(row.value).monospacedDigit().foregroundStyle(row.alert ? accent : ink)
28 }
29 .font(.subheadline)
30 }
31 }
32 }
33 if let footnote {
34 Rectangle().fill(ink.opacity(0.25)).frame(height: 0.5)
35 Text(footnote).font(.caption2.monospacedDigit()).dimmed(0.6)
36 }
37 }
38 .frame(maxWidth: .infinity, alignment: .leading)
39 .padding(16)
40 .foregroundStyle(ink)
41 .background(Color.black)
42 .environment(\.colorScheme, .dark)
43 }
44}
App/Sources/Intents/LibraryPageEntity.swift added +75
@@ -0,0 +1,75 @@
1import Answers
2import AppIntents
3import CoreSpotlight
4import Library
5
6/// A Library page for Siri, Shortcuts and Spotlight.
7struct LibraryPageEntity: IndexedEntity {
8 static let typeDisplayRepresentation: TypeDisplayRepresentation = "Library Page"
9 static let defaultQuery = LibraryPageQuery()
10
11 let page: LibraryPage
12 var id: String { page.id }
13
14 var displayRepresentation: DisplayRepresentation {
15 DisplayRepresentation(title: "\(page.title)", subtitle: "\(page.subtitle)", image: .init(systemName: page.section.symbol))
16 }
17
18 var attributeSet: CSSearchableItemAttributeSet {
19 let a = defaultAttributeSet
20 a.contentDescription = page.paragraphs.first
21 a.keywords = [page.section.title] + page.facts.map(\.value)
22 return a
23 }
24}
25
26struct LibraryPageQuery: EntityStringQuery {
27 func entities(for identifiers: [String]) async throws -> [LibraryPageEntity] {
28 identifiers.compactMap { Library.page(id: $0).map(LibraryPageEntity.init) }
29 }
30
31 func entities(matching string: String) async throws -> [LibraryPageEntity] {
32 Library.search(string).prefix(20).map(LibraryPageEntity.init)
33 }
34
35 /// The pages the Sky tab can turn to: the Moon, planets, constellations and named stars.
36 /// These are also the names Siri listens for in "Look up …" and "Show … in the sky".
37 func suggestedEntities() async throws -> [LibraryPageEntity] {
38 Library.pages.filter { $0.skyTarget != nil }.map(LibraryPageEntity.init)
39 }
40}
41
42extension LibrarySection {
43 var symbol: String {
44 switch self {
45 case .sun: "sun.max"
46 case .planets: "circle.circle"
47 case .dwarfPlanets: "circle.dotted"
48 case .moons: "moon"
49 case .constellations: "star.circle"
50 case .stars: "star"
51 case .deepSky: "sparkles"
52 case .exoplanets: "globe"
53 case .missions: "antenna.radiowaves.left.and.right"
54 case .rockets: "airplane.departure"
55 }
56 }
57}
58
59/// Indexes every Library page in Spotlight once per build; pages only change with an app release.
60enum SpotlightIndexer {
61 static let key = "spotlightIndexedBuild"
62
63 static func indexIfNeeded() async {
64 let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? ""
65 guard UserDefaults.standard.string(forKey: key) != build else { return }
66 let index = CSSearchableIndex(name: "library")
67 do {
68 try await index.deleteAppEntities(ofType: LibraryPageEntity.self)
69 try await index.indexAppEntities(Library.pages.map(LibraryPageEntity.init))
70 UserDefaults.standard.set(build, forKey: key)
71 } catch {
72 log.error("Spotlight indexing failed: \(error.localizedDescription, privacy: .public)")
73 }
74 }
75}
App/Sources/Intents/MoonriseShortcuts.swift added +61
@@ -0,0 +1,61 @@
1import AppIntents
2
3/// Siri phrases. Each must contain the app's name; at most 10 shortcuts.
4struct MoonriseShortcuts: AppShortcutsProvider {
5 static var appShortcuts: [AppShortcut] {
6 AppShortcut(
7 intent: MoonPhaseIntent(),
8 phrases: ["Moon phase in \(.applicationName)", "How full is the moon in \(.applicationName)", "\(.applicationName) moon phase"],
9 shortTitle: "Moon Phase",
10 systemImageName: "moonphase.waxing.gibbous"
11 )
12 AppShortcut(
13 intent: TonightIntent(),
14 phrases: ["What's in the sky tonight in \(.applicationName)", "What can I see tonight in \(.applicationName)", "\(.applicationName) tonight"],
15 shortTitle: "Tonight's Sky",
16 systemImageName: "sparkles"
17 )
18 AppShortcut(
19 intent: NextPassIntent(),
20 phrases: ["When is the next ISS pass in \(.applicationName)", "Next pass of \(\.$satellite) in \(.applicationName)", "When can I see \(\.$satellite) in \(.applicationName)"],
21 shortTitle: "Next Pass",
22 systemImageName: "dot.radiowaves.up.forward"
23 )
24 AppShortcut(
25 intent: NextLaunchIntent(),
26 phrases: ["When's the next launch in \(.applicationName)", "Next rocket launch in \(.applicationName)"],
27 shortTitle: "Next Launch",
28 systemImageName: "airplane.departure"
29 )
30 AppShortcut(
31 intent: SpaceWeatherIntent(),
32 phrases: ["Aurora chance in \(.applicationName)", "What's the Kp index in \(.applicationName)", "Space weather in \(.applicationName)"],
33 shortTitle: "Aurora and Kp",
34 systemImageName: "sun.max"
35 )
36 AppShortcut(
37 intent: ProbeDistanceIntent(),
38 phrases: ["How far is \(\.$probe) in \(.applicationName)", "How far away is \(\.$probe) in \(.applicationName)"],
39 shortTitle: "Probe Distance",
40 systemImageName: "antenna.radiowaves.left.and.right"
41 )
42 AppShortcut(
43 intent: LookUpIntent(),
44 phrases: ["Look up \(\.$page) in \(.applicationName)", "Look up something in \(.applicationName)"],
45 shortTitle: "Look Up",
46 systemImageName: "books.vertical"
47 )
48 AppShortcut(
49 intent: ShowInSkyIntent(),
50 phrases: ["Show \(\.$target) in the sky with \(.applicationName)", "Find \(\.$target) in \(.applicationName)", "Show something in the sky with \(.applicationName)"],
51 shortTitle: "Show in Sky",
52 systemImageName: "scope"
53 )
54 AppShortcut(
55 intent: SearchLibraryIntent(),
56 phrases: ["Search the \(.applicationName) Library", "Search \(.applicationName)"],
57 shortTitle: "Search Library",
58 systemImageName: "magnifyingglass"
59 )
60 }
61}
App/Sources/Intents/OpenIntents.swift added +72
@@ -0,0 +1,72 @@
1import Answers
2import AppIntents
3import Library
4
5/// Opens a Library page; used when a page is tapped in Spotlight. Not marked `.system.open`: that schema
6/// needs iOS 27, and a second, iOS 27-only OpenIntent for the same entity is rejected at build time.
7struct OpenLibraryPageIntent: OpenIntent {
8 static let title: LocalizedStringResource = "Open Library Page"
9 static let supportedModes: IntentModes = .foreground(.immediate)
10
11 @Parameter(title: "Page")
12 var target: LibraryPageEntity
13
14 @MainActor
15 func perform() async throws -> some IntentResult {
16 Router.shared.libraryPage = target.page
17 return .result()
18 }
19}
20
21/// Turns the Sky tab to an object; pages with no place in the sky (deep sky, missions) open instead.
22struct ShowInSkyIntent: AppIntent {
23 static let title: LocalizedStringResource = "Show in Sky"
24 static let description = IntentDescription("Turns the Sky tab to the Moon, a planet, a constellation or a bright star.")
25 static let supportedModes: IntentModes = .foreground(.immediate)
26
27 @Parameter(title: "Object", requestValueDialog: "Which object?")
28 var target: LibraryPageEntity
29
30 @MainActor
31 func perform() async throws -> some IntentResult {
32 if let t = target.page.skyTarget {
33 Router.shared.tab = "sky"
34 Router.shared.skyTarget = t
35 } else {
36 Router.shared.libraryPage = target.page
37 }
38 return .result()
39 }
40}
41
42struct SearchLibraryIntent: AppIntent {
43 static let title: LocalizedStringResource = "Search the Library"
44 static let description = IntentDescription("Opens the Library with results for a search.")
45 static let supportedModes: IntentModes = .foreground(.immediate)
46
47 @Parameter(title: "Search", requestValueDialog: "What would you like to look for?")
48 var query: String
49
50 @MainActor
51 func perform() async throws -> some IntentResult {
52 Router.shared.tab = "library"
53 Router.shared.libraryQuery = query
54 return .result()
55 }
56}
57
58/// "Search Moonrise for Cassini" in Siri's own words, without a fixed phrase.
59@available(iOS 27, *)
60@AppIntent(schema: .system.searchInApp)
61struct SearchLibrarySchemaIntent {
62 static let searchScopes: [StringSearchScope] = [.general]
63
64 var criteria: StringSearchCriteria
65
66 @MainActor
67 func perform() async throws -> some IntentResult {
68 Router.shared.tab = "library"
69 Router.shared.libraryQuery = criteria.term
70 return .result()
71 }
72}
App/Sources/LaunchesScreen.swift +8 −1
@@ -1,18 +1,25 @@
1import ActivityKit 1import ActivityKit
2import Feeds 2import Feeds
3import Library
3import SwiftUI 4import SwiftUI
4 5
5/// Backend base URL from Info.plist (MOONRISE_API_URL build setting); nil when unset. 6/// Backend base URL from Info.plist (MOONRISE_API_URL build setting); nil when unset.
6let apiBase: URL? = (Bundle.main.object(forInfoDictionaryKey: "MoonriseAPIURL") as? String) 7let apiBase: URL? = (Bundle.main.object(forInfoDictionaryKey: "MoonriseAPIURL") as? String)
7 .flatMap { $0.isEmpty ? nil : URL(string: $0) } 8 .flatMap { $0.isEmpty ? nil : URL(string: $0) }
8 9
9/// Tab and News/Launches switch that a notification tap can change. 10/// Tab and News/Launches switch that a notification tap can change, and what Siri or Spotlight asked to show.
10@MainActor 11@MainActor
11@Observable 12@Observable
12final class Router { 13final class Router {
13 static let shared = Router() 14 static let shared = Router()
14 var tab = "sky" 15 var tab = "sky"
15 var showNews = false 16 var showNews = false
17 /// Presented as a sheet over any tab.
18 var libraryPage: LibraryPage?
19 /// Typed into the Library's search field.
20 var libraryQuery: String?
21 /// Turned to by the Sky tab once its scene exists.
22 var skyTarget: Tour.Target?
16} 23}
17 24
18struct LaunchesScreen: View { 25struct LaunchesScreen: View {
App/Sources/LibraryScreen.swift +4
@@ -55,6 +55,7 @@ private struct TonightPicks: Sendable {
55 55
56struct LibraryScreen: View { 56struct LibraryScreen: View {
57 @State private var query = "" 57 @State private var query = ""
58 @State private var router = Router.shared
58 @State private var picks = TonightPicks() 59 @State private var picks = TonightPicks()
59 60
60 private var rows: [(String, LibrarySection, [LibraryPage])] { 61 private var rows: [(String, LibrarySection, [LibraryPage])] {
@@ -106,6 +107,9 @@ struct LibraryScreen: View {
106 } 107 }
107 .foregroundStyle(ink) 108 .foregroundStyle(ink)
108 .tint(ink) 109 .tint(ink)
110 .onChange(of: router.libraryQuery, initial: true) {
111 if let q = router.libraryQuery { query = q; router.libraryQuery = nil }
112 }
109 .task { 113 .task {
110 if picks.planet == nil, picks.constellations.isEmpty { 114 if picks.planet == nil, picks.constellations.isEmpty {
111 picks = await Task.detached { TonightPicks.compute() }.value 115 picks = await Task.detached { TonightPicks.compute() }.value
App/Sources/MoonriseApp.swift +6
@@ -1,3 +1,4 @@
1import AppIntents
1import SkyRenderer 2import SkyRenderer
2import SwiftUI 3import SwiftUI
3import UIKit 4import UIKit
@@ -30,6 +31,9 @@ struct MoonriseApp: App {
30 .fullScreenCover(isPresented: .constant(!onboarded)) { 31 .fullScreenCover(isPresented: .constant(!onboarded)) {
31 OnboardingScreen().preferredColorScheme(.dark) 32 OnboardingScreen().preferredColorScheme(.dark)
32 } 33 }
34 .sheet(item: $router.libraryPage) { page in
35 NavigationStack { LibraryPageView(page: page) }
36 }
33 } 37 }
34 } 38 }
35} 39}
@@ -39,6 +43,8 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent
39 ImageLoader.configureURLCache() 43 ImageLoader.configureURLCache()
40 UNUserNotificationCenter.current().delegate = self 44 UNUserNotificationCenter.current().delegate = self
41 application.registerForRemoteNotifications() 45 application.registerForRemoteNotifications()
46 MoonriseShortcuts.updateAppShortcutParameters()
47 Task.detached(priority: .background) { await SpotlightIndexer.indexIfNeeded() }
42 return true 48 return true
43 } 49 }
44 50
App/Sources/SkyModel.swift +3
@@ -1,3 +1,4 @@
1import Answers
1import CoreLocation 2import CoreLocation
2import Feeds 3import Feeds
3import Foundation 4import Foundation
@@ -115,6 +116,7 @@ final class SkyModel: NSObject, CLLocationManagerDelegate {
115 observer = Observer(latitude: lat, longitude: lon) 116 observer = Observer(latitude: lat, longitude: lon)
116 placeName = d.string(forKey: SettingsKey.cityName) ?? "Chicago" 117 placeName = d.string(forKey: SettingsKey.cityName) ?? "Chicago"
117 hasLocation = true 118 hasLocation = true
119 Place(latitude: lat, longitude: lon, name: placeName, kind: .city).save(to: d)
118 } else { 120 } else {
119 if !hasLocation { placeName = "Chicago (location unavailable)" } 121 if !hasLocation { placeName = "Chicago (location unavailable)" }
120 manager.requestWhenInUseAuthorization() 122 manager.requestWhenInUseAuthorization()
@@ -190,6 +192,7 @@ final class SkyModel: NSObject, CLLocationManagerDelegate {
190 self.observer = Observer(latitude: c.latitude, longitude: c.longitude) 192 self.observer = Observer(latitude: c.latitude, longitude: c.longitude)
191 self.hasLocation = true 193 self.hasLocation = true
192 self.placeName = "Your location" 194 self.placeName = "Your location"
195 Place(latitude: c.latitude, longitude: c.longitude, name: self.placeName, kind: .located).save(to: .standard)
193 self.refresh() 196 self.refresh()
194 } 197 }
195 } 198 }
App/Sources/SkyScreen.swift +16 −1
@@ -24,6 +24,9 @@ struct SkyScreen: View {
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 @State private var turning: Task<Void, Never>?
27 @State private var router = Router.shared
28 /// Object asked for from Siri or Shortcuts, highlighted until the view is dragged.
29 @State private var shown: Tour.Target?
27 @Environment(\.scenePhase) private var scenePhase 30 @Environment(\.scenePhase) private var scenePhase
28 @State private var libraryPage: LibraryPage? 31 @State private var libraryPage: LibraryPage?
29 @State private var selection: SkyObject? 32 @State private var selection: SkyObject?
@@ -42,7 +45,7 @@ struct SkyScreen: View {
42 style.background.ignoresSafeArea() 45 style.background.ignoresSafeArea()
43 if let scene = model.scene { 46 if let scene = model.scene {
44 let view = current(width: geo.size.width) 47 let view = current(width: geo.size.width)
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 48 SkyView(scene: scene, centerAzimuth: view.azimuth, centerAltitude: view.altitude, fieldOfView: view.fieldOfView, style: style, culture: Catalog.skyCultures.first { $0.id == skyCulture }, highlight: (tour.map { $0.steps[tourStep].target } ?? shown).flatMap(highlight)) { [stats] d in
46 stats.record(d) 49 stats.record(d)
47 } 50 }
48 .ignoresSafeArea() 51 .ignoresSafeArea()
@@ -114,6 +117,8 @@ struct SkyScreen: View {
114 .presentationDetents([.medium, .large]) 117 .presentationDetents([.medium, .large])
115 } 118 }
116 .onChange(of: model.offsetHours) { select(selection) } 119 .onChange(of: model.offsetHours) { select(selection) }
120 .onChange(of: router.skyTarget, initial: true) { showRequestedTarget() }
121 .onChange(of: model.scene == nil) { showRequestedTarget() }
117 .onChange(of: onboarded) { if onboarded { model.start() } } 122 .onChange(of: onboarded) { if onboarded { model.start() } }
118 .onChange(of: night, initial: true) { nightActive = night } 123 .onChange(of: night, initial: true) { nightActive = night }
119 .onChange(of: model.observer) { rescheduleSummary() } 124 .onChange(of: model.observer) { rescheduleSummary() }
@@ -175,6 +180,15 @@ struct SkyScreen: View {
175 } 180 }
176 } 181 }
177 182
183 /// Turns to the object in `router.skyTarget` once there is a scene to find it in.
184 private func showRequestedTarget() {
185 guard let t = router.skyTarget, model.scene != nil else { return }
186 router.skyTarget = nil
187 tour = nil
188 shown = t
189 aim(at: t)
190 }
191
178 private func rescheduleSummary() { 192 private func rescheduleSummary() {
179 Task { await DailySummaryNotifications.reschedule(model) } 193 Task { await DailySummaryNotifications.reschedule(model) }
180 } 194 }
@@ -200,6 +214,7 @@ struct SkyScreen: View {
200 DragGesture(minimumDistance: 6) 214 DragGesture(minimumDistance: 6)
201 .onChanged { _ in 215 .onChanged { _ in
202 turning?.cancel() 216 turning?.cancel()
217 shown = nil
203 if pointer.active { 218 if pointer.active {
204 azimuth = pointer.direction.azimuth 219 azimuth = pointer.direction.azimuth
205 altitude = min(max(pointer.direction.altitude, 0), 90) 220 altitude = min(max(pointer.direction.altitude, 0), 90)
App/project.yml +2
@@ -30,6 +30,8 @@ targets:
30 product: Library 30 product: Library
31 - package: MoonriseKit 31 - package: MoonriseKit
32 product: ObservingLog 32 product: ObservingLog
33 - package: MoonriseKit
34 product: Answers
33 - target: MoonriseWidgets 35 - target: MoonriseWidgets
34 - target: MoonriseWatch 36 - target: MoonriseWatch
35 entitlements: 37 entitlements:
Packages/MoonriseKit/Package.swift +3
@@ -10,6 +10,7 @@ let package = Package(
10 .library(name: "Feeds", targets: ["Feeds"]), 10 .library(name: "Feeds", targets: ["Feeds"]),
11 .library(name: "Library", targets: ["Library"]), 11 .library(name: "Library", targets: ["Library"]),
12 .library(name: "ObservingLog", targets: ["ObservingLog"]), 12 .library(name: "ObservingLog", targets: ["ObservingLog"]),
13 .library(name: "Answers", targets: ["Answers"]),
13 ], 14 ],
14 targets: [ 15 targets: [
15 // Astronomy Engine v2.1.19 (MIT), https://github.com/cosinekitty/astronomy 16 // Astronomy Engine v2.1.19 (MIT), https://github.com/cosinekitty/astronomy
@@ -27,10 +28,12 @@ let package = Package(
27 .target(name: "Feeds"), 28 .target(name: "Feeds"),
28 .target(name: "ObservingLog"), 29 .target(name: "ObservingLog"),
29 .target(name: "Library", resources: [.copy("Resources/pages"), .copy("Resources/tours"), .copy("Resources/stories")]), 30 .target(name: "Library", resources: [.copy("Resources/pages"), .copy("Resources/tours"), .copy("Resources/stories")]),
31 .target(name: "Answers", dependencies: ["SkyEngine", "SkyRenderer", "Feeds", "Library"]),
30 .testTarget(name: "SkyEngineTests", dependencies: ["SkyEngine"]), 32 .testTarget(name: "SkyEngineTests", dependencies: ["SkyEngine"]),
31 .testTarget(name: "FeedsTests", dependencies: ["Feeds"], resources: [.copy("Fixtures")]), 33 .testTarget(name: "FeedsTests", dependencies: ["Feeds"], resources: [.copy("Fixtures")]),
32 .testTarget(name: "ObservingLogTests", dependencies: ["ObservingLog"]), 34 .testTarget(name: "ObservingLogTests", dependencies: ["ObservingLog"]),
33 .testTarget(name: "LibraryTests", dependencies: ["Library", "SkyRenderer", "SkyEngine"]), 35 .testTarget(name: "LibraryTests", dependencies: ["Library", "SkyRenderer", "SkyEngine"]),
34 .testTarget(name: "SkyRendererTests", dependencies: ["SkyRenderer"]), 36 .testTarget(name: "SkyRendererTests", dependencies: ["SkyRenderer"]),
37 .testTarget(name: "AnswersTests", dependencies: ["Answers"]),
35 ] 38 ]
36) 39)
Packages/MoonriseKit/Sources/Answers/FeedAnswers.swift added +79
@@ -0,0 +1,79 @@
1import Feeds
2import Foundation
3import SkyEngine
4
5/// Kp now and over the next day, and whether aurora may be seen from here.
6public struct AuroraAnswer: Sendable {
7 public var kpNow: Double
8 public var kpNext24h: Double?
9 public var observedAt: Date
10 /// Kp at which aurora may be seen on the horizon from this place.
11 public var kpNeeded: Double
12 public var possible: Bool
13 public var rows: [AnswerRow]
14 public var footnote: String
15 public var spoken: String
16
17 static func level(_ kp: Double) -> String {
18 if let g = SpaceWeather.gScale(kp) { return "G\(g) storm" }
19 return kp < 4 ? "quiet" : "active"
20 }
21
22 public init(weather: SpaceWeather, place: Place, locale: Locale = .current, timeZone: TimeZone = .current) {
23 let say = Phrasing(locale: locale, timeZone: timeZone)
24 kpNow = weather.kpNow
25 kpNext24h = weather.kpNext24h
26 observedAt = weather.observedAt
27 kpNeeded = Geomagnetic.kpNeeded(place.observer)
28 possible = max(kpNow, kpNext24h ?? 0) >= kpNeeded
29 let horizon = Geomagnetic.latitude(place.observer) < 0 ? "southern" : "northern"
30
31 let gNow = SpaceWeather.gScale(kpNow)
32 rows = [
33 AnswerRow("Kp now", say.number(kpNow, digits: 1) + (gNow.map { " · G\($0)" } ?? ""), alert: gNow != nil),
34 AnswerRow("Next 24 h", kpNext24h.map { "up to \(say.number($0, digits: 1))" } ?? "–", alert: kpNext24h.flatMap(SpaceWeather.gScale) != nil),
35 AnswerRow("Aurora", possible ? "possible, \(horizon) horizon" : "unlikely here"),
36 ]
37 footnote = "NOAA SWPC · \(say.time(observedAt))"
38
39 var lines = ["Kp \(say.number(kpNow, digits: 1)), \(Self.level(kpNow))."]
40 if let next = kpNext24h, next > kpNow { lines.append("Up to Kp \(say.number(next, digits: 1)) in the next 24 hours.") }
41 if possible {
42 lines.append("Aurora possible low on the \(horizon) horizon.")
43 } else if kpNeeded > 9 {
44 lines.append("Aurora is out of reach this far from the poles.")
45 } else {
46 lines.append("Aurora unlikely here. You'd need about Kp \(Int(kpNeeded.rounded(.up))).")
47 }
48 spoken = lines.sentences(place.note)
49 }
50}
51
52/// The next launch in the backend's schedule that has not yet flown.
53public struct LaunchAnswer: Sendable {
54 public var launch: Launch?
55 public var rows: [AnswerRow]
56 public var spoken: String
57
58 /// Launch Library 2 statuses for launches that have left the pad.
59 static let flown: Set<String> = ["Success", "Failure", "Partial Failure", "In Flight"]
60
61 public init(launches: [Launch], at date: Date, locale: Locale = .current, timeZone: TimeZone = .current) {
62 let say = Phrasing(locale: locale, timeZone: timeZone)
63 launch = launches.filter { $0.net > date && !Self.flown.contains($0.status) }.min { $0.net < $1.net }
64 guard let l = launch else {
65 rows = []
66 spoken = "No upcoming launches are scheduled."
67 return
68 }
69 let mission = l.mission ?? l.name.components(separatedBy: " | ").last ?? l.name
70 let site = l.location.components(separatedBy: ",").first ?? l.location
71 rows = [
72 AnswerRow("Rocket", l.rocket),
73 AnswerRow("Mission", mission),
74 AnswerRow("From", site),
75 AnswerRow("Launch", "\(say.short(l.net, from: date)) · \(l.statusName)", alert: l.status == "Hold"),
76 ]
77 spoken = "\(l.rocket), \(mission), from \(site) in \(say.duration(l.net.timeIntervalSince(date), spoken: true)). Status: \(l.statusName)."
78 }
79}
Packages/MoonriseKit/Sources/Answers/Phrasing.swift added +79
@@ -0,0 +1,79 @@
1import Foundation
2
3/// One label and value in an answer's result view. `alert` values are drawn in the accent red.
4public struct AnswerRow: Sendable, Hashable {
5 public var label: String
6 public var value: String
7 public var alert = false
8
9 public init(_ label: String, _ value: String, alert: Bool = false) {
10 self.label = label
11 self.value = value
12 self.alert = alert
13 }
14}
15
16/// Times, days, durations and numbers for spoken answers and their rows.
17struct Phrasing {
18 var locale: Locale
19 var timeZone: TimeZone
20 var calendar: Calendar
21
22 init(locale: Locale, timeZone: TimeZone) {
23 self.locale = locale
24 self.timeZone = timeZone
25 calendar = Calendar(identifier: .gregorian)
26 calendar.timeZone = timeZone
27 calendar.locale = locale
28 }
29
30 private var style: Date.FormatStyle { Date.FormatStyle(locale: locale, calendar: calendar, timeZone: timeZone) }
31
32 /// "19:42" or "7:42 PM".
33 func time(_ d: Date) -> String {
34 d.formatted(Date.FormatStyle(date: .omitted, time: .shortened, locale: locale, timeZone: timeZone))
35 }
36
37 /// "today at 19:42", "tomorrow at 05:10", "Thursday at 03:12" or "on 28 October at 03:12".
38 func when(_ d: Date, from now: Date) -> String {
39 let at = "at \(time(d))"
40 if calendar.isDate(d, inSameDayAs: now) { return "today \(at)" }
41 if let next = calendar.date(byAdding: .day, value: 1, to: now), calendar.isDate(d, inSameDayAs: next) { return "tomorrow \(at)" }
42 if let week = calendar.date(byAdding: .day, value: 6, to: calendar.startOfDay(for: now)), d < week {
43 return "\(d.formatted(style.weekday(.wide))) \(at)"
44 }
45 return "on \(d.formatted(style.day().month(.wide))) \(at)"
46 }
47
48 /// "19:42" today, else "Thu 22 Oct 19:42", for rows.
49 func short(_ d: Date, from now: Date) -> String {
50 calendar.isDate(d, inSameDayAs: now) ? time(d) : d.formatted(style.weekday(.abbreviated).day().month(.abbreviated).hour().minute())
51 }
52
53 /// "11 hours, 30 minutes" (spoken) or "11 hr, 30 min" (rows).
54 func duration(_ seconds: TimeInterval, spoken: Bool) -> String {
55 Duration.seconds(seconds.rounded()).formatted(.units(allowed: [.days, .hours, .minutes], width: spoken ? .wide : .abbreviated, maximumUnitCount: 2).locale(locale))
56 }
57
58 func number(_ x: Double, digits: Int) -> String {
59 x.formatted(.number.precision(.fractionLength(digits)).locale(locale))
60 }
61
62 func list(_ items: [String]) -> String {
63 items.formatted(.list(type: .and).locale(locale))
64 }
65}
66
67enum Compass {
68 static func point(_ azimuth: Double) -> String {
69 ["N", "NE", "E", "SE", "S", "SW", "W", "NW"][index(azimuth)]
70 }
71
72 static func name(_ azimuth: Double) -> String {
73 ["north", "northeast", "east", "southeast", "south", "southwest", "west", "northwest"][index(azimuth)]
74 }
75
76 private static func index(_ azimuth: Double) -> Int {
77 Int(((azimuth + 22.5) / 45).rounded(.down)) % 8
78 }
79}
Packages/MoonriseKit/Sources/Answers/Place.swift added +61
@@ -0,0 +1,61 @@
1import Foundation
2import SkyEngine
3
4/// Where answers are worked out for. The Sky tab saves its observer here on every fix or city change,
5/// so intents launched in the background can answer without a location fix.
6public struct Place: Codable, Sendable, Equatable {
7 public enum Kind: String, Codable, Sendable {
8 /// The device's own position.
9 case located
10 /// A city chosen in Settings.
11 case city
12 /// No fix and no city yet.
13 case fallback
14 }
15
16 public var latitude: Double
17 public var longitude: Double
18 public var name: String
19 public var kind: Kind
20
21 /// Rounds to 0.1°, about 10 km, which is all any answer needs.
22 public init(latitude: Double, longitude: Double, name: String, kind: Kind) {
23 self.latitude = (latitude * 10).rounded() / 10
24 self.longitude = (longitude * 10).rounded() / 10
25 self.name = name
26 self.kind = kind
27 }
28
29 public static let fallback = Place(latitude: 41.8781, longitude: -87.6298, name: "Chicago", kind: .fallback)
30
31 public var observer: Observer { Observer(latitude: latitude, longitude: longitude) }
32
33 /// "Your location" or the place's name.
34 public var label: String { kind == .located ? "Your location" : name }
35
36 /// Sentence that ends an answer which depends on the place; nil for the device's own position.
37 public var note: String? {
38 switch kind {
39 case .located: nil
40 case .city: "For \(name)."
41 case .fallback: "For \(name); open Moonrise to set your location."
42 }
43 }
44
45 static let key = "answersPlace"
46
47 public static func saved(in defaults: UserDefaults) -> Place? {
48 defaults.data(forKey: key).flatMap { try? JSONDecoder().decode(Place.self, from: $0) }
49 }
50
51 public func save(to defaults: UserDefaults) {
52 defaults.set(try? JSONEncoder().encode(self), forKey: Self.key)
53 }
54}
55
56extension [String] {
57 /// Joins sentences, with the place's note, if any, last.
58 func sentences(_ note: String?) -> String {
59 (self + [note].compactMap { $0 }).joined(separator: " ")
60 }
61}
Packages/MoonriseKit/Sources/Answers/ReferenceAnswers.swift added +60
@@ -0,0 +1,60 @@
1import Foundation
2import Library
3import SkyEngine
4import SkyRenderer
5
6/// The five deep-space probes in `Spacecraft.all`, by name.
7public enum Probe: String, CaseIterable, Sendable {
8 case voyager1 = "Voyager 1", voyager2 = "Voyager 2", pioneer10 = "Pioneer 10", pioneer11 = "Pioneer 11", newHorizons = "New Horizons"
9}
10
11public enum ProbeAnswerError: Error {
12 case noData
13}
14
15/// A probe's distance from Earth and the Sun, and the one-way light time.
16public struct ProbeAnswer: Sendable {
17 public var probe: Probe
18 public var fromEarthKm: Double
19 public var fromSunAU: Double
20 /// Seconds for light to cross `fromEarthKm`.
21 public var lightTime: TimeInterval
22 public var rows: [AnswerRow]
23 public var spoken: String
24
25 public init(probe: Probe, at date: Date, locale: Locale = .current) throws {
26 let say = Phrasing(locale: locale, timeZone: .current)
27 guard let craft = Spacecraft.all.first(where: { $0.name == probe.rawValue }), let p = craft.position3(at: date) else { throw ProbeAnswerError.noData }
28 let d = p - (try SkyEngine.heliocentricEarth(at: date))
29 let au = (d * d).sum().squareRoot()
30 self.probe = probe
31 fromEarthKm = au * 149_597_870.7
32 fromSunAU = (p * p).sum().squareRoot()
33 lightTime = au * 499.004784
34 let billions = "\(say.number(fromEarthKm / 1e9, digits: 1)) billion km"
35 rows = [
36 AnswerRow("From Earth", billions),
37 AnswerRow("From the Sun", "\(say.number(fromSunAU, digits: 1)) AU"),
38 AnswerRow("Light time", say.duration(lightTime, spoken: false)),
39 ]
40 var lines = ["\(probe.rawValue) is \(billions) from Earth. Its signal takes \(say.duration(lightTime, spoken: true)) to reach us."]
41 if probe == .pioneer10 || probe == .pioneer11 { lines.append("Contact was lost, so this is a prediction.") }
42 spoken = lines.sentences(nil)
43 }
44}
45
46/// A Library page's subtitle and first three facts.
47public struct LookUpAnswer: Sendable {
48 public var page: LibraryPage
49 public var subtitle: String
50 public var rows: [AnswerRow]
51 public var spoken: String
52
53 public init(page: LibraryPage) {
54 self.page = page
55 subtitle = page.subtitle.replacingOccurrences(of: " · ", with: ", ")
56 let facts = page.facts.prefix(3)
57 rows = facts.map { AnswerRow($0.label, $0.value) }
58 spoken = (["\(page.title): \(subtitle)."] + facts.map { "\($0.label): \($0.value)." }).sentences(nil)
59 }
60}
Packages/MoonriseKit/Sources/Answers/SkyAnswers.swift added +165
@@ -0,0 +1,165 @@
1import Foundation
2import Library
3import SkyEngine
4
5/// The Moon's phase now and its next quarter.
6public struct MoonAnswer: Sendable {
7 public var phaseName: String
8 /// Fraction of the disc lit, 0...1.
9 public var illumination: Double
10 public var waxing: Bool
11 public var nextQuarter: SkyEngine.MoonQuarter
12 public var nextQuarterDate: Date
13 public var rows: [AnswerRow]
14 public var spoken: String
15
16 public static func quarterName(_ q: SkyEngine.MoonQuarter) -> String {
17 switch q {
18 case .new: "New moon"
19 case .firstQuarter: "First quarter"
20 case .full: "Full moon"
21 case .lastQuarter: "Last quarter"
22 }
23 }
24
25 public init(at date: Date, locale: Locale = .current, timeZone: TimeZone = .current) throws {
26 let say = Phrasing(locale: locale, timeZone: timeZone)
27 let angle = try SkyEngine.moonPhase(at: date)
28 phaseName = DailySummary.phaseName(angle)
29 illumination = try SkyEngine.illumination(.moon, at: date).phaseFraction
30 waxing = angle < 180
31 (nextQuarter, nextQuarterDate) = try SkyEngine.nextMoonQuarter(after: date)
32 let lit = "\(Int((illumination * 100).rounded()))%"
33 let quarter = Self.quarterName(nextQuarter)
34 rows = [
35 AnswerRow("Phase", phaseName),
36 AnswerRow("Illuminated", lit),
37 AnswerRow(quarter, say.short(nextQuarterDate, from: date)),
38 ]
39 spoken = "\(phaseName), \(lit) lit. \(quarter) \(say.when(nextQuarterDate, from: date))."
40 }
41}
42
43/// Naked-eye planets well placed tonight, with sunset and sunrise.
44public struct TonightAnswer: Sendable {
45 public struct Planet: Sendable, Hashable {
46 public var name: String
47 /// "up now, 32° SE", "rises 21:10" or "highest 22:40, 41°".
48 public var detail: String
49 }
50
51 public var planets: [Planet]
52 public var sunset: Date?
53 public var sunrise: Date?
54 public var heading: String
55 public var rows: [AnswerRow]
56 public var spoken: String
57
58 public init(at date: Date, place: Place, locale: Locale = .current, timeZone: TimeZone = .current) throws {
59 let say = Phrasing(locale: locale, timeZone: timeZone)
60 let t = try Tonight(at: date, from: place.observer)
61 sunset = t.sunset
62 sunrise = t.sunrise
63 heading = "Tonight · \(place.label)"
64
65 let placed = t.planets.filter { $0.best != nil }
66 planets = placed.map { p in
67 let detail: String
68 if p.now.altitude > 0 {
69 detail = "up now, \(Int(p.now.altitude.rounded()))° \(Compass.point(p.now.azimuth))"
70 } else if let rise = p.rise, rise < t.end {
71 detail = "rises \(say.time(rise))"
72 } else {
73 detail = "highest \(say.time(p.best!.date)), \(Int(p.best!.position.altitude.rounded()))°"
74 }
75 return Planet(name: p.body.rawValue.capitalized, detail: detail)
76 }
77
78 var sun: [String] = []
79 if let s = t.sunset { sun.append("sets \(say.time(s))") }
80 if let r = t.sunrise { sun.append("rises \(say.time(r))") }
81 rows = (planets.isEmpty ? [AnswerRow("Planets", "none well placed")] : planets.prefix(3).map { AnswerRow($0.name, $0.detail) })
82 + [AnswerRow("Sun", sun.joined(separator: " · "))]
83
84 var lines: [String] = []
85 if let first = placed.first {
86 let name = first.body.rawValue.capitalized
87 lines.append("Up tonight: \(say.list(planets.map(\.name))).")
88 if first.now.altitude > 0 {
89 lines.append("\(name) is up now, \(Int(first.now.altitude.rounded()))° in the \(Compass.name(first.now.azimuth)).")
90 } else if let rise = first.rise, rise < t.end {
91 lines.append("\(name) rises \(say.when(rise, from: date)).")
92 } else if let best = first.best {
93 lines.append("\(name) is highest \(say.when(best.date, from: date)), \(Int(best.position.altitude.rounded()))° up.")
94 }
95 } else {
96 lines.append("No bright planets are well placed tonight.")
97 }
98 if let s = t.sunset {
99 lines.append("Sunset \(say.time(s)).")
100 } else if let r = t.sunrise {
101 lines.append("Sunrise \(say.time(r)).")
102 }
103 spoken = lines.sentences(place.note)
104 }
105}
106
107/// The three satellites the app features, by NORAD catalogue number.
108public enum Satellite: Int, CaseIterable, Sendable {
109 case iss = 25544, tiangong = 48274, hubble = 20580
110
111 public var name: String {
112 switch self {
113 case .iss: "ISS"
114 case .tiangong: "Tiangong"
115 case .hubble: "Hubble"
116 }
117 }
118
119 var subject: String { self == .iss ? "The ISS" : name }
120}
121
122/// The next pass of one satellite, preferring one that can be seen.
123public struct PassAnswer: Sendable {
124 public var satellite: Satellite
125 public var pass: SatellitePass?
126 public var rows: [AnswerRow]
127 public var spoken: String
128
129 /// `passes` are this satellite's, in time order, as `SkyEngine.passes` returns them for the next three days.
130 public init(satellite: Satellite, passes: [SatellitePass], at date: Date, place: Place, locale: Locale = .current, timeZone: TimeZone = .current) {
131 let say = Phrasing(locale: locale, timeZone: timeZone)
132 self.satellite = satellite
133 let upcoming = passes.filter { $0.set > date }
134 let visible = upcoming.first(where: \.visible)
135 pass = visible ?? upcoming.first
136 guard let p = pass else {
137 rows = [AnswerRow("Next pass", "none above 10° in 3 days")]
138 spoken = "No \(satellite.name) passes above 10° in the next three days."
139 return
140 }
141 let alt = "\(Int(p.maxAltitude.rounded()))°"
142 rows = [
143 AnswerRow("Rises", "\(say.short(p.rise, from: date)) \(Compass.point(p.riseAzimuth))", alert: p.rise.timeIntervalSince(date) < 15 * 60),
144 AnswerRow("Highest", "\(say.time(p.peak)), \(alt)"),
145 AnswerRow("Sets", "\(say.time(p.set)) \(Compass.point(p.setAzimuth))"),
146 AnswerRow("Visible", p.visible ? "yes" : "no"),
147 ]
148 let rise = "rises \(say.when(p.rise, from: date)) in the \(Compass.name(p.riseAzimuth)), \(alt) high."
149 if visible != nil {
150 spoken = ["\(satellite.subject) \(rise) Visible to the eye."].sentences(place.note)
151 } else {
152 spoken = ["No visible \(satellite.name) passes in the next three days. The next one \(rise)"].sentences(place.note)
153 }
154 }
155}
156
157extension LibraryPage {
158 /// Where the Sky tab can turn to for this page: the Moon, the planets, the 88 constellations and the named stars.
159 public var skyTarget: Tour.Target? {
160 if let c = link.constellation { return Tour.Target(constellation: c) }
161 if let hr = link.hr { return Tour.Target(hr: hr) }
162 if let b = link.body, b == Body.moon.rawValue || Body.planets.contains(where: { $0.rawValue == b }) { return Tour.Target(body: b) }
163 return nil
164 }
165}
Packages/MoonriseKit/Sources/Library/Tours.swift +6
@@ -8,6 +8,12 @@ public struct Tour: Sendable, Decodable, Identifiable {
8 public var hr: Int? 8 public var hr: Int?
9 /// Body raw value, e.g. "jupiter". 9 /// Body raw value, e.g. "jupiter".
10 public var body: String? 10 public var body: String?
11
12 public init(constellation: String? = nil, hr: Int? = nil, body: String? = nil) {
13 self.constellation = constellation
14 self.hr = hr
15 self.body = body
16 }
11 } 17 }
12 18
13 public struct Step: Sendable, Decodable, Hashable { 19 public struct Step: Sendable, Decodable, Hashable {
Packages/MoonriseKit/Sources/SkyEngine/Geomagnetic.swift +17
@@ -21,4 +21,21 @@ public enum Geomagnetic {
21 let b = Int((abs(m) / 5).rounded(.down)) * 5 21 let b = Int((abs(m) / 5).rounded(.down)) * 5
22 return m < 0 ? -(b + 5) : b 22 return m < 0 ? -(b + 5) : b
23 } 23 }
24
25 /// Geomagnetic latitude of the auroral oval's equatorward edge for a Kp value.
26 /// Must match `ovalEdge` in server/src/aurora.ts.
27 public static func ovalEdge(kp: Double) -> Double {
28 66.5 - 2.04 * kp
29 }
30
31 /// Lowest geomagnetic latitude (absolute) where aurora may be seen low on the poleward horizon.
32 /// Must match `visibleFrom` in server/src/aurora.ts.
33 public static func visibleFrom(kp: Double) -> Double {
34 ovalEdge(kp: kp) - 6
35 }
36
37 /// Kp at which aurora may be seen on the horizon from here; 0 under or poleward of the quiet oval.
38 public static func kpNeeded(_ observer: Observer) -> Double {
39 max(0, (visibleFrom(kp: 0) - abs(latitude(observer))) / 2.04)
40 }
24} 41}
Packages/MoonriseKit/Tests/AnswersTests/AnswersTests.swift added +183
@@ -0,0 +1,183 @@
1import Foundation
2import Testing
3@testable import Answers
4@testable import Feeds
5import Library
6@testable import SkyEngine
7
8private let chicagoPlace = Place(latitude: 41.878, longitude: -87.630, name: "Your location", kind: .located)
9private let central = TimeZone(identifier: "America/Chicago")!
10private let gb = Locale(identifier: "en_GB")
11/// 18:00 CDT, 18 October 2026.
12private let evening = Date(timeIntervalSince1970: 1_792_364_400)
13
14@Test func placeRoundsAndRoundTrips() throws {
15 let p = Place(latitude: 41.8781, longitude: -87.6298, name: "Your location", kind: .located)
16 #expect(p.latitude == 41.9)
17 #expect(p.longitude == -87.6)
18 let defaults = try #require(UserDefaults(suiteName: "AnswersTests.place"))
19 defaults.removePersistentDomain(forName: "AnswersTests.place")
20 #expect(Place.saved(in: defaults) == nil)
21 p.save(to: defaults)
22 #expect(Place.saved(in: defaults) == p)
23}
24
25@Test func placeNotes() {
26 #expect(chicagoPlace.note == nil)
27 #expect(Place(latitude: 52.52, longitude: 13.405, name: "Berlin", kind: .city).note == "For Berlin.")
28 #expect(Place.fallback.note == "For Chicago; open Moonrise to set your location.")
29 #expect(Place.fallback.label == "Chicago")
30 #expect(chicagoPlace.label == "Your location")
31}
32
33@Test func compass() {
34 #expect(Compass.point(0) == "N")
35 #expect(Compass.point(135) == "SE")
36 #expect(Compass.point(350) == "N")
37 #expect(Compass.name(240) == "southwest")
38 #expect(Compass.name(270) == "west")
39}
40
41@Test func moonAnswer() throws {
42 let a = try MoonAnswer(at: evening, locale: gb, timeZone: central)
43 let angle = try SkyEngine.moonPhase(at: evening)
44 #expect(a.phaseName == DailySummary.phaseName(angle))
45 #expect(a.waxing == (angle < 180))
46 let lit = Int((try SkyEngine.illumination(.moon, at: evening).phaseFraction * 100).rounded())
47 #expect(a.spoken.hasPrefix("\(a.phaseName), \(lit)% lit. \(MoonAnswer.quarterName(a.nextQuarter)) "))
48 let time = a.nextQuarterDate.formatted(Date.FormatStyle(date: .omitted, time: .shortened, locale: gb, timeZone: central))
49 #expect(a.spoken.hasSuffix(" at \(time)."))
50 #expect(a.rows.count == 3)
51}
52
53@Test func spokenDays() {
54 let p = Phrasing(locale: gb, timeZone: central)
55 #expect(p.when(evening.addingTimeInterval(3600), from: evening) == "today at 19:00")
56 #expect(p.when(evening.addingTimeInterval(12 * 3600), from: evening) == "tomorrow at \(p.time(evening.addingTimeInterval(12 * 3600)))")
57 // Tuesday 20 October.
58 #expect(p.when(evening.addingTimeInterval(48 * 3600), from: evening) == "Tuesday at 18:00")
59 #expect(p.when(evening.addingTimeInterval(10 * 86_400), from: evening) == "on 28 October at 18:00")
60}
61
62@Test func tonightAnswer() throws {
63 let a = try TonightAnswer(at: evening, place: chicagoPlace, locale: gb, timeZone: central)
64 let t = try Tonight(at: evening, from: chicagoPlace.observer)
65 #expect(a.planets.map(\.name) == t.planets.filter { $0.best != nil }.map { $0.body.rawValue.capitalized })
66 #expect(a.spoken.contains("Sunset 18:"))
67 #expect(a.heading == "Tonight · Your location")
68 #expect(a.rows.count <= 4)
69 #expect(a.rows.last?.label == "Sun")
70 if let first = a.planets.first { #expect(a.spoken.hasPrefix("Up tonight: \(first.name)")) }
71
72 let berlin = Place(latitude: 52.52, longitude: 13.405, name: "Berlin", kind: .city)
73 let b = try TonightAnswer(at: evening, place: berlin, locale: gb, timeZone: central)
74 #expect(b.spoken.hasSuffix(" For Berlin."))
75}
76
77private func pass(_ name: String, rise: TimeInterval, alt: Double, visible: Bool) -> SatellitePass {
78 SatellitePass(name: name, rise: evening.addingTimeInterval(rise), set: evening.addingTimeInterval(rise + 360), peak: evening.addingTimeInterval(rise + 180),
79 maxAltitude: alt, riseAzimuth: 240, setAzimuth: 60, visible: visible, featured: true)
80}
81
82@Test func passAnswerPrefersVisible() {
83 let dull = pass("ISS (ZARYA)", rise: 1800, alt: 20, visible: false)
84 let bright = pass("ISS (ZARYA)", rise: 5400, alt: 54.4, visible: true)
85 let a = PassAnswer(satellite: .iss, passes: [dull, bright], at: evening, place: chicagoPlace, locale: gb, timeZone: central)
86 #expect(a.pass == bright)
87 #expect(a.spoken == "The ISS rises today at 19:30 in the southwest, 54° high. Visible to the eye.")
88 #expect(a.rows.count == 4)
89 #expect(!a.rows[0].alert)
90}
91
92@Test func passAnswerWithoutVisiblePass() {
93 let dull = pass("HST", rise: 40_000, alt: 20, visible: false)
94 let a = PassAnswer(satellite: .hubble, passes: [dull], at: evening, place: Place.fallback, locale: gb, timeZone: central)
95 let time = Phrasing(locale: gb, timeZone: central).time(dull.rise)
96 #expect(a.spoken == "No visible Hubble passes in the next three days. The next one rises tomorrow at \(time) in the southwest, 20° high. For Chicago; open Moonrise to set your location.")
97 let none = PassAnswer(satellite: .tiangong, passes: [], at: evening, place: chicagoPlace, locale: gb, timeZone: central)
98 #expect(none.pass == nil)
99 #expect(none.spoken == "No Tiangong passes above 10° in the next three days.")
100}
101
102@Test func passSoonIsAnAlert() {
103 let a = PassAnswer(satellite: .iss, passes: [pass("ISS (ZARYA)", rise: 600, alt: 40, visible: true)], at: evening, place: chicagoPlace, locale: gb, timeZone: central)
104 #expect(a.rows[0].alert)
105}
106
107@Test func satelliteIDs() {
108 #expect(Satellite.allCases.map(\.rawValue) == [25544, 48274, 20580])
109 #expect(Set(Satellite.allCases.map(\.rawValue)) == OrbitalElements.featuredIDs)
110}
111
112private func launches(_ json: String) throws -> [Launch] {
113 try LaunchFeed.decode(Data(#"{"fetchedAt":null,"launches":[\#(json)]}"#.utf8)).launches
114}
115
116private let bandwagon = #"""
117{"id":"a","name":"Falcon 9 Block 5 | Bandwagon 5","net":"2026-10-19T10:30:00Z","status":"Go","statusName":"Go for Launch","provider":"SpaceX",
118 "rocket":"Falcon 9 Block 5","mission":"Bandwagon 5","pad":"Space Launch Complex 40","location":"Cape Canaveral SFS, FL, USA"}
119"""#
120
121@Test func launchAnswer() throws {
122 let done = #"{"id":"b","name":"Electron | X","net":"2026-10-18T22:00:00Z","status":"Success","statusName":"Launch Successful","provider":"Rocket Lab","rocket":"Electron","pad":"LC-1B","location":"Mahia"}"#
123 let a = LaunchAnswer(launches: try launches(done + "," + bandwagon), at: evening, locale: gb, timeZone: central)
124 #expect(a.launch?.id == "a")
125 #expect(a.spoken == "Falcon 9 Block 5, Bandwagon 5, from Cape Canaveral SFS in 11 hours, 30 minutes. Status: Go for Launch.")
126 #expect(a.rows.map(\.label) == ["Rocket", "Mission", "From", "Launch"])
127 #expect(!a.rows[3].alert)
128
129 let hold = bandwagon.replacingOccurrences(of: #""status":"Go","statusName":"Go for Launch""#, with: #""status":"Hold","statusName":"On Hold""#)
130 #expect(LaunchAnswer(launches: try launches(hold), at: evening, locale: gb, timeZone: central).rows[3].alert)
131 #expect(LaunchAnswer(launches: [], at: evening, locale: gb, timeZone: central).spoken == "No upcoming launches are scheduled.")
132}
133
134@Test func auroraAnswer() {
135 let quiet = SpaceWeather(kpNow: 3.33, observedAt: evening, kpNext24h: 4.67)
136 let a = AuroraAnswer(weather: quiet, place: chicagoPlace, locale: gb, timeZone: central)
137 #expect(!a.possible)
138 #expect(a.spoken == "Kp 3.3, quiet. Up to Kp 4.7 in the next 24 hours. Aurora unlikely here. You'd need about Kp 5.")
139 #expect(a.footnote == "NOAA SWPC · 18:00")
140
141 let storm = SpaceWeather(kpNow: 6, observedAt: evening, kpNext24h: nil)
142 let s = AuroraAnswer(weather: storm, place: chicagoPlace, locale: gb, timeZone: central)
143 #expect(s.possible)
144 #expect(s.spoken == "Kp 6.0, G2 storm. Aurora possible low on the northern horizon.")
145 #expect(s.rows[0].alert)
146
147 let hobart = Place(latitude: -42.88, longitude: 147.33, name: "Hobart", kind: .city)
148 #expect(AuroraAnswer(weather: storm, place: hobart, locale: gb, timeZone: central).spoken.contains("southern horizon"))
149
150 let quito = Place(latitude: -0.18, longitude: -78.47, name: "Quito", kind: .city)
151 #expect(AuroraAnswer(weather: quiet, place: quito, locale: gb, timeZone: central).spoken.contains("Aurora is out of reach this far from the poles."))
152}
153
154@Test func probeAnswer() throws {
155 let a = try ProbeAnswer(probe: .voyager1, at: evening, locale: gb)
156 // Voyager 1 is about 170 AU out in late 2026.
157 #expect(a.fromEarthKm > 24e9 && a.fromEarthKm < 26.5e9, "\(a.fromEarthKm)")
158 #expect(abs(a.lightTime - a.fromEarthKm / 299_792.458) < 1)
159 #expect(a.spoken.hasPrefix("Voyager 1 is 2"))
160 #expect(a.spoken.contains(" billion km from Earth. Its signal takes 2"))
161 #expect(!a.spoken.contains("prediction"))
162 #expect(try ProbeAnswer(probe: .pioneer10, at: evening, locale: gb).spoken.hasSuffix("Contact was lost, so this is a prediction."))
163 #expect(Probe.allCases.count == 5)
164}
165
166@Test func lookUpAnswer() throws {
167 let a = LookUpAnswer(page: try #require(Library.page(id: "ori")))
168 #expect(a.spoken.hasPrefix("Orion: The Hunter. Abbreviation: Ori."))
169 #expect(a.rows.count == 3)
170 let m1 = LookUpAnswer(page: try #require(Library.page(id: "m1")))
171 #expect(m1.spoken.hasPrefix("M1: Crab Nebula, supernova remnant in Taurus."))
172}
173
174@Test func skyTargets() throws {
175 #expect(try #require(Library.page(id: "ori")).skyTarget == Tour.Target(constellation: "Ori"))
176 #expect(try #require(Library.page(body: "jupiter")).skyTarget == Tour.Target(body: "jupiter"))
177 #expect(try #require(Library.page(body: "moon")).skyTarget == Tour.Target(body: "moon"))
178 #expect(try #require(Library.page(id: "sirius")).skyTarget?.hr != nil)
179 #expect(try #require(Library.page(id: "sun")).skyTarget == nil)
180 #expect(try #require(Library.page(body: "pluto")).skyTarget == nil)
181 #expect(try #require(Library.page(id: "m42")).skyTarget == nil)
182 #expect(Library.pages.filter { $0.skyTarget != nil }.count == 88 + 50 + 8)
183}
Packages/MoonriseKit/Tests/SkyEngineTests/GeomagneticTests.swift +15
@@ -19,3 +19,18 @@ import Testing
19 let pole = Observer(latitude: Geomagnetic.poleLatitude, longitude: Geomagnetic.poleLongitude) 19 let pole = Observer(latitude: Geomagnetic.poleLatitude, longitude: Geomagnetic.poleLongitude)
20 #expect(abs(Geomagnetic.latitude(pole) - 90) < 1e-6) 20 #expect(abs(Geomagnetic.latitude(pole) - 90) < 1e-6)
21} 21}
22
23@Test func ovalMatchesServer() {
24 // server/src/aurora.ts: ovalEdge(5) = 56.3, visibleFrom(5) = 50.3.
25 #expect(abs(Geomagnetic.ovalEdge(kp: 5) - 56.3) < 1e-9)
26 #expect(abs(Geomagnetic.visibleFrom(kp: 5) - 50.3) < 1e-9)
27}
28
29@Test func kpNeeded() {
30 let kp = Geomagnetic.kpNeeded(chicago)
31 #expect(kp > 4 && kp < 5.5, "\(kp)")
32 #expect(abs(Geomagnetic.visibleFrom(kp: kp) - Geomagnetic.latitude(chicago)) < 1e-9)
33 #expect(Geomagnetic.kpNeeded(Observer(latitude: -42.88, longitude: 147.33)) < 6)
34 #expect(Geomagnetic.kpNeeded(Observer(latitude: 0, longitude: -80)) > 9)
35 #expect(Geomagnetic.kpNeeded(Observer(latitude: 69.6, longitude: 18.9)) == 0)
36}