Commit 2752d3a6fc
Verified · cmc
Layout: unified · split
App/Sources/SkyScreen.swift +46 −4
| @@ -20,6 +20,9 @@ struct SkyScreen: View { | |||
| 20 | @AppStorage(SettingsKey.nightActive) private var nightActive = false | 20 | @AppStorage(SettingsKey.nightActive) private var nightActive = false |
| 21 | @State private var showToday = false | 21 | @State private var showToday = false |
| 22 | @State private var showYear = false | 22 | @State private var showYear = false |
| 23 | @State private var showTours = false | ||
| 24 | @State private var tour: Tour? | ||
| 25 | @State private var tourStep = 0 | ||
| 23 | @Environment(\.scenePhase) private var scenePhase | 26 | @Environment(\.scenePhase) private var scenePhase |
| 24 | @State private var libraryPage: LibraryPage? | 27 | @State private var libraryPage: LibraryPage? |
| 25 | @State private var selection: SkyObject? | 28 | @State private var selection: SkyObject? |
| @@ -58,7 +61,10 @@ struct SkyScreen: View { | |||
| 58 | header | 61 | header |
| 59 | if let selection { card(for: selection).padding(.horizontal, 16).padding(.top, 8) } | 62 | if let selection { card(for: selection).padding(.horizontal, 16).padding(.top, 8) } |
| 60 | Spacer() | 63 | Spacer() |
| 61 | if model.historicalYear == nil { | 64 | if let tour { |
| 65 | TourCard(tour: tour, step: $tourStep, belowHorizon: (tourTarget(tour.steps[tourStep].target)?.altitude ?? 0) < 0, | ||
| 66 | ink: ink, background: style.ground, onOpen: { libraryPage = $0 }, onEnd: { self.tour = nil }) | ||
| 67 | } else if model.historicalYear == nil { | ||
| 62 | TodayBar(model: model, ink: ink, background: style.ground) { showToday = true } | 68 | TodayBar(model: model, ink: ink, background: style.ground) { showToday = true } |
| 63 | } | 69 | } |
| 64 | } | 70 | } |
| @@ -77,6 +83,10 @@ struct SkyScreen: View { | |||
| 77 | .preferredColorScheme(.dark) | 83 | .preferredColorScheme(.dark) |
| 78 | .tint(.blue) | 84 | .tint(.blue) |
| 79 | } | 85 | } |
| 86 | .sheet(isPresented: $showTours) { | ||
| 87 | TourPicker { tour = $0; tourStep = 0; aim(at: $0.steps[0].target) }.preferredColorScheme(.dark).tint(.blue) | ||
| 88 | } | ||
| 89 | .onChange(of: tourStep) { if let tour { aim(at: tour.steps[tourStep].target) } } | ||
| 80 | .sheet(isPresented: $showYear) { | 90 | .sheet(isPresented: $showYear) { |
| 81 | YearSheet(year: $model.historicalYear).preferredColorScheme(.dark).tint(.blue) | 91 | YearSheet(year: $model.historicalYear).preferredColorScheme(.dark).tint(.blue) |
| 82 | } | 92 | } |
| @@ -107,6 +117,35 @@ struct SkyScreen: View { | |||
| 107 | } | 117 | } |
| 108 | } | 118 | } |
| 109 | 119 | ||
| 120 | /// Where a tour target is in the current scene; constellations use the mean direction of their stars. | ||
| 121 | private func tourTarget(_ t: Tour.Target) -> Horizontal? { | ||
| 122 | guard let scene = model.scene else { return nil } | ||
| 123 | if let hr = t.hr { return scene.starsByHR[hr] } | ||
| 124 | if let b = t.body { return scene.bodies.first { $0.body.rawValue == b }?.position } | ||
| 125 | guard let c = t.constellation, let figure = Catalog.constellations.first(where: { $0.id == c }) else { return nil } | ||
| 126 | let positions = Set(figure.lines.joined()).compactMap { scene.starsByHR[$0] } | ||
| 127 | guard !positions.isEmpty else { return nil } | ||
| 128 | var v = SIMD3<Double>.zero | ||
| 129 | for h in positions { | ||
| 130 | let alt = h.altitude * .pi / 180, az = h.azimuth * .pi / 180 | ||
| 131 | v += SIMD3(cos(alt) * sin(az), cos(alt) * cos(az), sin(alt)) | ||
| 132 | } | ||
| 133 | var az = atan2(v.x, v.y) * 180 / .pi | ||
| 134 | if az < 0 { az += 360 } | ||
| 135 | return Horizontal(altitude: asin(v.z / (v * v).sum().squareRoot()) * 180 / .pi, azimuth: az) | ||
| 136 | } | ||
| 137 | |||
| 138 | /// Turns the view to a tour target, keeping it at least 10° up so the horizon stays in view. | ||
| 139 | private func aim(at t: Tour.Target) { | ||
| 140 | guard let h = tourTarget(t) else { return } | ||
| 141 | pointer.stop() | ||
| 142 | withAnimation(.easeInOut(duration: 0.6)) { | ||
| 143 | azimuth = h.azimuth | ||
| 144 | altitude = max(h.altitude, 10) | ||
| 145 | fieldOfView = 80 | ||
| 146 | } | ||
| 147 | } | ||
| 148 | |||
| 110 | private func rescheduleSummary() { | 149 | private func rescheduleSummary() { |
| 111 | Task { await DailySummaryNotifications.reschedule(model) } | 150 | Task { await DailySummaryNotifications.reschedule(model) } |
| 112 | } | 151 | } |
| @@ -184,12 +223,15 @@ struct SkyScreen: View { | |||
| 184 | } | 223 | } |
| 185 | .accessibilityLabel(pointer.active ? "Stop following the phone" : "Point the phone at the sky") | 224 | .accessibilityLabel(pointer.active ? "Stop following the phone" : "Point the phone at the sky") |
| 186 | } | 225 | } |
| 187 | Button { showYear = true } label: { | 226 | Menu { |
| 188 | Image(systemName: "hourglass") | 227 | Button { showTours = true } label: { Label("Guided tours", systemImage: "figure.walk") } |
| 228 | Button { showYear = true } label: { Label("Sky in another year", systemImage: "hourglass") } | ||
| 229 | } label: { | ||
| 230 | Image(systemName: "ellipsis.circle") | ||
| 189 | .font(.body) | 231 | .font(.body) |
| 190 | .frame(width: 44, height: 44) | 232 | .frame(width: 44, height: 44) |
| 191 | } | 233 | } |
| 192 | .accessibilityLabel("Sky in another year") | 234 | .accessibilityLabel("More") |
| 193 | Button { showLog = true } label: { | 235 | Button { showLog = true } label: { |
| 194 | Image(systemName: "book.closed") | 236 | Image(systemName: "book.closed") |
| 195 | .font(.body) | 237 | .font(.body) |
App/Sources/TourViews.swift added +76
| @@ -0,0 +1,76 @@ | |||
| 1 | import Library | ||
| 2 | import SwiftUI | ||
| 3 | |||
| 4 | /// The tours, starting with the one for this month. | ||
| 5 | struct TourPicker: View { | ||
| 6 | var onPick: (Tour) -> Void | ||
| 7 | @Environment(\.dismiss) private var dismiss | ||
| 8 | |||
| 9 | var body: some View { | ||
| 10 | NavigationStack { | ||
| 11 | List(Tour.ordered(month: Calendar.current.component(.month, from: .now))) { tour in | ||
| 12 | Button { | ||
| 13 | onPick(tour) | ||
| 14 | dismiss() | ||
| 15 | } label: { | ||
| 16 | VStack(alignment: .leading, spacing: 4) { | ||
| 17 | Text(tour.title).font(.body.weight(.medium)) | ||
| 18 | Text(tour.intro).font(.footnote).foregroundStyle(.secondary) | ||
| 19 | Text("\(tour.steps.count) stops").font(.caption).foregroundStyle(.secondary) | ||
| 20 | } | ||
| 21 | .padding(.vertical, 4) | ||
| 22 | } | ||
| 23 | .buttonStyle(.plain) | ||
| 24 | } | ||
| 25 | .navigationTitle("Guided tours") | ||
| 26 | .navigationBarTitleDisplayMode(.inline) | ||
| 27 | .toolbar { ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } } } | ||
| 28 | } | ||
| 29 | } | ||
| 30 | } | ||
| 31 | |||
| 32 | /// One stop of a tour, with back, next and a link to the Library page. | ||
| 33 | struct TourCard: View { | ||
| 34 | var tour: Tour | ||
| 35 | @Binding var step: Int | ||
| 36 | /// Whether the target is below the horizon at the time shown. | ||
| 37 | var belowHorizon: Bool | ||
| 38 | var ink: Color | ||
| 39 | var background: Color | ||
| 40 | var onOpen: (LibraryPage) -> Void | ||
| 41 | var onEnd: () -> Void | ||
| 42 | |||
| 43 | var body: some View { | ||
| 44 | let s = tour.steps[step] | ||
| 45 | VStack(alignment: .leading, spacing: 8) { | ||
| 46 | HStack { | ||
| 47 | Text("\(tour.title) · \(step + 1) of \(tour.steps.count)").font(.caption2).dimmed(0.7) | ||
| 48 | Spacer() | ||
| 49 | Button("End tour", action: onEnd).font(.caption.weight(.medium)) | ||
| 50 | } | ||
| 51 | Text(s.title).font(.headline) | ||
| 52 | Text(s.text).font(.subheadline).fixedSize(horizontal: false, vertical: true) | ||
| 53 | if belowHorizon { | ||
| 54 | Text("Below your horizon at this time. Try later in the evening or another month.").font(.footnote).dimmed(0.7) | ||
| 55 | } | ||
| 56 | HStack { | ||
| 57 | Button { step -= 1 } label: { Label("Back", systemImage: "chevron.left") } | ||
| 58 | .disabled(step == 0) | ||
| 59 | Spacer() | ||
| 60 | if let page = s.libraryPage { | ||
| 61 | Button("Read more") { onOpen(page) } | ||
| 62 | Spacer() | ||
| 63 | } | ||
| 64 | Button { step += 1 } label: { Label("Next", systemImage: "chevron.right").labelStyle(.titleAndIcon) } | ||
| 65 | .disabled(step == tour.steps.count - 1) | ||
| 66 | } | ||
| 67 | .font(.subheadline.weight(.medium)) | ||
| 68 | .padding(.top, 2) | ||
| 69 | } | ||
| 70 | .padding(16) | ||
| 71 | .background(background.opacity(0.92), in: RoundedRectangle(cornerRadius: 16)) | ||
| 72 | .foregroundStyle(ink) | ||
| 73 | .padding(.horizontal, 12) | ||
| 74 | .padding(.bottom, 8) | ||
| 75 | } | ||
| 76 | } | ||
PLAN.org +2 −1
| @@ -414,7 +414,8 @@ Sky, System, Sun, Launches, Library core (above). | |||
| 414 | - Historical sky for any date and place, with precession. Done: 3000 BCE | 414 | - Historical sky for any date and place, with precession. Done: 3000 BCE |
| 415 | to 3000 CE, with BSC5 proper motions and the pole star of the year. | 415 | to 3000 CE, with BSC5 proper motions and the pole star of the year. |
| 416 | - 3D planets from USGS maps; immersive sky and orrery on Vision Pro. | 416 | - 3D planets from USGS maps; immersive sky and orrery on Vision Pro. |
| 417 | - Guided seasonal sky tours and mission stories. | 417 | - Guided seasonal sky tours and mission stories. Tours done: four, by |
| 418 | months, 5 to 8 stops each, in Library/Resources/tours. | ||
| 418 | - Live coverage of major events (e.g. an Artemis landing) on the orrery | 419 | - Live coverage of major events (e.g. an Artemis landing) on the orrery |
| 419 | with a Live Activity. | 420 | with a Live Activity. |
| 420 | - Maybe: smart-telescope pointing (Seestar and similar). | 421 | - Maybe: smart-telescope pointing (Seestar and similar). |
Packages/MoonriseKit/Package.swift +1 −1
| @@ -26,7 +26,7 @@ let package = Package( | |||
| 26 | ), | 26 | ), |
| 27 | .target(name: "Feeds"), | 27 | .target(name: "Feeds"), |
| 28 | .target(name: "ObservingLog"), | 28 | .target(name: "ObservingLog"), |
| 29 | .target(name: "Library", resources: [.copy("Resources/pages")]), | 29 | .target(name: "Library", resources: [.copy("Resources/pages"), .copy("Resources/tours")]), |
| 30 | .testTarget(name: "SkyEngineTests", dependencies: ["SkyEngine"]), | 30 | .testTarget(name: "SkyEngineTests", dependencies: ["SkyEngine"]), |
| 31 | .testTarget(name: "FeedsTests", dependencies: ["Feeds"], resources: [.copy("Fixtures")]), | 31 | .testTarget(name: "FeedsTests", dependencies: ["Feeds"], resources: [.copy("Fixtures")]), |
| 32 | .testTarget(name: "ObservingLogTests", dependencies: ["ObservingLog"]), | 32 | .testTarget(name: "ObservingLogTests", dependencies: ["ObservingLog"]), |
Packages/MoonriseKit/Sources/Library/Resources/tours/tours.json added +220
| @@ -0,0 +1,220 @@ | |||
| 1 | [ | ||
| 2 | { | ||
| 3 | "id": "winter", | ||
| 4 | "title": "December to February evenings", | ||
| 5 | "months": [ | ||
| 6 | 12, | ||
| 7 | 1, | ||
| 8 | 2 | ||
| 9 | ], | ||
| 10 | "intro": "The brightest part of the year's sky, around Orion, seen from both hemispheres. Directions are for mid-northern latitudes; from the south, the figures appear upside down.", | ||
| 11 | "steps": [ | ||
| 12 | { | ||
| 13 | "title": "Orion", | ||
| 14 | "target": { | ||
| 15 | "constellation": "Ori" | ||
| 16 | }, | ||
| 17 | "text": "Start with the three stars of Orion's belt, in a short straight line. The hunter's shoulders are above the belt and his knees below it." | ||
| 18 | }, | ||
| 19 | { | ||
| 20 | "title": "Betelgeuse", | ||
| 21 | "target": { | ||
| 22 | "hr": 2061 | ||
| 23 | }, | ||
| 24 | "text": "Orion's upper-left shoulder is Betelgeuse, a red supergiant about 500 light-years away. Its colour is plain to the eye next to the blue-white stars around it." | ||
| 25 | }, | ||
| 26 | { | ||
| 27 | "title": "Rigel", | ||
| 28 | "target": { | ||
| 29 | "hr": 1713 | ||
| 30 | }, | ||
| 31 | "text": "Diagonally opposite, at Orion's lower right, is Rigel, a blue supergiant about 860 light-years away." | ||
| 32 | }, | ||
| 33 | { | ||
| 34 | "title": "Sirius", | ||
| 35 | "target": { | ||
| 36 | "hr": 2491 | ||
| 37 | }, | ||
| 38 | "text": "Follow the belt down and to the left to Sirius, the brightest star in the night sky, only 8.6 light-years away." | ||
| 39 | }, | ||
| 40 | { | ||
| 41 | "title": "Aldebaran and the Hyades", | ||
| 42 | "target": { | ||
| 43 | "hr": 1457 | ||
| 44 | }, | ||
| 45 | "text": "Follow the belt the other way, up and to the right, to orange Aldebaran. It sits in front of the Hyades, a V-shaped cluster that forms the face of Taurus." | ||
| 46 | }, | ||
| 47 | { | ||
| 48 | "title": "The Pleiades", | ||
| 49 | "target": { | ||
| 50 | "constellation": "Tau" | ||
| 51 | }, | ||
| 52 | "page": "m45", | ||
| 53 | "text": "Keep going past Aldebaran to the Pleiades, a small, tight cluster. Most people see six or seven stars; binoculars show dozens." | ||
| 54 | }, | ||
| 55 | { | ||
| 56 | "title": "Castor and Pollux", | ||
| 57 | "target": { | ||
| 58 | "hr": 2990 | ||
| 59 | }, | ||
| 60 | "text": "Up and to the left of Orion are the twin stars of Gemini. Pollux, the brighter and more orange of the two, is 33.8 light-years away." | ||
| 61 | }, | ||
| 62 | { | ||
| 63 | "title": "Capella", | ||
| 64 | "target": { | ||
| 65 | "hr": 1708 | ||
| 66 | }, | ||
| 67 | "text": "High overhead is Capella in Auriga, a yellow star 42.8 light-years away and the sixth brightest in the sky." | ||
| 68 | } | ||
| 69 | ] | ||
| 70 | }, | ||
| 71 | { | ||
| 72 | "id": "spring", | ||
| 73 | "title": "March to May evenings", | ||
| 74 | "months": [ | ||
| 75 | 3, | ||
| 76 | 4, | ||
| 77 | 5 | ||
| 78 | ], | ||
| 79 | "intro": "From the Big Dipper to two of the brightest stars of these months. The Dipper is low or hidden from far southern latitudes.", | ||
| 80 | "steps": [ | ||
| 81 | { | ||
| 82 | "title": "The Big Dipper", | ||
| 83 | "target": { | ||
| 84 | "constellation": "UMa" | ||
| 85 | }, | ||
| 86 | "text": "High in the north is the Big Dipper, seven stars in the shape of a ladle, part of Ursa Major." | ||
| 87 | }, | ||
| 88 | { | ||
| 89 | "title": "Polaris", | ||
| 90 | "target": { | ||
| 91 | "hr": 424 | ||
| 92 | }, | ||
| 93 | "text": "The two stars at the end of the Dipper's bowl point to Polaris, the North Star, about five times their separation away. It stays nearly still while the sky turns around it." | ||
| 94 | }, | ||
| 95 | { | ||
| 96 | "title": "Arcturus", | ||
| 97 | "target": { | ||
| 98 | "hr": 5340 | ||
| 99 | }, | ||
| 100 | "text": "Follow the curve of the Dipper's handle away from the bowl, an arc to Arcturus. This orange giant is 36.7 light-years away." | ||
| 101 | }, | ||
| 102 | { | ||
| 103 | "title": "Spica", | ||
| 104 | "target": { | ||
| 105 | "hr": 5056 | ||
| 106 | }, | ||
| 107 | "text": "Continue the curve, a spike to Spica in Virgo, a blue-white star about 250 light-years away." | ||
| 108 | }, | ||
| 109 | { | ||
| 110 | "title": "Leo and Regulus", | ||
| 111 | "target": { | ||
| 112 | "hr": 3982 | ||
| 113 | }, | ||
| 114 | "text": "To the west is Leo. A backwards question mark of stars, the Sickle, ends in Regulus, 79.3 light-years away." | ||
| 115 | } | ||
| 116 | ] | ||
| 117 | }, | ||
| 118 | { | ||
| 119 | "id": "summer", | ||
| 120 | "title": "June to August evenings", | ||
| 121 | "months": [ | ||
| 122 | 6, | ||
| 123 | 7, | ||
| 124 | 8 | ||
| 125 | ], | ||
| 126 | "intro": "The Summer Triangle and the Milky Way's brightest part, which passes nearly overhead from the southern hemisphere. Directions are for mid-northern latitudes.", | ||
| 127 | "steps": [ | ||
| 128 | { | ||
| 129 | "title": "Vega", | ||
| 130 | "target": { | ||
| 131 | "hr": 7001 | ||
| 132 | }, | ||
| 133 | "text": "Nearly overhead is Vega in Lyra, 25 light-years away and the brightest star of the Summer Triangle." | ||
| 134 | }, | ||
| 135 | { | ||
| 136 | "title": "Deneb", | ||
| 137 | "target": { | ||
| 138 | "hr": 7924 | ||
| 139 | }, | ||
| 140 | "text": "To the east of Vega is Deneb, at the tail of Cygnus. It looks fainter than Vega but is far more luminous, about 1,400 light-years away." | ||
| 141 | }, | ||
| 142 | { | ||
| 143 | "title": "Altair", | ||
| 144 | "target": { | ||
| 145 | "hr": 7557 | ||
| 146 | }, | ||
| 147 | "text": "South of the two is Altair in Aquila, 16.7 light-years away. Vega, Deneb and Altair make the Summer Triangle." | ||
| 148 | }, | ||
| 149 | { | ||
| 150 | "title": "Cygnus", | ||
| 151 | "target": { | ||
| 152 | "constellation": "Cyg" | ||
| 153 | }, | ||
| 154 | "text": "Cygnus, the swan, flies down the Milky Way with Deneb at its tail. Its brightest stars are also called the Northern Cross." | ||
| 155 | }, | ||
| 156 | { | ||
| 157 | "title": "Antares", | ||
| 158 | "target": { | ||
| 159 | "hr": 6134 | ||
| 160 | }, | ||
| 161 | "text": "Low in the south is Antares, the red heart of Scorpius, a supergiant about 550 light-years away." | ||
| 162 | }, | ||
| 163 | { | ||
| 164 | "title": "Sagittarius", | ||
| 165 | "target": { | ||
| 166 | "constellation": "Sgr" | ||
| 167 | }, | ||
| 168 | "text": "East of Scorpius is the teapot of Sagittarius. The centre of our galaxy lies in this direction, behind its spout." | ||
| 169 | } | ||
| 170 | ] | ||
| 171 | }, | ||
| 172 | { | ||
| 173 | "id": "autumn", | ||
| 174 | "title": "September to November evenings", | ||
| 175 | "months": [ | ||
| 176 | 9, | ||
| 177 | 10, | ||
| 178 | 11 | ||
| 179 | ], | ||
| 180 | "intro": "A quieter sky with the nearest large galaxy. Directions are for mid-northern latitudes.", | ||
| 181 | "steps": [ | ||
| 182 | { | ||
| 183 | "title": "The Great Square", | ||
| 184 | "target": { | ||
| 185 | "constellation": "Peg" | ||
| 186 | }, | ||
| 187 | "text": "High in the south is the Great Square of Pegasus, four stars of similar brightness in a large square with few stars inside it." | ||
| 188 | }, | ||
| 189 | { | ||
| 190 | "title": "Andromeda", | ||
| 191 | "target": { | ||
| 192 | "constellation": "And" | ||
| 193 | }, | ||
| 194 | "page": "m31", | ||
| 195 | "text": "From the Square's north-east corner, a line of stars runs to the east: Andromeda. Off this line is the Andromeda Galaxy, M31, visible to the eye from a dark site as a faint smudge, 2.5 million light-years away." | ||
| 196 | }, | ||
| 197 | { | ||
| 198 | "title": "Cassiopeia", | ||
| 199 | "target": { | ||
| 200 | "constellation": "Cas" | ||
| 201 | }, | ||
| 202 | "text": "North of Andromeda is the W of Cassiopeia, opposite the Big Dipper across Polaris." | ||
| 203 | }, | ||
| 204 | { | ||
| 205 | "title": "Algol", | ||
| 206 | "target": { | ||
| 207 | "hr": 936 | ||
| 208 | }, | ||
| 209 | "text": "In Perseus, east of Andromeda, is Algol, an eclipsing binary. Every 2.87 days it fades for a few hours as the dimmer star passes in front of the brighter one." | ||
| 210 | }, | ||
| 211 | { | ||
| 212 | "title": "Fomalhaut", | ||
| 213 | "target": { | ||
| 214 | "hr": 8728 | ||
| 215 | }, | ||
| 216 | "text": "Low in the south is Fomalhaut, the only bright star in its part of the sky, 25.1 light-years away." | ||
| 217 | } | ||
| 218 | ] | ||
| 219 | } | ||
| 220 | ]\ No newline at end of file | ||
Packages/MoonriseKit/Sources/Library/Tours.swift added +49
| @@ -0,0 +1,49 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// A guided walk around one season's evening sky. | ||
| 4 | public struct Tour: Sendable, Decodable, Identifiable { | ||
| 5 | public struct Target: Sendable, Decodable, Hashable { | ||
| 6 | /// IAU abbreviation, e.g. "Ori". | ||
| 7 | public var constellation: String? | ||
| 8 | public var hr: Int? | ||
| 9 | /// Body raw value, e.g. "jupiter". | ||
| 10 | public var body: String? | ||
| 11 | } | ||
| 12 | |||
| 13 | public struct Step: Sendable, Decodable, Hashable { | ||
| 14 | public var title: String | ||
| 15 | public var target: Target | ||
| 16 | public var text: String | ||
| 17 | /// Library page to open, when it is not the target's own page. | ||
| 18 | public var page: String? | ||
| 19 | } | ||
| 20 | |||
| 21 | public var id: String | ||
| 22 | public var title: String | ||
| 23 | /// Months (1–12) when the tour's stars are up in the evening (the same in both hemispheres). | ||
| 24 | public var months: [Int] | ||
| 25 | public var intro: String | ||
| 26 | public var steps: [Step] | ||
| 27 | |||
| 28 | public static let all: [Tour] = { | ||
| 29 | let url = Bundle.module.url(forResource: "tours", withExtension: "json", subdirectory: "tours")! | ||
| 30 | return try! JSONDecoder().decode([Tour].self, from: Data(contentsOf: url)) | ||
| 31 | }() | ||
| 32 | |||
| 33 | /// Tours in order from the one for `month` onwards. | ||
| 34 | public static func ordered(month: Int) -> [Tour] { | ||
| 35 | let start = all.firstIndex { $0.months.contains(month) } ?? 0 | ||
| 36 | return Array(all[start...] + all[..<start]) | ||
| 37 | } | ||
| 38 | } | ||
| 39 | |||
| 40 | extension Tour.Step { | ||
| 41 | /// The step's own Library page, else the target's. | ||
| 42 | public var libraryPage: LibraryPage? { | ||
| 43 | if let page { return Library.page(id: page) } | ||
| 44 | if let hr = target.hr { return Library.page(hr: hr) } | ||
| 45 | if let c = target.constellation { return Library.page(constellation: c) } | ||
| 46 | if let b = target.body { return Library.page(body: b) } | ||
| 47 | return nil | ||
| 48 | } | ||
| 49 | } | ||
Packages/MoonriseKit/Tests/LibraryTests/LibraryTests.swift +23
| @@ -66,3 +66,26 @@ func linksPointAtThingsTheAppKnows(_ p: LibraryPage) { | |||
| 66 | #expect(Library.formatSize(100_000 * 9.4607e15) == "100,000 light-years") | 66 | #expect(Library.formatSize(100_000 * 9.4607e15) == "100,000 light-years") |
| 67 | #expect(Library.formatSize(93e9 * 9.4607e15) == "93 billion light-years") | 67 | #expect(Library.formatSize(93e9 * 9.4607e15) == "93 billion light-years") |
| 68 | } | 68 | } |
| 69 | |||
| 70 | @Test func toursPointAtKnownThings() { | ||
| 71 | #expect(Tour.all.count == 4) | ||
| 72 | let constellations = Set(Catalog.constellations.map(\.id)) | ||
| 73 | let stars = Set(Catalog.stars.map(\.hr)) | ||
| 74 | for tour in Tour.all { | ||
| 75 | #expect(!tour.steps.isEmpty) | ||
| 76 | for step in tour.steps { | ||
| 77 | let t = step.target | ||
| 78 | #expect([t.constellation != nil, t.hr != nil, t.body != nil].filter { $0 }.count == 1, "\(tour.id) \(step.title)") | ||
| 79 | if let c = t.constellation { #expect(constellations.contains(c), "\(c)") } | ||
| 80 | if let hr = t.hr { #expect(stars.contains(hr), "\(hr)") } | ||
| 81 | if let p = step.page { #expect(Library.page(id: p) != nil, "\(p)") } | ||
| 82 | } | ||
| 83 | } | ||
| 84 | #expect(Set(Tour.all.flatMap(\.months)) == Set(1...12)) | ||
| 85 | } | ||
| 86 | |||
| 87 | @Test func toursStartWithTheSeason() { | ||
| 88 | #expect(Tour.ordered(month: 1).first?.id == "winter") | ||
| 89 | #expect(Tour.ordered(month: 7).first?.id == "summer") | ||
| 90 | #expect(Tour.ordered(month: 11).map(\.id) == ["autumn", "winter", "spring", "summer"]) | ||
| 91 | } | ||