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 | import ActivityKit | 1 | import ActivityKit |
| 2 | import Feeds | 2 | import Feeds |
| 3 | import Library | ||
| 3 | import SwiftUI | 4 | import 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. |
| 6 | let apiBase: URL? = (Bundle.main.object(forInfoDictionaryKey: "MoonriseAPIURL") as? String) | 7 | let 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 |
| 12 | final class Router { | 13 | final 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 | ||
| 18 | struct LaunchesScreen: View { | 25 | struct LaunchesScreen: View { |
App/Sources/LibraryScreen.swift +4
| @@ -55,6 +55,7 @@ private struct TonightPicks: Sendable { | |||
| 55 | 55 | ||
| 56 | struct LibraryScreen: View { | 56 | struct 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 @@ | |||
| 1 | import AppIntents | ||
| 1 | import SkyRenderer | 2 | import SkyRenderer |
| 2 | import SwiftUI | 3 | import SwiftUI |
| 3 | import UIKit | 4 | import 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 @@ | |||
| 1 | import Answers | ||
| 1 | import CoreLocation | 2 | import CoreLocation |
| 2 | import Feeds | 3 | import Feeds |
| 3 | import Foundation | 4 | import 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 @@ | |||
| 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 | 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 @@ | |||
| 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 | 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 | } | ||