krz/moonrise

Night-sky companion for iPhone, Apple Watch and Vision Pro: what's up tonight, the Moon, planets, launches and missions, and a Library of the solar system. SwiftUI app with a Cloudflare Workers backend. astronomy cloudflare-workers ios night-sky space swiftui visionos watchos

Commit 2752d3a6fc

2752d3a6fce33bbbe79815343b5eb471755553ba

parent: 98c962118c

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-18 21:58 UTC

Add guided sky tours

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 @@
1import Library
2import SwiftUI
3
4/// The tours, starting with the one for this month.
5struct 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.
33struct 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 @@
1import Foundation
2
3/// A guided walk around one season's evening sky.
4public 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
40extension 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}