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

6c4b28384d65e09486e0ad62f2ac9d10401cc80c
moonrise/App/Sources/LibraryScreen.swift history · blame · raw

630 lines · 27695 bytes

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