| @@ -1,136 +1,612 @@ |
| |
1 | import CoreLocation |
| 1 | import Library |
2 | import Library |
| |
3 | import SkyEngine |
| |
4 | import SkyRenderer |
| 2 | import SwiftUI |
5 | import SwiftUI |
| 3 | |
6 | |
| 4 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) |
7 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) |
| 5 | private let accent = Color(red: 0.88, green: 0.24, blue: 0.19) |
8 | private let accent = Color(red: 0.88, green: 0.24, blue: 0.19) |
| 6 | private let pageBackground = Color.black |
9 | private let pageBackground = Color.black |
| |
10 | private let tile = Color(white: 0.08) |
| |
11 | |
| |
12 | /// The observer from Settings (a chosen city) or the last known location, else Chicago. |
| |
13 | private 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. |
| |
23 | private 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 | |
| 8 | struct LibraryScreen: View { |
57 | struct 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 | |
| 62 | struct 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. |
| |
192 | private 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 | |
| 77 | struct PageRow: View { |
225 | /// A page as a card: picture or drawing, title, up to two lines of subtitle. |
| |
226 | struct 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 | |
| 89 | struct LibraryPageView: View { |
246 | private 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. |
| |
273 | struct 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 | |
| |
343 | struct 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 | |
| |
367 | struct 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 | |
| |
468 | struct 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 | } |