Commit 6d858a843d
Verified · cmc
Layout: unified · split
App/Sources/LibraryScreen.swift +12
| @@ -12,6 +12,18 @@ struct LibraryScreen: View { | ||
| 12 | 12 | NavigationStack { |
| 13 | 13 | List { |
| 14 | 14 | if query.isEmpty { |
| 15 | Section { | |
| 16 | NavigationLink("Timeline of exploration") { ExplorationTimeline() } | |
| 17 | .listRowBackground(pageBackground) | |
| 18 | if !Library.pages(in: .rockets).isEmpty { | |
| 19 | NavigationLink("Rockets to scale") { | |
| 20 | ScrollView { RocketLineup(highlight: nil, ink: ink, accent: accent).frame(height: 420).padding(.vertical, 20) } | |
| 21 | .background(Color.black.ignoresSafeArea()) | |
| 22 | .navigationTitle("Rockets to scale") | |
| 23 | } | |
| 24 | .listRowBackground(pageBackground) | |
| 25 | } | |
| 26 | } | |
| 15 | 27 | ForEach(LibrarySection.allCases) { section in |
| 16 | 28 | let pages = Library.pages(in: section) |
| 17 | 29 | if !pages.isEmpty { |
App/Sources/PageDrawing.swift +55
| @@ -17,6 +17,8 @@ struct PageDrawing: View { | ||
| 17 | 17 | case "star": star |
| 18 | 18 | case "deep-sky": deepSky |
| 19 | 19 | case "mission": mission |
| 20 | case "exoplanet": exoplanet | |
| 21 | case "rocket": RocketLineup(highlight: page.id, ink: ink, accent: accent).frame(height: 260) | |
| 20 | 22 | default: EmptyView() |
| 21 | 23 | } |
| 22 | 24 | } |
| @@ -158,6 +160,59 @@ struct PageDrawing: View { | ||
| 158 | 160 | .accessibilityLabel("\(page.title) apparent size compared with the full Moon") |
| 159 | 161 | } |
| 160 | 162 | |
| 163 | // MARK: Exoplanets | |
| 164 | ||
| 165 | /// The planet beside Earth and Jupiter to scale, and its orbit beside Mercury's and Earth's. | |
| 166 | private var exoplanet: some View { | |
| 167 | let d = page.drawing! | |
| 168 | let star = starColor(d.starTempK) | |
| 169 | return HStack(alignment: .bottom, spacing: 24) { | |
| 170 | if let r = d.radiusEarth { | |
| 171 | Canvas { context, size in | |
| 172 | let maxR = max(r, 11.2) | |
| 173 | let scale = (size.height - 22) / (2 * maxR) | |
| 174 | let bodies: [(String, Double, Color)] = [("Earth", 1, Color(red: 0.35, green: 0.6, blue: 0.95)), (page.title, r, ink), ("Jupiter", 11.2, Color(red: 0.85, green: 0.72, blue: 0.55))] | |
| 175 | var x = 8.0 | |
| 176 | for (name, radius, color) in bodies.sorted(by: { $0.1 < $1.1 }) { | |
| 177 | let rr = max(radius * scale, 1.5) | |
| 178 | let c = CGPoint(x: x + rr, y: size.height - 18 - rr) | |
| 179 | context.fill(Path(ellipseIn: CGRect(x: c.x - rr, y: c.y - rr, width: 2 * rr, height: 2 * rr)), with: .color(name == page.title ? accent : color)) | |
| 180 | context.draw(Text(name).font(.caption2).foregroundStyle(ink.opacity(0.7)), at: CGPoint(x: c.x, y: size.height - 6)) | |
| 181 | x += 2 * rr + 34 | |
| 182 | } | |
| 183 | } | |
| 184 | .frame(height: 140) | |
| 185 | .accessibilityLabel("\(page.title), \(String(format: "%.1f", r)) times Earth's radius") | |
| 186 | } | |
| 187 | if let a = d.orbitAU { | |
| 188 | Canvas { context, size in | |
| 189 | let c = CGPoint(x: size.width / 2, y: size.height / 2 - 6) | |
| 190 | let outer = max(a, 1) | |
| 191 | let scale = (min(size.width, size.height) / 2 - 14) / outer | |
| 192 | for (name, au) in [("Mercury", 0.387), ("Earth", 1.0)] where au <= outer { | |
| 193 | let r = au * scale | |
| 194 | context.stroke(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)), with: .color(ink.opacity(0.3)), style: StrokeStyle(lineWidth: 0.6, dash: [2, 3])) | |
| 195 | context.draw(Text(name).font(.caption2).foregroundStyle(ink.opacity(0.6)), at: CGPoint(x: c.x, y: c.y - r - 7)) | |
| 196 | } | |
| 197 | let r = a * scale | |
| 198 | context.stroke(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)), with: .color(accent), lineWidth: 1) | |
| 199 | context.fill(Path(ellipseIn: CGRect(x: c.x - 4, y: c.y - 4, width: 8, height: 8)), with: .color(star)) | |
| 200 | context.draw(Text(String(format: "%.3g AU", a)).font(.caption2).foregroundStyle(ink), at: CGPoint(x: c.x, y: size.height - 6)) | |
| 201 | } | |
| 202 | .frame(width: 150, height: 150) | |
| 203 | .accessibilityLabel("Orbit \(String(format: "%.3g", a)) astronomical units from its star") | |
| 204 | } | |
| 205 | } | |
| 206 | } | |
| 207 | ||
| 208 | private func starColor(_ t: Double?) -> Color { | |
| 209 | guard let t else { return ink } | |
| 210 | if t < 3700 { return Color(red: 1, green: 0.6, blue: 0.4) } | |
| 211 | if t < 5200 { return Color(red: 1, green: 0.8, blue: 0.55) } | |
| 212 | if t < 6000 { return Color(red: 1, green: 0.95, blue: 0.8) } | |
| 213 | return Color(red: 0.8, green: 0.88, blue: 1) | |
| 214 | } | |
| 215 | ||
| 161 | 216 | // MARK: Missions |
| 162 | 217 | |
| 163 | 218 | private var mission: some View { |
App/Sources/RocketLineup.swift added +42
| @@ -0,0 +1,42 @@ | ||
| 1 | import Library | |
| 2 | import SwiftUI | |
| 3 | ||
| 4 | /// Every rocket in the Library drawn side by side at the same scale, tallest last. | |
| 5 | struct RocketLineup: View { | |
| 6 | var highlight: String? | |
| 7 | var ink: Color | |
| 8 | var accent: Color | |
| 9 | ||
| 10 | private var rockets: [LibraryPage] { | |
| 11 | Library.pages(in: .rockets).filter { $0.drawing?.heightM != nil }.sorted { ($0.drawing?.heightM ?? 0) < ($1.drawing?.heightM ?? 0) } | |
| 12 | } | |
| 13 | ||
| 14 | var body: some View { | |
| 15 | ScrollView(.horizontal, showsIndicators: false) { | |
| 16 | Canvas { context, size in | |
| 17 | let tallest = rockets.compactMap(\.drawing?.heightM).max() ?? 1 | |
| 18 | let scale = (size.height - 36) / tallest | |
| 19 | var x = 12.0 | |
| 20 | for r in rockets { | |
| 21 | let h = r.drawing!.heightM! * scale | |
| 22 | let w = max((r.drawing?.diameterM ?? 3) * scale, 3) | |
| 23 | let rect = CGRect(x: x, y: size.height - 24 - h, width: w, height: h) | |
| 24 | let mine = r.id == highlight | |
| 25 | var body = Path(roundedRect: rect, cornerRadius: w / 2) | |
| 26 | body.addRect(rect.insetBy(dx: 0, dy: w / 2)) | |
| 27 | context.fill(body, with: .color(mine ? accent : ink.opacity(0.55))) | |
| 28 | context.draw(Text(String(format: "%.0f m", r.drawing!.heightM!)).font(.caption2).foregroundStyle(ink.opacity(0.7)), at: CGPoint(x: x + w / 2, y: rect.minY - 8)) | |
| 29 | context.draw(Text(r.title).font(.caption2).foregroundStyle(mine ? accent : ink.opacity(0.8)), at: CGPoint(x: x + w / 2, y: size.height - 10)) | |
| 30 | x += max(w, 64) + 10 | |
| 31 | } | |
| 32 | var ground = Path() | |
| 33 | ground.move(to: CGPoint(x: 0, y: size.height - 24)) | |
| 34 | ground.addLine(to: CGPoint(x: size.width, y: size.height - 24)) | |
| 35 | context.stroke(ground, with: .color(ink.opacity(0.4)), lineWidth: 0.5) | |
| 36 | } | |
| 37 | .frame(width: CGFloat(rockets.count) * 74 + 24) | |
| 38 | } | |
| 39 | .accessibilityElement() | |
| 40 | .accessibilityLabel("Rockets to scale: " + rockets.map { "\($0.title) \(Int($0.drawing!.heightM!)) metres" }.joined(separator: ", ")) | |
| 41 | } | |
| 42 | } | |
App/Sources/TimelineScreen.swift added +51
| @@ -0,0 +1,51 @@ | ||
| 1 | import Library | |
| 2 | import SwiftUI | |
| 3 | ||
| 4 | /// Missions in the Library by launch year, from Sputnik to today. | |
| 5 | struct ExplorationTimeline: View { | |
| 6 | @State private var selected: LibraryPage? | |
| 7 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) | |
| 8 | private let accent = Color(red: 0.88, green: 0.24, blue: 0.19) | |
| 9 | ||
| 10 | private var decades: [(Int, [LibraryPage])] { | |
| 11 | let missions = Library.pages(in: .missions).filter { $0.drawing?.launch != nil }.sorted { $0.drawing!.launch! < $1.drawing!.launch! } | |
| 12 | let grouped = Dictionary(grouping: missions) { (Int($0.drawing!.launch!.prefix(4)) ?? 0) / 10 * 10 } | |
| 13 | return grouped.keys.sorted().map { ($0, grouped[$0]!) } | |
| 14 | } | |
| 15 | ||
| 16 | var body: some View { | |
| 17 | List { | |
| 18 | ForEach(decades, id: \.0) { decade, missions in | |
| 19 | Section("\(String(decade))s") { | |
| 20 | ForEach(missions) { m in | |
| 21 | Button { selected = m } label: { row(m) } | |
| 22 | .buttonStyle(.plain) | |
| 23 | .listRowBackground(Color.black) | |
| 24 | } | |
| 25 | } | |
| 26 | } | |
| 27 | } | |
| 28 | .listStyle(.plain) | |
| 29 | .scrollContentBackground(.hidden) | |
| 30 | .background(Color.black.ignoresSafeArea()) | |
| 31 | .navigationTitle("Timeline") | |
| 32 | .foregroundStyle(ink) | |
| 33 | .sheet(item: $selected) { page in NavigationStack { LibraryPageView(page: page) } } | |
| 34 | } | |
| 35 | ||
| 36 | private func row(_ m: LibraryPage) -> some View { | |
| 37 | let year = String(m.drawing?.launch?.prefix(4) ?? "") | |
| 38 | let active = m.drawing?.status == "active" | |
| 39 | return HStack(alignment: .firstTextBaseline, spacing: 12) { | |
| 40 | Text(year).font(.footnote.monospacedDigit()).dimmed(0.7) | |
| 41 | VStack(alignment: .leading, spacing: 2) { | |
| 42 | Text(m.title).font(.body) | |
| 43 | Text(m.subtitle).font(.footnote).dimmed(0.7) | |
| 44 | } | |
| 45 | Spacer() | |
| 46 | if active { | |
| 47 | Circle().fill(accent).frame(width: 7, height: 7).accessibilityLabel("Active") | |
| 48 | } | |
| 49 | } | |
| 50 | } | |
| 51 | } | |
Packages/MoonriseKit/Sources/Library/Library.swift +8 −1
| @@ -36,6 +36,11 @@ public struct LibraryPage: Codable, Sendable, Identifiable, Hashable { | ||
| 36 | 36 | public var sizeArcmin: Double? |
| 37 | 37 | public var status: String? |
| 38 | 38 | public var launch: String? |
| 39 | public var radiusEarth: Double? | |
| 40 | public var orbitAU: Double? | |
| 41 | public var starTempK: Double? | |
| 42 | public var heightM: Double? | |
| 43 | public var diameterM: Double? | |
| 39 | 44 | } |
| 40 | 45 | |
| 41 | 46 | /// What the page describes elsewhere in the app. |
| @@ -68,7 +73,7 @@ public struct LibraryPage: Codable, Sendable, Identifiable, Hashable { | ||
| 68 | 73 | } |
| 69 | 74 | |
| 70 | 75 | public enum LibrarySection: String, Codable, Sendable, CaseIterable, Identifiable { |
| 71 | case sun, planets, dwarfPlanets = "dwarf-planets", moons, constellations, stars, deepSky = "deep-sky", missions | |
| 76 | case sun, planets, dwarfPlanets = "dwarf-planets", moons, constellations, stars, deepSky = "deep-sky", exoplanets, missions, rockets | |
| 72 | 77 | |
| 73 | 78 | public var id: String { rawValue } |
| 74 | 79 | |
| @@ -81,7 +86,9 @@ public enum LibrarySection: String, Codable, Sendable, CaseIterable, Identifiabl | ||
| 81 | 86 | case .constellations: "Constellations" |
| 82 | 87 | case .stars: "Stars" |
| 83 | 88 | case .deepSky: "Deep sky" |
| 89 | case .exoplanets: "Exoplanets" | |
| 84 | 90 | case .missions: "Missions" |
| 91 | case .rockets: "Rockets" | |
| 85 | 92 | } |
| 86 | 93 | } |
| 87 | 94 | } |
docs/library-content.org +3 −1
| @@ -30,7 +30,7 @@ one file per section, each a JSON array of pages. | ||
| 30 | 30 | #+end_src |
| 31 | 31 | |
| 32 | 32 | - =id=: lowercase, hyphenated, unique across all sections (e.g. =m42=, =ori=, =sirius=, =voyager-1=). |
| 33 | - =section=: one of =sun=, =planets=, =dwarf-planets=, =moons=, =constellations=, =stars=, =deep-sky=, =missions=. | |
| 33 | - =section=: one of =sun=, =planets=, =dwarf-planets=, =moons=, =constellations=, =stars=, =deep-sky=, =missions=, =exoplanets=, =rockets=. | |
| 34 | 34 | - =facts=: 4–8 items. Every fact has =source=, an index into =sources=. Units in SI or AU, with conversions where helpful. Use tabular style: "10 h 33 m", "29.4 Earth years". |
| 35 | 35 | - =sources=: 1–5 items, primary or authoritative (NASA NSSDC fact sheets, NASA science pages, JPL, IAU, ESA, NOIRLab, SIMBAD). Every URL must return HTTP 200 when checked. |
| 36 | 36 | - =image=: optional only for constellations, stars and missions when no verifiable image exists (set to =null=). |
| @@ -40,6 +40,8 @@ one file per section, each a JSON array of pages. | ||
| 40 | 40 | - stars: ={"kind": "star", "spectralType": "A1V", "colorIndex": 0.0}= (B−V). |
| 41 | 41 | - deep sky: ={"kind": "deep-sky", "type": "galaxy|globular|open|nebula|planetary|supernova-remnant", "sizeArcmin": 90}=. |
| 42 | 42 | - missions: ={"kind": "mission", "status": "active|complete|lost", "launch": "1977-09-05"}=. |
| 43 | - exoplanets: ={"kind": "exoplanet", "radiusEarth": 1.07, "orbitAU": 0.0485, "starTempK": 2566}= (omit a field if no measured value). | |
| 44 | - rockets: ={"kind": "rocket", "heightM": 70.0, "diameterM": 3.7, "status": "active|retired|in development"}=. | |
| 43 | 45 | - =link=: what the page describes in the rest of the app: ={"body": "mars"}=, ={"constellation": "Ori"}= (IAU abbreviation), ={"hr": 2491}= (Bright Star Catalogue number), ={"messier": 42}=, ={"spacecraft": "Voyager 1"}=, or ={}=. |
| 44 | 46 | |
| 45 | 47 | * Essay voice |