v3: Mission stories !38
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 @@ | |||
| 1 | import Library | ||
| 2 | import SkyRenderer | ||
| 3 | import SwiftUI | ||
| 4 | |||
| 5 | /// A mission's chapters on the orrery. Moving between chapters runs the clock so the craft travels. | ||
| 6 | struct 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 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// A mission told in a few dated chapters, shown on the orrery. | ||
| 4 | public 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 | } | ||