v3: Mission stories !38

merged merged by cmc on 2026-10-18 22:02 UTC · krz/moonrise:v3-mission-stories into main

8 files changed, +180 −6

Layout: unified · split

App/Sources/LibraryScreen.swift +6
@@ -98,6 +98,12 @@ struct LibraryPageView: View {
98 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 10).padding(.bottom, 16) 98 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 10).padding(.bottom, 16)
99 99
100 PageDrawing(page: page, ink: ink, accent: accent) 100 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 }
101 107
102 facts.padding(.top, 16) 108 facts.padding(.top, 16)
103 109
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).
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. Tours done: four, by 417- 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.
419- Live coverage of major events (e.g. an Artemis landing) on the orrery 420- Live coverage of major events (e.g. an Artemis landing) on the orrery
420 with a Live Activity. 421 with a Live Activity.
421- Maybe: smart-telescope pointing (Seestar and similar). 422- 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"), .copy("Resources/tours")]), 29 .target(name: "Library", resources: [.copy("Resources/pages"), .copy("Resources/tours"), .copy("Resources/stories")]),
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/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 {
81 public var date: Date 81 public var date: Date
82 public var ink: Color 82 public var ink: Color
83 public var accent: Color 83 public var accent: Color
84 /// Spacecraft name to draw in full; the others are drawn faint and unlabelled.
85 public var highlight: String?
84 86
85 public init(date: Date, ink: Color, accent: Color) { 87 public init(date: Date, ink: Color, accent: Color, highlight: String? = nil) {
86 self.date = date 88 self.date = date
87 self.ink = ink 89 self.ink = ink
88 self.accent = accent 90 self.accent = accent
91 self.highlight = highlight
89 } 92 }
90 93
91 /// Mean distances (AU) for orbit rings. 94 /// Mean distances (AU) for orbit rings.
@@ -111,13 +114,16 @@ public struct OrreryView: View {
111 for craft in Spacecraft.all { 114 for craft in Spacecraft.all {
112 let path = craft.path(until: date) 115 let path = craft.path(until: date)
113 guard let last = path.last else { continue } 116 guard let last = path.last else { continue }
117 let faint = highlight != nil && craft.name != highlight
114 var line = Path() 118 var line = Path()
115 line.move(to: s.point(path[0])) 119 line.move(to: s.point(path[0]))
116 for v in path.dropFirst() { line.addLine(to: s.point(v)) } 120 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)
118 let p = s.point(last) 122 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)) 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)))
120 context.draw(Text(craft.name).font(.caption2).foregroundStyle(accent), at: CGPoint(x: p.x + 6, y: p.y), anchor: .leading) 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 }
121 } 127 }
122 128
123 for body in [SolarBody.mercury, .venus, .mars, .jupiter, .saturn, .uranus, .neptune] { 129 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) {
89 #expect(Tour.ordered(month: 7).first?.id == "summer") 89 #expect(Tour.ordered(month: 7).first?.id == "summer")
90 #expect(Tour.ordered(month: 11).map(\.id) == ["autumn", "winter", "spring", "summer"]) 90 #expect(Tour.ordered(month: 11).map(\.id) == ["autumn", "winter", "spring", "summer"])
91} 91}
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}