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

App/Sources/LibraryScreen.swift

04b2b1d914788057bcd20945b876847386eb8f65
moonrise/App/Sources/LibraryScreen.swift history · blame · raw

626 lines · 27503 bytes

16 symbols in this file
  1import CoreLocation
  2import Library
  3import SkyEngine
  4import SkyRenderer
  5import SwiftUI
  6
  7private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
  8private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
  9private let pageBackground = Color.black
 10private let tile = Color(white: 0.08)
 11
 12/// The observer from Settings (a chosen city) or the last known location, else Chicago.
 13private func libraryObserver() -> Observer {
 14    let d = UserDefaults.standard
 15    if d.string(forKey: SettingsKey.locationMode) == "city", d.object(forKey: SettingsKey.cityLatitude) != nil {
 16        return Observer(latitude: d.double(forKey: SettingsKey.cityLatitude), longitude: d.double(forKey: SettingsKey.cityLongitude))
 17    }
 18    if let c = CLLocationManager().location?.coordinate { return Observer(latitude: c.latitude, longitude: c.longitude) }
 19    return Observer(latitude: 41.8781, longitude: -87.6298)
 20}
 21
 22/// What is well placed tonight, for the Library's front page and the Constellations section.
 23private struct TonightPicks: Sendable {
 24    var planet: Featured?
 25    var constellations: [LibraryPage] = []
 26
 27    struct Featured: Sendable {
 28        var page: LibraryPage
 29        var best: Date
 30        var altitude: Double
 31    }
 32
 33    static func compute() -> TonightPicks {
 34        let observer = libraryObserver()
 35        guard let t = try? Tonight(at: .now, from: observer) else { return TonightPicks() }
 36        var picks = TonightPicks()
 37        if let p = t.planets.first(where: { $0.best != nil }), let best = p.best, let page = Library.page(body: p.body.rawValue) {
 38            picks.planet = Featured(page: page, best: best.date, altitude: best.position.altitude)
 39        }
 40        // Constellations whose stars are on average more than 25° up two hours into the night.
 41        let when = t.start.addingTimeInterval(2 * 3600)
 42        let scored: [(LibraryPage, Double)] = Catalog.constellations.compactMap { c in
 43            guard let page = Library.page(constellation: c.id) else { return nil }
 44            let stars = Set(c.lines.joined()).compactMap { Catalog.starsByHR[$0] }
 45            guard !stars.isEmpty else { return nil }
 46            let alts = SkyEngine.horizontal(j2000: stars.map { $0.position(at: when) }, at: when, from: observer).map(\.altitude)
 47            return (page, alts.reduce(0, +) / Double(alts.count))
 48        }
 49        picks.constellations = scored.filter { $0.1 > 25 }.sorted { $0.1 > $1.1 }.map(\.0)
 50        return picks
 51    }
 52}
 53
 54// MARK: - Front page
 55
 56struct LibraryScreen: View {
 57    @State private var query = ""
 58    @State private var picks = TonightPicks()
 59
 60    private var rows: [(String, LibrarySection, [LibraryPage])] {
 61        let constellations = picks.constellations.isEmpty ? Library.pages(in: .constellations) : picks.constellations
 62        let all: [(String, LibrarySection, [LibraryPage])] = [
 63            ("Sun and planets", .planets, Library.pages(in: .sun) + Library.pages(in: .planets)),
 64            (picks.constellations.isEmpty ? "Constellations" : "Constellations up tonight", .constellations, Array(constellations.prefix(12))),
 65            ("Brightest stars", .stars, Array(Library.pages(in: .stars).prefix(12))),
 66            ("Moons", .moons, Library.pages(in: .moons)),
 67            ("Deep sky", .deepSky, Array(Library.pages(in: .deepSky).filter { $0.image != nil }.prefix(12))),
 68            ("Missions", .missions, Array(Library.pages(in: .missions).reversed().prefix(12))),
 69            ("Exoplanets", .exoplanets, Library.pages(in: .exoplanets)),
 70            ("Rockets", .rockets, Library.pages(in: .rockets).filter { $0.image != nil }),
 71            ("Dwarf planets", .dwarfPlanets, Library.pages(in: .dwarfPlanets)),
 72        ]
 73        return all.filter { !$0.2.isEmpty }
 74    }
 75
 76    var body: some View {
 77        NavigationStack {
 78            ScrollView {
 79                VStack(alignment: .leading, spacing: 0) {
 80                    VStack(alignment: .leading, spacing: 0) {
 81                        Text("Library").font(.title.weight(.medium))
 82                        Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 14)
 83                        searchField
 84                    }
 85                    .padding(.horizontal, 20)
 86
 87                    if query.isEmpty {
 88                        if let p = picks.planet { featured(p).padding(.horizontal, 20).padding(.top, 18) }
 89                        CardRow(title: "Explore") { exploreCards }
 90                        ForEach(rows, id: \.0) { title, section, pages in
 91                            CardRow(title: title, section: section) {
 92                                ForEach(pages) { PageCard(page: $0) }
 93                            }
 94                        }
 95                        Text("Pages draw on NASA, ESA, ESO, the IAU and other sources, listed on each page.")
 96                            .font(.caption2).dimmed(0.5).padding(20)
 97                    } else {
 98                        searchResults
 99                    }
100                }
101                .padding(.top, 8)
102            }
103            .scrollDismissesKeyboard(.immediately)
104            .background(pageBackground.ignoresSafeArea())
105            .toolbar(.hidden, for: .navigationBar)
106        }
107        .foregroundStyle(ink)
108        .tint(ink)
109        .task {
110            if picks.planet == nil, picks.constellations.isEmpty {
111                picks = await Task.detached { TonightPicks.compute() }.value
112            }
113        }
114    }
115
116    private var searchField: some View {
117        HStack(spacing: 8) {
118            Image(systemName: "magnifyingglass").dimmed(0.6).accessibilityHidden(true)
119            TextField("Search 370 pages: Saturn, Orion, M42", text: $query)
120                .submitLabel(.search)
121                .autocorrectionDisabled()
122            if !query.isEmpty {
123                Button { query = "" } label: { Image(systemName: "xmark.circle.fill").dimmed(0.6) }
124                    .accessibilityLabel("Clear search")
125            }
126        }
127        .padding(.horizontal, 12)
128        .frame(minHeight: 44)
129        .background(Color(white: 0.11), in: RoundedRectangle(cornerRadius: 12))
130    }
131
132    private var searchResults: some View {
133        let results = Library.search(query)
134        return LazyVStack(alignment: .leading, spacing: 0) {
135            if results.isEmpty {
136                Text("Nothing matches \u{201C}\(query)\u{201D}.").font(.subheadline).dimmed(0.7).padding(.top, 20)
137            }
138            ForEach(results) { page in
139                NavigationLink { LibraryPageView(page: page) } label: { PageListRow(page: page, showSection: true) }
140                    .buttonStyle(.plain)
141            }
142        }
143        .padding(.horizontal, 20)
144        .padding(.top, 12)
145    }
146
147    private func featured(_ p: TonightPicks.Featured) -> some View {
148        NavigationLink { LibraryPageView(page: p.page) } label: {
149            ZStack(alignment: .bottomLeading) {
150                PageThumb(page: p.page, large: true)
151                    .frame(height: 210)
152                    .frame(maxWidth: .infinity)
153                    .clipped()
154                LinearGradient(colors: [.clear, .black.opacity(0.85)], startPoint: .center, endPoint: .bottom)
155                VStack(alignment: .leading, spacing: 3) {
156                    Text("UP TONIGHT").font(.caption2.weight(.bold)).tracking(0.8).foregroundStyle(accent)
157                    Text(p.page.title).font(.title2.weight(.medium))
158                    Text("Best around \(p.best.formatted(date: .omitted, time: .shortened)), \(Int(p.altitude.rounded()))° up. \(p.page.subtitle).")
159                        .font(.footnote).dimmed(0.85).lineLimit(2)
160                }
161                .padding(14)
162            }
163            .clipShape(RoundedRectangle(cornerRadius: 18))
164            .contentShape(RoundedRectangle(cornerRadius: 18))
165        }
166        .buttonStyle(.plain)
167        .accessibilityLabel("Up tonight: \(p.page.title)")
168    }
169
170    @ViewBuilder
171    private var exploreCards: some View {
172        ExploreCard(title: "Timeline of exploration", subtitle: "From Sputnik to today", symbol: "clock.arrow.circlepath", tint: Color(red: 0.12, green: 0.16, blue: 0.24)) {
173            ExplorationTimeline()
174        }
175        ExploreCard(title: "Scale of the universe", subtitle: "The ISS to the cosmos", symbol: "circle.dotted.circle", tint: Color(red: 0.1, green: 0.1, blue: 0.18)) {
176            ScaleScreen(ink: ink, accent: accent)
177        }
178        if !Library.pages(in: .rockets).isEmpty {
179            ExploreCard(title: "Rockets to scale", subtitle: "\(Library.pages(in: .rockets).count) rockets side by side", symbol: "chart.bar.xaxis", tint: Color(red: 0.2, green: 0.1, blue: 0.08)) {
180                VStack(alignment: .leading, spacing: 8) {
181                    Text("Every rocket in the Library at the same scale, shortest to tallest. Scroll sideways; tap one to read about it.")
182                        .font(.subheadline).dimmed(0.75).padding(.horizontal, 20)
183                    RocketLineup(highlight: nil, ink: ink, accent: accent)
184                }
185                .padding(.vertical, 12)
186                    .background(Color.black.ignoresSafeArea())
187                    .navigationTitle("Rockets to scale")
188            }
189        }
190    }
191}
192
193// MARK: - Building blocks
194
195/// A titled, horizontally scrolling row of cards, with See all when it belongs to a section.
196private struct CardRow<Content: View>: View {
197    var title: String
198    var section: LibrarySection?
199    @ViewBuilder var content: Content
200
201    var body: some View {
202        VStack(alignment: .leading, spacing: 10) {
203            HStack(alignment: .firstTextBaseline) {
204                Text(title).font(.headline)
205                Spacer()
206                if let section {
207                    NavigationLink { SectionScreen(section: section) } label: {
208                        HStack(spacing: 3) {
209                            Text("See all \(Library.pages(in: section).count)")
210                            Image(systemName: "chevron.right").font(.caption.weight(.semibold))
211                        }
212                        .font(.subheadline)
213                        .dimmed(0.7)
214                    }
215                    .accessibilityLabel("See all \(section.title)")
216                }
217            }
218            .padding(.horizontal, 20)
219            ScrollView(.horizontal, showsIndicators: false) {
220                LazyHStack(alignment: .top, spacing: 12) { content }
221                    .padding(.horizontal, 20)
222            }
223            .scrollClipDisabled()
224        }
225        .padding(.top, 26)
226    }
227}
228
229/// A page as a card: picture or drawing, title, up to two lines of subtitle.
230struct PageCard: View {
231    var page: LibraryPage
232    var width: CGFloat = 150
233
234    var body: some View {
235        NavigationLink { LibraryPageView(page: page) } label: {
236            VStack(alignment: .leading, spacing: 4) {
237                PageThumb(page: page)
238                    .frame(width: width, height: width * 0.72)
239                    .clipShape(RoundedRectangle(cornerRadius: 14))
240                Text(page.title).font(.subheadline.weight(.medium)).lineLimit(1)
241                Text(page.subtitle).font(.caption).dimmed(0.65).lineLimit(2, reservesSpace: true)
242            }
243            .frame(width: width, alignment: .leading)
244            .contentShape(Rectangle())
245        }
246        .buttonStyle(.plain)
247    }
248}
249
250private struct ExploreCard<Destination: View>: View {
251    var title: String
252    var subtitle: String
253    var symbol: String
254    var tint: Color
255    @ViewBuilder var destination: Destination
256
257    var body: some View {
258        NavigationLink { destination } label: {
259            VStack(alignment: .leading, spacing: 4) {
260                ZStack {
261                    tint
262                    Image(systemName: symbol).font(.system(size: 34, weight: .light)).foregroundStyle(ink.opacity(0.9))
263                }
264                .frame(width: 190, height: 110)
265                .clipShape(RoundedRectangle(cornerRadius: 14))
266                Text(title).font(.subheadline.weight(.medium)).lineLimit(1)
267                Text(subtitle).font(.caption).dimmed(0.65).lineLimit(1)
268            }
269            .frame(width: 190, alignment: .leading)
270            .contentShape(Rectangle())
271        }
272        .buttonStyle(.plain)
273    }
274}
275
276/// The page's photo, or a drawing in its place: a disc for bodies and stars, the figure for constellations.
277struct PageThumb: View {
278    var page: LibraryPage
279    var large = false
280
281    var body: some View {
282        ZStack {
283            tile
284            if let image = page.image {
285                // Library of Congress scans carry a frame and colour bars; crop to the plate.
286                RemoteImage(url: image.url, points: large ? 640 : 260) { fallback }
287                    .scaleEffect(image.url.host() == "tile.loc.gov" ? 1.45 : 1)
288            } else {
289                fallback
290            }
291        }
292        .accessibilityHidden(true)
293    }
294
295    @ViewBuilder
296    private var fallback: some View {
297        let d = page.drawing
298        switch d?.kind {
299        case "planet", "exoplanet":
300            GeometryReader { g in
301                let r = min(g.size.width, g.size.height) * 0.32
302                ZStack {
303                    if d?.rings == true {
304                        Ellipse().stroke((Color(hex: d?.color) ?? ink).opacity(0.7), lineWidth: 2)
305                            .frame(width: r * 4.2, height: r * 0.9).rotationEffect(.degrees(-14))
306                    }
307                    Circle().fill(Color(hex: d?.color) ?? ink.opacity(0.5)).frame(width: 2 * r, height: 2 * r)
308                }
309                .frame(maxWidth: .infinity, maxHeight: .infinity)
310            }
311        case "constellation":
312            ConstellationFigure(id: page.link.constellation, ink: ink).padding(large ? 20 : 6)
313        case "star":
314            let c = starColor(d?.colorIndex ?? 0.6)
315            ZStack {
316                Circle().fill(RadialGradient(colors: [c.opacity(0.55), c.opacity(0)], center: .center, startRadius: 4, endRadius: large ? 90 : 46))
317                    .frame(width: large ? 180 : 92, height: large ? 180 : 92)
318                Circle().fill(c).frame(width: large ? 30 : 16, height: large ? 30 : 16)
319                    .shadow(color: c, radius: 6)
320            }
321        default:
322            Image(systemName: symbol).font(.system(size: 28, weight: .light)).dimmed(0.6)
323        }
324    }
325
326    private var symbol: String {
327        switch page.section {
328        case .missions: "antenna.radiowaves.left.and.right"
329        case .rockets: "airplane.departure"
330        case .deepSky: "sparkles"
331        default: "circle"
332        }
333    }
334
335    /// Star tint from B−V colour index.
336    private func starColor(_ bv: Double) -> Color {
337        let t = min(max((bv + 0.3) / 2.0, 0), 1)
338        return Color(red: 0.62 + 0.38 * t, green: 0.76 - 0.12 * t, blue: 1.0 - 0.62 * t)
339    }
340}
341
342struct PageListRow: View {
343    var page: LibraryPage
344    var showSection = false
345
346    var body: some View {
347        HStack(spacing: 12) {
348            PageThumb(page: page)
349                .frame(width: 52, height: 52)
350                .clipShape(RoundedRectangle(cornerRadius: 10))
351            VStack(alignment: .leading, spacing: 2) {
352                Text(page.title).font(.body)
353                Text(showSection ? "\(page.section.title) · \(page.subtitle)" : page.subtitle).font(.caption).dimmed(0.65).lineLimit(2)
354            }
355            Spacer(minLength: 0)
356            Image(systemName: "chevron.right").font(.caption.weight(.semibold)).dimmed(0.4)
357        }
358        .padding(.vertical, 8)
359        .overlay(alignment: .bottom) { Rectangle().fill(ink.opacity(0.12)).frame(height: 0.5) }
360        .contentShape(Rectangle())
361    }
362}
363
364// MARK: - Section
365
366struct SectionScreen: View {
367    var section: LibrarySection
368    @State private var tonight: [LibraryPage] = []
369
370    private var pages: [LibraryPage] { Library.pages(in: section) }
371
372    private var intro: String {
373        switch section {
374        case .sun: "Our star."
375        case .planets: "The eight planets, from Mercury out to Neptune."
376        case .dwarfPlanets: "Worlds big enough to be round that haven't cleared their orbits."
377        case .moons: "The major moons of the solar system, by planet."
378        case .constellations: "The 88 official star patterns, with the ones up tonight first."
379        case .stars: "The brightest stars in the night sky, brightest first."
380        case .deepSky: "Galaxies, nebulae and star clusters from Messier's catalogue."
381        case .exoplanets: "Planets around other stars, from the first finds to Webb targets."
382        case .missions: "Spacecraft that changed what we know, newest first."
383        case .rockets: "Launch vehicles past and present."
384        }
385    }
386
387    private static let zodiac = ["Ari", "Tau", "Gem", "Cnc", "Leo", "Vir", "Lib", "Sco", "Sgr", "Cap", "Aqr", "Psc"]
388
389    /// Card rows for big sections; small sections are one grid.
390    private var groups: [(String, [LibraryPage])] {
391        switch section {
392        case .constellations:
393            return [("Up tonight", Array(tonight.prefix(14))),
394                    ("Zodiac", Self.zodiac.compactMap { Library.page(constellation: $0) })]
395        case .stars:
396            return [("Brightest", Array(pages.prefix(12)))]
397        case .deepSky:
398            let by: (Set<String>) -> [LibraryPage] = { types in pages.filter { types.contains($0.drawing?.type ?? "") } }
399            return [("Galaxies", by(["galaxy"])), ("Nebulae", by(["nebula", "planetary", "supernova-remnant"])),
400                    ("Globular clusters", by(["globular"])), ("Open clusters", by(["open"]))]
401        case .moons:
402            var order: [String] = []
403            var byPlanet: [String: [LibraryPage]] = [:]
404            for p in pages {
405                let planet = p.id == "moon" ? "Earth" : (p.subtitle.components(separatedBy: " of ").last ?? "Other")
406                if byPlanet[planet] == nil { order.append(planet) }
407                byPlanet[planet, default: []].append(p)
408            }
409            return order.map { (byPlanet[$0]!.count == 1 && $0 == "Earth" ? "Earth" : "Moons of \($0)", byPlanet[$0]!) }
410        case .missions:
411            let byDecade = Dictionary(grouping: pages) { p in p.drawing?.launch.map { "\($0.prefix(3))0s" } ?? "Other" }
412            return byDecade.keys.sorted(by: >).map { ($0, byDecade[$0]!.reversed()) }
413        case .rockets:
414            return [("Flying now", pages.filter { $0.drawing?.status != "retired" }), ("Retired", pages.filter { $0.drawing?.status == "retired" })]
415        default:
416            return []
417        }
418    }
419
420    var body: some View {
421        ScrollView {
422            VStack(alignment: .leading, spacing: 0) {
423                VStack(alignment: .leading, spacing: 0) {
424                    Text(section.title).font(.title.weight(.medium))
425                    Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12)
426                    Text(intro).font(.subheadline).dimmed(0.75)
427                }
428                .padding(.horizontal, 20)
429
430                if groups.isEmpty {
431                    LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 14, alignment: .top)], spacing: 18) {
432                        ForEach(pages) { PageCard(page: $0, width: 160) }
433                    }
434                    .padding(.horizontal, 20)
435                    .padding(.top, 20)
436                } else {
437                    ForEach(groups.filter { !$0.1.isEmpty }, id: \.0) { title, items in
438                        CardRow(title: title) { ForEach(items) { PageCard(page: $0) } }
439                    }
440                    if pages.count > 12 {
441                        Text("All, A to Z").font(.headline).padding(.horizontal, 20).padding(.top, 30).padding(.bottom, 4)
442                        LazyVStack(spacing: 0) {
443                            ForEach(pages.sorted { $0.title.localizedStandardCompare($1.title) == .orderedAscending }) { page in
444                                NavigationLink { LibraryPageView(page: page) } label: { PageListRow(page: page) }
445                                    .buttonStyle(.plain)
446                            }
447                        }
448                        .padding(.horizontal, 20)
449                    }
450                }
451            }
452            .padding(.bottom, 32)
453        }
454        .foregroundStyle(ink)
455        .background(pageBackground.ignoresSafeArea())
456        .navigationBarTitleDisplayMode(.inline)
457        .task {
458            if section == .constellations, tonight.isEmpty {
459                tonight = await Task.detached { TonightPicks.compute().constellations }.value
460            }
461        }
462    }
463}
464
465// MARK: - Page
466
467struct LibraryPageView: View {
468    var page: LibraryPage
469
470    /// Up to three facts short enough to show as chips.
471    private var keyFacts: [LibraryPage.Fact] {
472        Array(page.facts.filter { $0.value.count <= 26 && !["Abbreviation", "Genitive", "Colour index (B−V)", "Spectral type"].contains($0.label) }.prefix(3))
473    }
474
475    var body: some View {
476        ScrollView {
477            VStack(alignment: .leading, spacing: 0) {
478                if let image = page.image {
479                    hero(image)
480                } else {
481                    plainHeader.padding(.top, 8)
482                }
483
484                VStack(alignment: .leading, spacing: 0) {
485                    if !keyFacts.isEmpty { chips.padding(.top, 16) }
486                    actions.padding(.top, 12)
487
488                    VStack(alignment: .leading, spacing: 12) {
489                        ForEach(page.paragraphs, id: \.self) { Text($0).font(.body).lineSpacing(3) }
490                    }
491                    .padding(.top, 20)
492
493                    if page.image != nil, page.drawing != nil {
494                        PageDrawing(page: page, ink: ink, accent: accent).padding(.top, 24)
495                    }
496                }
497                .padding(.horizontal, 20)
498                .readableWidth()
499
500                let related = page.related()
501                if !related.isEmpty {
502                    CardRow(title: "Related") { ForEach(related) { PageCard(page: $0, width: 130) } }
503                }
504
505                VStack(alignment: .leading, spacing: 0) {
506                    Text("Facts").font(.headline).padding(.top, 30).padding(.bottom, 10)
507                    facts
508                    sources.padding(.top, 24).padding(.bottom, 32)
509                }
510                .padding(.horizontal, 20)
511                .readableWidth()
512            }
513        }
514        .ignoresSafeArea(.container, edges: page.image == nil ? [] : .top)
515        .foregroundStyle(ink)
516        .background(pageBackground.ignoresSafeArea())
517        .navigationBarTitleDisplayMode(.inline)
518    }
519
520    private func hero(_ image: LibraryPage.Image) -> some View {
521        VStack(alignment: .leading, spacing: 0) {
522            ZStack(alignment: .bottomLeading) {
523                Color(white: 0.06)
524                    .frame(height: 340)
525                    .overlay {
526                        RemoteImage(url: image.url, points: 900) { ProgressView().tint(ink) }
527                    }
528                    .clipped()
529                    .accessibilityLabel(image.caption)
530                LinearGradient(colors: [.clear, .black], startPoint: .init(x: 0.5, y: 0.45), endPoint: .bottom)
531                    .allowsHitTesting(false)
532                VStack(alignment: .leading, spacing: 2) {
533                    Text(page.section.title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.75)
534                    Text(page.title).font(.largeTitle.weight(.medium))
535                    Text(page.subtitle).font(.subheadline).dimmed(0.85)
536                }
537                .padding(.horizontal, 20)
538                .padding(.bottom, 4)
539            }
540            VStack(alignment: .leading, spacing: 2) {
541                Text(image.caption).font(.caption).dimmed(0.6)
542                Link(destination: image.page) {
543                    Text("\(image.credit) · \(image.licence)").font(.caption2).dimmed(0.45)
544                }
545            }
546            .padding(.horizontal, 20)
547            .padding(.top, 10)
548        }
549    }
550
551    private var plainHeader: some View {
552        VStack(alignment: .leading, spacing: 0) {
553            Text(page.section.title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
554            Text(page.title).font(.largeTitle.weight(.medium)).padding(.top, 4)
555            Text(page.subtitle).font(.subheadline).dimmed(0.75).padding(.top, 2)
556            Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 10).padding(.bottom, 16)
557            PageDrawing(page: page, ink: ink, accent: accent)
558        }
559        .padding(.horizontal, 20)
560        .readableWidth()
561    }
562
563    private var chips: some View {
564        HStack(alignment: .top, spacing: 8) {
565            ForEach(keyFacts, id: \.self) { f in
566                VStack(alignment: .leading, spacing: 2) {
567                    Text(f.label).font(.caption2).dimmed(0.6).lineLimit(2)
568                    Text(f.value).font(.footnote.weight(.medium).monospacedDigit()).lineLimit(2)
569                }
570                .padding(.horizontal, 10).padding(.vertical, 8)
571                .frame(maxWidth: .infinity, alignment: .leading)
572                .background(Color(white: 0.1), in: RoundedRectangle(cornerRadius: 10))
573                .accessibilityElement(children: .combine)
574            }
575        }
576    }
577
578    @ViewBuilder
579    private var actions: some View {
580        let story = MissionStory.story(page: page.id)
581        let globe = page.drawing?.kind == "planet"
582        if story != nil || globe {
583            HStack(spacing: 8) {
584                if globe {
585                    NavigationLink { GlobeView(page: page, credit: GlobeMaps.credit[page.id]) } label: { pill("View as a globe", "globe") }
586                }
587                if let story {
588                    NavigationLink { StoryScreen(story: story) } label: { pill("Follow the mission", "point.topleft.down.to.point.bottomright.curvepath") }
589                }
590            }
591            .buttonStyle(.plain)
592        }
593    }
594
595    private func pill(_ title: String, _ symbol: String) -> some View {
596        Label(title, systemImage: symbol)
597            .font(.subheadline.weight(.medium))
598            .padding(.horizontal, 14)
599            .frame(minHeight: 40)
600            .overlay(Capsule().stroke(ink.opacity(0.35), lineWidth: 0.5))
601    }
602
603    private var facts: some View {
604        Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {
605            ForEach(page.facts, id: \.self) { f in
606                GridRow(alignment: .firstTextBaseline) {
607                    Text(f.label).font(.footnote).dimmed(0.6)
608                    Text(f.value).font(.subheadline.monospacedDigit())
609                }
610                .accessibilityElement(children: .combine)
611                Divider().overlay(ink.opacity(0.15))
612            }
613        }
614    }
615
616    private var sources: some View {
617        VStack(alignment: .leading, spacing: 6) {
618            Text("Sources").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6)
619            ForEach(Array(page.sources.enumerated()), id: \.offset) { i, s in
620                Link(destination: s.url) {
621                    Text("\(i + 1). \(s.title)").font(.caption).underline().dimmed(0.8).multilineTextAlignment(.leading)
622                }
623            }
624        }
625    }
626}