App/Sources/LibraryScreen.swift
626 lines · 27503 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.
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}