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 53de8fc315

53de8fc315c01709faf973934e2ec9db242a9098

parent: 65e90b3d89

Unsigned

cmc <hello@cleberg.net> · 2026-10-20 01:53 UTC

Siri, Shortcuts and Spotlight, phase 1 (!56)

Phase 1 of the Siri/Spotlight design: answers computed in MoonriseKit, App Shortcuts, a Spotlight index of the Library, and hooks to open a page or turn the Sky tab.

**MoonriseKit**
- New `Answers` target (depends on SkyEngine, SkyRenderer, Feeds, Library) with `AnswersTests` (15 tests). `MoonAnswer`, `TonightAnswer`, `PassAnswer`, `LaunchAnswer`, `AuroraAnswer`, `ProbeAnswer`, `LookUpAnswer`: numbers, a `spoken` string and up to four `AnswerRow`s. `Place` is the saved observer.
- `Geomagnetic.ovalEdge(kp:)` and `visibleFrom(kp:)`, ported from `server/src/aurora.ts`, and `kpNeeded(_:)`.
- `Tour.Target` gets a public init; `LibraryPage.skyTarget` maps the Moon, 7 planets, 88 constellations and 50 named stars to it.
- `ProbeAnswer` gives distance from Earth (Spacecraft position minus Earth's heliocentric vector), not from the Sun as the System tab does.

**App** (`App/Sources/Intents/`, app target only)
- Nine App Shortcuts: moon phase, tonight, next pass (ISS/Tiangong/Hubble), next launch, aurora and Kp, probe distance (5 probes), look up, show in sky, search the Library. Every phrase contains the app name.
- Answer intents use `supportedModes = .background` and return `IntentDialog(full:supporting:)` plus `AnswerSnippet`: Today-sheet layout (caption label, hairline, label/value grid, monospaced digits), always dark, red only for G1+, a launch on hold, or a pass rising within 15 minutes.
- `LibraryPageEntity: IndexedEntity` with `EntityStringQuery` over `Library.search`. Suggested entities, and so the names Siri listens for in "Look up …" / "Show … in the sky", are the 146 pages with a sky target. `SpotlightIndexer` indexes all 370 pages in `CSSearchableIndex(name: "library")` when `CFBundleVersion` changes.
- `OpenLibraryPageIntent` (OpenIntent) for Spotlight taps. `SearchLibrarySchemaIntent` adopts `.system.searchInApp` under `@available(iOS 27, *)`.
- `Router` gains `libraryPage` (sheet at the app root), `libraryQuery` (Library search field) and `skyTarget` (SkyScreen aims and highlights once a scene exists; the highlight clears on drag). Pages with no sky position open instead.
- `SkyModel` saves `Place` to `UserDefaults.standard` on every fix and city change. Intents use the saved place, else the chosen city, else Chicago, and name the place in the answer when it is not the device's own position ("For Chicago; open Moonrise to set your location.").

**Decisions on the doc's open questions**
- Deployment target stays iOS 26; the iOS 27 schema type is availability-gated.
- No fix ever: chosen city, else the existing Chicago fallback, named in the answer.
- Result views always use the app's dark style and system font.
- No App Group or new entitlements; intents live in the app target and read `UserDefaults.standard`.
- Mac: nothing Mac-specific.
- `.system.open` is not adopted: the schema needs iOS 27, and the build rejects a second, iOS 27-only OpenIntent for the same entity alongside the iOS 26 one.
- `Spacecraft` stays in SkyRenderer; `Answers` links SkyRenderer (and so SwiftUI) rather than moving it.
- Network failures say "Moonrise couldn't reach the network"; no disk cache of last values.

**Verified**
- `swift test` in Packages/MoonriseKit passes; `npm test` in server/ passes.
- App builds for the iOS simulator (Xcode 27, iOS 27 SDK); `Metadata.appintents` lists the 10 intents, the entity, both enums and 9 shortcuts.

**Not verified**
- Nothing run on a simulator or device: no simulator could be booted (process limit). Siri phrases, phrase collisions with the word "moonrise", result-view layout in the Siri card, Spotlight results and taps, cold launch into "Show in sky", and the iOS 27 search schema are all untested.
- The root Library sheet does not present while another sheet (e.g. the Today sheet) is open.
- Mac (Designed for iPad) Shortcuts/Spotlight behaviour.

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}