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

Commit 17229b9367

17229b93672c9d1625160da718a982761a3a9029

parent: 24212ca649

Unsigned

cmc <hello@cleberg.net> · 2026-10-19 05:27 UTC

Library redesign (!44)

* Front page
- Search field always visible; results as rows with thumbnails.
- Up tonight: a large card for the best-placed planet tonight, with when it's highest.
- Horizontally scrolling rows: Explore (timeline, scale, rockets), Sun and planets, Constellations up tonight, Brightest stars, Moons, Deep sky, Missions (newest first), Exoplanets, Rockets, Dwarf planets. Each row's See all opens its section.

* Sections
- Title, accent bar and one-line intro.
- Big sections are grouped into card rows, then All, A to Z with thumbnails: constellations (up tonight, zodiac), stars (brightest), deep sky (galaxies, nebulae, globular, open), moons (by planet), missions (by decade), rockets (flying now, retired). Small sections are a card grid.

* Pages
- With a photo: the photo full width under the status bar, title over it, caption and credit below.
- Up to three short facts as chips, Globe / Follow the mission as pills, then the essay, the drawing, a Related row, then the full facts table and sources.
- =LibraryPage.related()=: pages named in the facts (exact values first), then in the essay (including "Cassini" for "Cassini–Huygens"), then pages that mention this one; tested on Saturn and Europa.

* Thumbnails
- Photo where there is one; Library of Congress scans are cropped past their frame and colour bars. Otherwise a disc for bodies, a glow for stars (colour from B−V), the IAU figure for constellations (=ConstellationFigure=, extracted from PageDrawing).

Checked on the simulator: front page, Moons section, Europa, Sirius. Package tests pass; visionOS builds.

Layout: unified · split

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