App/Sources/LibraryScreen.swift
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}