| @@ -1,136 +1,612 @@ |
| 1 | import CoreLocation |
| 1 | 2 | import Library |
| 3 | import SkyEngine |
| 4 | import SkyRenderer |
| 2 | 5 | import SwiftUI |
| 3 | 6 | |
| 4 | 7 | private let ink = Color(red: 0.95, green: 0.95, blue: 0.93) |
| 5 | 8 | private let accent = Color(red: 0.88, green: 0.24, blue: 0.19) |
| 6 | 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 | 57 | struct LibraryScreen: View { |
| 9 | 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 | 77 | var body: some View { |
| 12 | 78 | NavigationStack { |
| 13 | | List { |
| 14 | | if query.isEmpty { |
| 15 | | Section { |
| 16 | | NavigationLink("Timeline of exploration") { ExplorationTimeline() } |
| 17 | | .listRowBackground(pageBackground) |
| 18 | | NavigationLink("Scale of the universe") { ScaleScreen(ink: ink, accent: accent) } |
| 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 | | } |
| 79 | ScrollView { |
| 80 | VStack(alignment: .leading, spacing: 0) { |
| 81 | VStack(alignment: .leading, spacing: 0) { |
| 82 | Text("Library").font(.title.weight(.medium)) |
| 83 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 14) |
| 84 | searchField |
| 28 | 85 | } |
| 29 | | ForEach(LibrarySection.allCases) { section in |
| 30 | | let pages = Library.pages(in: section) |
| 31 | | if !pages.isEmpty { |
| 32 | | NavigationLink(value: section) { |
| 33 | | HStack { |
| 34 | | Text(section.title).font(.body) |
| 35 | | Spacer() |
| 36 | | Text("\(pages.count)").font(.footnote.monospacedDigit()).dimmed(0.5) |
| 37 | | } |
| 86 | .padding(.horizontal, 20) |
| 87 | |
| 88 | if query.isEmpty { |
| 89 | if let p = picks.planet { featured(p).padding(.horizontal, 20).padding(.top, 18) } |
| 90 | CardRow(title: "Explore") { exploreCards } |
| 91 | ForEach(rows, id: \.0) { title, section, pages in |
| 92 | CardRow(title: title, section: section) { |
| 93 | ForEach(pages) { PageCard(page: $0) } |
| 38 | 94 | } |
| 39 | | .listRowBackground(pageBackground) |
| 40 | 95 | } |
| 41 | | } |
| 42 | | } else { |
| 43 | | ForEach(Library.search(query)) { page in |
| 44 | | NavigationLink(value: page) { PageRow(page: page) } |
| 45 | | .listRowBackground(pageBackground) |
| 96 | Text("Pages draw on NASA, ESA, ESO, the IAU and other sources, listed on each page.") |
| 97 | .font(.caption2).dimmed(0.5).padding(20) |
| 98 | } else { |
| 99 | searchResults |
| 46 | 100 | } |
| 47 | 101 | } |
| 102 | .padding(.top, 8) |
| 48 | 103 | } |
| 49 | | .listStyle(.plain) |
| 50 | | .scrollContentBackground(.hidden) |
| 104 | .scrollDismissesKeyboard(.immediately) |
| 51 | 105 | .background(pageBackground.ignoresSafeArea()) |
| 52 | | .navigationTitle("Library") |
| 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) } |
| 106 | .toolbar(.hidden, for: .navigationBar) |
| 56 | 107 | } |
| 57 | 108 | .foregroundStyle(ink) |
| 58 | 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 { |
| 63 | | var section: LibrarySection |
| 189 | // MARK: - Building blocks |
| 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 | 197 | var body: some View { |
| 66 | | List(Library.pages(in: section)) { page in |
| 67 | | NavigationLink(value: page) { PageRow(page: page) } |
| 68 | | .listRowBackground(pageBackground) |
| 198 | VStack(alignment: .leading, spacing: 10) { |
| 199 | HStack(alignment: .firstTextBaseline) { |
| 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) |
| 71 | | .scrollContentBackground(.hidden) |
| 72 | | .background(pageBackground.ignoresSafeArea()) |
| 73 | | .navigationTitle(section.title) |
| 221 | .padding(.top, 26) |
| 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 | 227 | var page: LibraryPage |
| 228 | var width: CGFloat = 150 |
| 79 | 229 | |
| 80 | 230 | var body: some View { |
| 81 | | VStack(alignment: .leading, spacing: 2) { |
| 82 | | Text(page.title).font(.body) |
| 83 | | Text(page.subtitle).font(.caption).dimmed(0.7) |
| 231 | NavigationLink { LibraryPageView(page: page) } label: { |
| 232 | VStack(alignment: .leading, spacing: 4) { |
| 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 | 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 | 421 | var body: some View { |
| 93 | 422 | ScrollView { |
| 94 | 423 | VStack(alignment: .leading, spacing: 0) { |
| 95 | | Text(page.section.title).font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6) |
| 96 | | Text(page.title).font(.largeTitle.weight(.medium)).padding(.top, 4) |
| 97 | | Text(page.subtitle).font(.subheadline).dimmed(0.75).padding(.top, 2) |
| 98 | | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 10).padding(.bottom, 16) |
| 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) |
| 424 | VStack(alignment: .leading, spacing: 0) { |
| 425 | Text(section.title).font(.title.weight(.medium)) |
| 426 | Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 8).padding(.bottom, 12) |
| 427 | Text(intro).font(.subheadline).dimmed(0.75) |
| 106 | 428 | } |
| 107 | | if page.drawing?.kind == "planet" { |
| 108 | | NavigationLink { GlobeView(page: page, credit: GlobeMaps.credit[page.id]) } label: { |
| 109 | | Label("View as a globe", systemImage: "globe").font(.subheadline) |
| 429 | .padding(.horizontal, 20) |
| 430 | |
| 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) { |
| 117 | | ForEach(page.paragraphs, id: \.self) { Text($0).font(.body).lineSpacing(3) } |
| 494 | if page.image != nil, page.drawing != nil { |
| 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 | 516 | .foregroundStyle(ink) |
| 130 | 517 | .background(pageBackground.ignoresSafeArea()) |
| 131 | 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 | 610 | private var facts: some View { |
| 135 | 611 | Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { |
| 136 | 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 | 623 | private var sources: some View { |
| 164 | 624 | VStack(alignment: .leading, spacing: 6) { |
| 165 | 625 | Text("Sources").font(.caption2.weight(.medium)).textCase(.uppercase).tracking(0.8).dimmed(0.6) |
| 166 | 626 | ForEach(Array(page.sources.enumerated()), id: \.offset) { i, s in |
| 167 | 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 | } |