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 ae45cdb97e

ae45cdb97eb88a1ba56811d596a89d33b62ed6df

parent: b11d24d3fd

Unsigned

cmc <hello@cleberg.net> · 2026-10-18 22:02 UTC

v3: Mission stories (!38)

Library mission page › Follow the mission: the mission in 4 to 6 dated chapters on the orrery. Moving between chapters runs the date over about 1.5 s so the craft travels; the story's craft is drawn in full and the others faint.

- Stories for Voyager 1 and 2, Pioneer 10 and 11, New Horizons (the craft with Horizons trajectories in the app), in Library/Resources/stories. Dates match the mission pages.
- =OrreryView= gains =highlight=.
- Tests: each story's craft and page exist and match, chapters are in order, and every chapter after launch is within the trajectory data.

Review: the chapter text is new content; please read it.

Layout: unified · split

App/Sources/LibraryScreen.swift +6
@@ -98,6 +98,12 @@ struct LibraryPageView: View {
9898 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 10).padding(.bottom, 16)
9999
100100 PageDrawing(page: page, ink: ink, accent: accent)
101 if let story = MissionStory.story(page: page.id) {
102 NavigationLink { StoryScreen(story: story) } label: {
103 Label("Follow the mission", systemImage: "point.topleft.down.to.point.bottomright.curvepath").font(.subheadline)
104 }
105 .padding(.top, 10)
106 }
101107
102108 facts.padding(.top, 16)
103109
App/Sources/StoryScreen.swift added +70
@@ -0,0 +1,70 @@
1import Library
2import SkyRenderer
3import SwiftUI
4
5/// A mission's chapters on the orrery. Moving between chapters runs the clock so the craft travels.
6struct StoryScreen: View {
7 var story: MissionStory
8 @State private var chapter = 0
9 @State private var date: Date
10 @State private var travel: Task<Void, Never>?
11
12 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
13 private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
14
15 init(story: MissionStory) {
16 self.story = story
17 _date = State(initialValue: story.chapters[0].day)
18 }
19
20 var body: some View {
21 let c = story.chapters[chapter]
22 ScrollView {
23 VStack(alignment: .leading, spacing: 12) {
24 OrreryView(date: date, ink: ink, accent: accent, highlight: story.spacecraft)
25 .dynamicTypeSize(...DynamicTypeSize.xxLarge)
26 .accessibilityHidden(true)
27 Text(date.formatted(date: .long, time: .omitted)).font(.subheadline.monospacedDigit()).dimmed(0.7)
28 Text(c.title).font(.title2.weight(.medium))
29 Text(c.text).font(.body).fixedSize(horizontal: false, vertical: true)
30 HStack {
31 Button { go(to: chapter - 1) } label: { Label("Back", systemImage: "chevron.left") }
32 .disabled(chapter == 0)
33 Spacer()
34 Text("\(chapter + 1) of \(story.chapters.count)").font(.footnote).dimmed(0.6)
35 Spacer()
36 Button { go(to: chapter + 1) } label: { Label("Next", systemImage: "chevron.right").labelStyle(.titleAndIcon) }
37 .disabled(chapter == story.chapters.count - 1)
38 }
39 .font(.subheadline.weight(.medium))
40 .padding(.top, 8)
41 Text("Positions: NASA/JPL Horizons.").font(.caption2).dimmed(0.5).padding(.top, 8)
42 }
43 .padding(20)
44 .readableWidth()
45 }
46 .foregroundStyle(ink)
47 .tint(accent)
48 .background(Color.black.ignoresSafeArea())
49 .navigationTitle(story.spacecraft)
50 .navigationBarTitleDisplayMode(.inline)
51 .onDisappear { travel?.cancel() }
52 }
53
54 /// Moves to chapter `i`, running the date there over about 1.5 s.
55 private func go(to i: Int) {
56 let from = date, to = story.chapters[i].day
57 chapter = i
58 travel?.cancel()
59 travel = Task {
60 let frames = 45
61 for f in 1...frames {
62 try? await Task.sleep(for: .milliseconds(33))
63 if Task.isCancelled { return }
64 let t = Double(f) / Double(frames)
65 let eased = t * t * (3 - 2 * t)
66 date = from.addingTimeInterval(to.timeIntervalSince(from) * eased)
67 }
68 }
69 }
70}
PLAN.org +2 −1
@@ -415,7 +415,8 @@ Sky, System, Sun, Launches, Library core (above).
415415 to 3000 CE, with BSC5 proper motions and the pole star of the year.
416416- 3D planets from USGS maps; immersive sky and orrery on Vision Pro.
417417- Guided seasonal sky tours and mission stories. Tours done: four, by
418 months, 5 to 8 stops each, in Library/Resources/tours.
418 months, 5 to 8 stops each, in Library/Resources/tours. Mission stories
419 done for Voyager 1 and 2, Pioneer 10 and 11 and New Horizons, on the orrery.
419420- Live coverage of major events (e.g. an Artemis landing) on the orrery
420421 with a Live Activity.
421422- Maybe: smart-telescope pointing (Seestar and similar).
Packages/MoonriseKit/Package.swift +1 −1
@@ -26,7 +26,7 @@ let package = Package(
2626 ),
2727 .target(name: "Feeds"),
2828 .target(name: "ObservingLog"),
29 .target(name: "Library", resources: [.copy("Resources/pages"), .copy("Resources/tours")]),
29 .target(name: "Library", resources: [.copy("Resources/pages"), .copy("Resources/tours"), .copy("Resources/stories")]),
3030 .testTarget(name: "SkyEngineTests", dependencies: ["SkyEngine"]),
3131 .testTarget(name: "FeedsTests", dependencies: ["Feeds"], resources: [.copy("Fixtures")]),
3232 .testTarget(name: "ObservingLogTests", dependencies: ["ObservingLog"]),
Packages/MoonriseKit/Sources/Library/Resources/stories/stories.json added +51
@@ -0,0 +1,51 @@
1[
2 {
3 "spacecraft": "Voyager 2", "page": "voyager-2",
4 "chapters": [
5 {"date": "1977-08-20", "title": "Launch", "text": "Voyager 2 launched first, sixteen days before Voyager 1, on a slower path that could reach all four giant planets."},
6 {"date": "1979-07-09", "title": "Jupiter", "text": "It passed Jupiter four months after Voyager 1, and Jupiter's gravity bent its path towards Saturn."},
7 {"date": "1981-08-25", "title": "Saturn", "text": "At Saturn the planets were lined up for one more assist, a chance that comes about once every 175 years."},
8 {"date": "1986-01-24", "title": "Uranus", "text": "The first and still the only spacecraft to visit Uranus. It found ten new moons and two new rings."},
9 {"date": "1989-08-25", "title": "Neptune", "text": "The only visit to Neptune so far, passing about 4,950 km above its north pole and then close to its moon Triton."},
10 {"date": "2018-11-05", "title": "Interstellar space", "text": "Voyager 2 crossed the heliopause, the edge of the Sun's bubble of solar wind, six years after Voyager 1."}
11 ]
12 },
13 {
14 "spacecraft": "Voyager 1", "page": "voyager-1",
15 "chapters": [
16 {"date": "1977-09-05", "title": "Launch", "text": "Voyager 1 launched on a faster path than Voyager 2 and reached Jupiter first."},
17 {"date": "1979-03-05", "title": "Jupiter", "text": "Its images showed active volcanoes on Io, the first seen anywhere beyond Earth."},
18 {"date": "1980-11-12", "title": "Saturn and Titan", "text": "A close pass of Titan bent its path up out of the plane of the planets, ending its planetary tour."},
19 {"date": "1990-02-14", "title": "The Pale Blue Dot", "text": "Looking back from about 6 billion km, it photographed Earth as a single pixel."},
20 {"date": "2012-08-25", "title": "Interstellar space", "text": "The first spacecraft to cross the heliopause into interstellar space."},
21 {"date": "2026-11-18", "title": "One light-day", "text": "Voyager 1 reaches one light-day from Earth: a signal takes 24 hours each way."}
22 ]
23 },
24 {
25 "spacecraft": "Pioneer 10", "page": "pioneer-10",
26 "chapters": [
27 {"date": "1972-03-03", "title": "Launch", "text": "Pioneer 10 was the first spacecraft sent to the outer planets."},
28 {"date": "1972-07-15", "title": "Asteroid belt", "text": "It became the first to cross the asteroid belt, which took about seven months and showed the belt was safe to pass."},
29 {"date": "1973-12-04", "title": "Jupiter", "text": "The first flyby of Jupiter, at 130,354 km, measuring its intense radiation belts."},
30 {"date": "2003-01-23", "title": "Last signal", "text": "The last, faint signal came from 12.23 billion km. Pioneer 10 continues towards the constellation Taurus."}
31 ]
32 },
33 {
34 "spacecraft": "Pioneer 11", "page": "pioneer-11",
35 "chapters": [
36 {"date": "1973-04-06", "title": "Launch", "text": "Pioneer 11 followed Pioneer 10 a year later."},
37 {"date": "1974-12-03", "title": "Jupiter", "text": "It passed much closer than Pioneer 10, about 42,500 km from the cloud tops, and Jupiter's gravity sent it across the solar system to Saturn."},
38 {"date": "1979-09-01", "title": "Saturn", "text": "The first spacecraft at Saturn, scouting the route through the rings for the Voyagers."},
39 {"date": "1995-11-24", "title": "Last data", "text": "Its power fell too low to run the instruments, and the last engineering data came in November 1995."}
40 ]
41 },
42 {
43 "spacecraft": "New Horizons", "page": "new-horizons",
44 "chapters": [
45 {"date": "2006-01-19", "title": "Launch", "text": "New Horizons left Earth faster than any spacecraft before it."},
46 {"date": "2007-02-28", "title": "Jupiter", "text": "A gravity assist from Jupiter cut about three years off the trip to Pluto."},
47 {"date": "2015-07-14", "title": "Pluto", "text": "The first visit to Pluto, passing 12,500 km from the surface and showing its nitrogen-ice plains and mountains of water ice."},
48 {"date": "2019-01-01", "title": "Arrokoth", "text": "The most distant object yet visited, a small, two-lobed body in the Kuiper Belt."}
49 ]
50 }
51]
Packages/MoonriseKit/Sources/Library/Stories.swift added +26
@@ -0,0 +1,26 @@
1import Foundation
2
3/// A mission told in a few dated chapters, shown on the orrery.
4public struct MissionStory: Sendable, Decodable, Identifiable {
5 public struct Chapter: Sendable, Decodable, Hashable {
6 /// UTC date, yyyy-MM-dd.
7 public var date: String
8 public var title: String
9 public var text: String
10
11 public var day: Date { try! Date(date + "T12:00:00Z", strategy: .iso8601) }
12 }
13
14 /// Name as in the orrery's spacecraft list.
15 public var spacecraft: String
16 public var page: String
17 public var chapters: [Chapter]
18 public var id: String { page }
19
20 public static let all: [MissionStory] = {
21 let url = Bundle.module.url(forResource: "stories", withExtension: "json", subdirectory: "stories")!
22 return try! JSONDecoder().decode([MissionStory].self, from: Data(contentsOf: url))
23 }()
24
25 public static func story(page: String) -> MissionStory? { all.first { $0.page == page } }
26}
Packages/MoonriseKit/Sources/SkyRenderer/Orrery.swift +10 −4
@@ -81,11 +81,14 @@ public struct OrreryView: View {
8181 public var date: Date
8282 public var ink: Color
8383 public var accent: Color
84 /// Spacecraft name to draw in full; the others are drawn faint and unlabelled.
85 public var highlight: String?
8486
85 public init(date: Date, ink: Color, accent: Color) {
87 public init(date: Date, ink: Color, accent: Color, highlight: String? = nil) {
8688 self.date = date
8789 self.ink = ink
8890 self.accent = accent
91 self.highlight = highlight
8992 }
9093
9194 /// Mean distances (AU) for orbit rings.
@@ -111,13 +114,16 @@ public struct OrreryView: View {
111114 for craft in Spacecraft.all {
112115 let path = craft.path(until: date)
113116 guard let last = path.last else { continue }
117 let faint = highlight != nil && craft.name != highlight
114118 var line = Path()
115119 line.move(to: s.point(path[0]))
116120 for v in path.dropFirst() { line.addLine(to: s.point(v)) }
117 context.stroke(line, with: .color(accent.opacity(0.7)), lineWidth: 0.8)
121 context.stroke(line, with: .color(accent.opacity(faint ? 0.2 : 0.7)), lineWidth: faint ? 0.5 : 0.8)
118122 let p = s.point(last)
119 context.fill(Path(ellipseIn: CGRect(x: p.x - 2.5, y: p.y - 2.5, width: 5, height: 5)), with: .color(accent))
120 context.draw(Text(craft.name).font(.caption2).foregroundStyle(accent), at: CGPoint(x: p.x + 6, y: p.y), anchor: .leading)
123 context.fill(Path(ellipseIn: CGRect(x: p.x - 2.5, y: p.y - 2.5, width: 5, height: 5)), with: .color(accent.opacity(faint ? 0.3 : 1)))
124 if !faint {
125 context.draw(Text(craft.name).font(.caption2).foregroundStyle(accent), at: CGPoint(x: p.x + 6, y: p.y), anchor: .leading)
126 }
121127 }
122128
123129 for body in [SolarBody.mercury, .venus, .mars, .jupiter, .saturn, .uranus, .neptune] {
Packages/MoonriseKit/Tests/LibraryTests/LibraryTests.swift +14
@@ -89,3 +89,17 @@ func linksPointAtThingsTheAppKnows(_ p: LibraryPage) {
8989 #expect(Tour.ordered(month: 7).first?.id == "summer")
9090 #expect(Tour.ordered(month: 11).map(\.id) == ["autumn", "winter", "spring", "summer"])
9191}
92
93@Test func storiesMatchSpacecraftAndPages() {
94 #expect(MissionStory.all.count == 5)
95 let craft = Set(Spacecraft.all.map(\.name))
96 for s in MissionStory.all {
97 #expect(craft.contains(s.spacecraft), "\(s.spacecraft)")
98 #expect(Library.page(id: s.page)?.link.spacecraft == s.spacecraft, "\(s.page)")
99 let days = s.chapters.map(\.day)
100 #expect(days == days.sorted(), "\(s.page) chapters out of order")
101 // After launch, every chapter falls within the trajectory data (which starts up to 30 days after launch).
102 let craftData = Spacecraft.all.first { $0.name == s.spacecraft }!
103 for c in s.chapters.dropFirst() { #expect(craftData.position(at: c.day) != nil, "\(s.page) \(c.date)") }
104 }
105}