Commit 53de8fc315
Unsigned
Layout: unified · split
App/Sources/Intents/AnswerIntents.swift added +172
| @@ -0,0 +1,172 @@ | ||
| 1 | import Answers | |
| 2 | import AppIntents | |
| 3 | import Feeds | |
| 4 | import Foundation | |
| 5 | import Library | |
| 6 | import SkyEngine | |
| 7 | ||
| 8 | extension 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 | ||
| 20 | enum 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. | |
| 29 | private 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 | ||
| 34 | struct 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 | ||
| 45 | struct 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 | ||
| 56 | enum 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 | ||
| 75 | struct 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 | ||
| 98 | struct 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 | ||
| 110 | struct 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 | ||
| 123 | enum 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 | ||
| 146 | struct 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 | ||
| 160 | struct 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 @@ | ||
| 1 | import Answers | |
| 2 | import SkyRenderer | |
| 3 | import SwiftUI | |
| 4 | ||
| 5 | /// Result view for Siri and Shortcuts, in the Today sheet's style: section label, hairline, label/value grid. | |
| 6 | struct 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 @@ | ||
| 1 | import Answers | |
| 2 | import AppIntents | |
| 3 | import CoreSpotlight | |
| 4 | import Library | |
| 5 | ||
| 6 | /// A Library page for Siri, Shortcuts and Spotlight. | |
| 7 | struct 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 | ||
| 26 | struct 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 | ||
| 42 | extension 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. | |
| 60 | enum 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 @@ | ||
| 1 | import AppIntents | |
| 2 | ||
| 3 | /// Siri phrases. Each must contain the app's name; at most 10 shortcuts. | |
| 4 | struct 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 @@ | ||
| 1 | import Answers | |
| 2 | import AppIntents | |
| 3 | import 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. | |
| 7 | struct 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. | |
| 22 | struct 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 | ||
| 42 | struct 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) | |
| 61 | struct 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 @@ | ||
| 1 | 1 | import ActivityKit |
| 2 | 2 | import Feeds |
| 3 | import Library | |
| 3 | 4 | import SwiftUI |
| 4 | 5 | |
| 5 | 6 | /// Backend base URL from Info.plist (MOONRISE_API_URL build setting); nil when unset. |
| 6 | 7 | let apiBase: URL? = (Bundle.main.object(forInfoDictionaryKey: "MoonriseAPIURL") as? String) |
| 7 | 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 | 11 | @MainActor |
| 11 | 12 | @Observable |
| 12 | 13 | final class Router { |
| 13 | 14 | static let shared = Router() |
| 14 | 15 | var tab = "sky" |
| 15 | 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 | |
| 18 | 25 | struct LaunchesScreen: View { |
App/Sources/LibraryScreen.swift +4
| @@ -55,6 +55,7 @@ private struct TonightPicks: Sendable { | ||
| 55 | 55 | |
| 56 | 56 | struct LibraryScreen: View { |
| 57 | 57 | @State private var query = "" |
| 58 | @State private var router = Router.shared | |
| 58 | 59 | @State private var picks = TonightPicks() |
| 59 | 60 | |
| 60 | 61 | private var rows: [(String, LibrarySection, [LibraryPage])] { |
| @@ -106,6 +107,9 @@ struct LibraryScreen: View { | ||
| 106 | 107 | } |
| 107 | 108 | .foregroundStyle(ink) |
| 108 | 109 | .tint(ink) |
| 110 | .onChange(of: router.libraryQuery, initial: true) { | |
| 111 | if let q = router.libraryQuery { query = q; router.libraryQuery = nil } | |
| 112 | } | |
| 109 | 113 | .task { |
| 110 | 114 | if picks.planet == nil, picks.constellations.isEmpty { |
| 111 | 115 | picks = await Task.detached { TonightPicks.compute() }.value |
App/Sources/MoonriseApp.swift +6
| @@ -1,3 +1,4 @@ | ||
| 1 | import AppIntents | |
| 1 | 2 | import SkyRenderer |
| 2 | 3 | import SwiftUI |
| 3 | 4 | import UIKit |
| @@ -30,6 +31,9 @@ struct MoonriseApp: App { | ||
| 30 | 31 | .fullScreenCover(isPresented: .constant(!onboarded)) { |
| 31 | 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 | 43 | ImageLoader.configureURLCache() |
| 40 | 44 | UNUserNotificationCenter.current().delegate = self |
| 41 | 45 | application.registerForRemoteNotifications() |
| 46 | MoonriseShortcuts.updateAppShortcutParameters() | |
| 47 | Task.detached(priority: .background) { await SpotlightIndexer.indexIfNeeded() } | |
| 42 | 48 | return true |
| 43 | 49 | } |
| 44 | 50 | |
App/Sources/SkyModel.swift +3
| @@ -1,3 +1,4 @@ | ||
| 1 | import Answers | |
| 1 | 2 | import CoreLocation |
| 2 | 3 | import Feeds |
| 3 | 4 | import Foundation |
| @@ -115,6 +116,7 @@ final class SkyModel: NSObject, CLLocationManagerDelegate { | ||
| 115 | 116 | observer = Observer(latitude: lat, longitude: lon) |
| 116 | 117 | placeName = d.string(forKey: SettingsKey.cityName) ?? "Chicago" |
| 117 | 118 | hasLocation = true |
| 119 | Place(latitude: lat, longitude: lon, name: placeName, kind: .city).save(to: d) | |
| 118 | 120 | } else { |
| 119 | 121 | if !hasLocation { placeName = "Chicago (location unavailable)" } |
| 120 | 122 | manager.requestWhenInUseAuthorization() |
| @@ -190,6 +192,7 @@ final class SkyModel: NSObject, CLLocationManagerDelegate { | ||
| 190 | 192 | self.observer = Observer(latitude: c.latitude, longitude: c.longitude) |
| 191 | 193 | self.hasLocation = true |
| 192 | 194 | self.placeName = "Your location" |
| 195 | Place(latitude: c.latitude, longitude: c.longitude, name: self.placeName, kind: .located).save(to: .standard) | |
| 193 | 196 | self.refresh() |
| 194 | 197 | } |
| 195 | 198 | } |
App/Sources/SkyScreen.swift +16 −1
| @@ -24,6 +24,9 @@ struct SkyScreen: View { | ||
| 24 | 24 | @State private var tour: Tour? |
| 25 | 25 | @State private var tourStep = 0 |
| 26 | 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 | 30 | @Environment(\.scenePhase) private var scenePhase |
| 28 | 31 | @State private var libraryPage: LibraryPage? |
| 29 | 32 | @State private var selection: SkyObject? |
| @@ -42,7 +45,7 @@ struct SkyScreen: View { | ||
| 42 | 45 | style.background.ignoresSafeArea() |
| 43 | 46 | if let scene = model.scene { |
| 44 | 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 | 49 | stats.record(d) |
| 47 | 50 | } |
| 48 | 51 | .ignoresSafeArea() |
| @@ -114,6 +117,8 @@ struct SkyScreen: View { | ||
| 114 | 117 | .presentationDetents([.medium, .large]) |
| 115 | 118 | } |
| 116 | 119 | .onChange(of: model.offsetHours) { select(selection) } |
| 120 | .onChange(of: router.skyTarget, initial: true) { showRequestedTarget() } | |
| 121 | .onChange(of: model.scene == nil) { showRequestedTarget() } | |
| 117 | 122 | .onChange(of: onboarded) { if onboarded { model.start() } } |
| 118 | 123 | .onChange(of: night, initial: true) { nightActive = night } |
| 119 | 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 | 192 | private func rescheduleSummary() { |
| 179 | 193 | Task { await DailySummaryNotifications.reschedule(model) } |
| 180 | 194 | } |
| @@ -200,6 +214,7 @@ struct SkyScreen: View { | ||
| 200 | 214 | DragGesture(minimumDistance: 6) |
| 201 | 215 | .onChanged { _ in |
| 202 | 216 | turning?.cancel() |
| 217 | shown = nil | |
| 203 | 218 | if pointer.active { |
| 204 | 219 | azimuth = pointer.direction.azimuth |
| 205 | 220 | altitude = min(max(pointer.direction.altitude, 0), 90) |
App/project.yml +2
| @@ -30,6 +30,8 @@ targets: | ||
| 30 | 30 | product: Library |
| 31 | 31 | - package: MoonriseKit |
| 32 | 32 | product: ObservingLog |
| 33 | - package: MoonriseKit | |
| 34 | product: Answers | |
| 33 | 35 | - target: MoonriseWidgets |
| 34 | 36 | - target: MoonriseWatch |
| 35 | 37 | entitlements: |
Packages/MoonriseKit/Package.swift +3
| @@ -10,6 +10,7 @@ let package = Package( | ||
| 10 | 10 | .library(name: "Feeds", targets: ["Feeds"]), |
| 11 | 11 | .library(name: "Library", targets: ["Library"]), |
| 12 | 12 | .library(name: "ObservingLog", targets: ["ObservingLog"]), |
| 13 | .library(name: "Answers", targets: ["Answers"]), | |
| 13 | 14 | ], |
| 14 | 15 | targets: [ |
| 15 | 16 | // Astronomy Engine v2.1.19 (MIT), https://github.com/cosinekitty/astronomy |
| @@ -27,10 +28,12 @@ let package = Package( | ||
| 27 | 28 | .target(name: "Feeds"), |
| 28 | 29 | .target(name: "ObservingLog"), |
| 29 | 30 | .target(name: "Library", resources: [.copy("Resources/pages"), .copy("Resources/tours"), .copy("Resources/stories")]), |
| 31 | .target(name: "Answers", dependencies: ["SkyEngine", "SkyRenderer", "Feeds", "Library"]), | |
| 30 | 32 | .testTarget(name: "SkyEngineTests", dependencies: ["SkyEngine"]), |
| 31 | 33 | .testTarget(name: "FeedsTests", dependencies: ["Feeds"], resources: [.copy("Fixtures")]), |
| 32 | 34 | .testTarget(name: "ObservingLogTests", dependencies: ["ObservingLog"]), |
| 33 | 35 | .testTarget(name: "LibraryTests", dependencies: ["Library", "SkyRenderer", "SkyEngine"]), |
| 34 | 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 @@ | ||
| 1 | import Feeds | |
| 2 | import Foundation | |
| 3 | import SkyEngine | |
| 4 | ||
| 5 | /// Kp now and over the next day, and whether aurora may be seen from here. | |
| 6 | public 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. | |
| 53 | public 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 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | /// One label and value in an answer's result view. `alert` values are drawn in the accent red. | |
| 4 | public 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. | |
| 17 | struct 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 | ||
| 67 | enum 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 @@ | ||
| 1 | import Foundation | |
| 2 | import 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. | |
| 6 | public 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 | ||
| 56 | extension [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 @@ | ||
| 1 | import Foundation | |
| 2 | import Library | |
| 3 | import SkyEngine | |
| 4 | import SkyRenderer | |
| 5 | ||
| 6 | /// The five deep-space probes in `Spacecraft.all`, by name. | |
| 7 | public enum Probe: String, CaseIterable, Sendable { | |
| 8 | case voyager1 = "Voyager 1", voyager2 = "Voyager 2", pioneer10 = "Pioneer 10", pioneer11 = "Pioneer 11", newHorizons = "New Horizons" | |
| 9 | } | |
| 10 | ||
| 11 | public enum ProbeAnswerError: Error { | |
| 12 | case noData | |
| 13 | } | |
| 14 | ||
| 15 | /// A probe's distance from Earth and the Sun, and the one-way light time. | |
| 16 | public 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. | |
| 47 | public 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Library | |
| 3 | import SkyEngine | |
| 4 | ||
| 5 | /// The Moon's phase now and its next quarter. | |
| 6 | public 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. | |
| 44 | public 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. | |
| 108 | public 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. | |
| 123 | public 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 | ||
| 157 | extension 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 | 8 | public var hr: Int? |
| 9 | 9 | /// Body raw value, e.g. "jupiter". |
| 10 | 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 | 19 | public struct Step: Sendable, Decodable, Hashable { |
Packages/MoonriseKit/Sources/SkyEngine/Geomagnetic.swift +17
| @@ -21,4 +21,21 @@ public enum Geomagnetic { | ||
| 21 | 21 | let b = Int((abs(m) / 5).rounded(.down)) * 5 |
| 22 | 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 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import Answers | |
| 4 | @testable import Feeds | |
| 5 | import Library | |
| 6 | @testable import SkyEngine | |
| 7 | ||
| 8 | private let chicagoPlace = Place(latitude: 41.878, longitude: -87.630, name: "Your location", kind: .located) | |
| 9 | private let central = TimeZone(identifier: "America/Chicago")! | |
| 10 | private let gb = Locale(identifier: "en_GB") | |
| 11 | /// 18:00 CDT, 18 October 2026. | |
| 12 | private 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 | ||
| 77 | private 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 | ||
| 112 | private func launches(_ json: String) throws -> [Launch] { | |
| 113 | try LaunchFeed.decode(Data(#"{"fetchedAt":null,"launches":[\#(json)]}"#.utf8)).launches | |
| 114 | } | |
| 115 | ||
| 116 | private 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 | 19 | let pole = Observer(latitude: Geomagnetic.poleLatitude, longitude: Geomagnetic.poleLongitude) |
| 20 | 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 | } | |