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 c7a97761a8

c7a97761a865f8dd23f03876dade1d030d8f8098

parent: c2c5268c22

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-18 01:26 UTC

Add Library: page model, tab, drawings and links

- Library package target: LibraryPage model, loader for bundled
  pages/*.json, search (title and subtitle before essay and facts).
- LibraryTests check every bundled page against docs/library-content.org:
  unique ids, sources cited by every fact, no Wikipedia, essay length,
  image licence and credit, links to known bodies, constellations, HR
  numbers, Messier numbers and spacecraft.
- App: Library tab (sections, search, page view with facts, essay,
  credited photo and source links); generated drawings for planets and
  moons to scale with Earth, constellation figures, stars by colour
  index, deep-sky size against the full Moon, mission status.
- Links: "Read more" on Sky cards; spacecraft names in the System tab.
- docs/library-content.org: content brief. scripts/check-library.py:
  URL checker.

Layout: unified · split

App/Sources/LibraryScreen.swift added +145
@@ -0,0 +1,145 @@
1import Library
2import SwiftUI
3
4private 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)
6private let pageBackground = Color(red: 0.043, green: 0.078, blue: 0.141)
7
8struct LibraryScreen: View {
9 @State private var query = ""
10
11 var body: some View {
12 NavigationStack {
13 List {
14 if query.isEmpty {
15 ForEach(LibrarySection.allCases) { section in
16 let pages = Library.pages(in: section)
17 if !pages.isEmpty {
18 NavigationLink(value: section) {
19 HStack {
20 Text(section.title).font(.system(size: 17))
21 Spacer()
22 Text("\(pages.count)").font(.system(size: 13).monospacedDigit()).opacity(0.5)
23 }
24 }
25 .listRowBackground(pageBackground)
26 }
27 }
28 } else {
29 ForEach(Library.search(query)) { page in
30 NavigationLink(value: page) { PageRow(page: page) }
31 .listRowBackground(pageBackground)
32 }
33 }
34 }
35 .listStyle(.plain)
36 .scrollContentBackground(.hidden)
37 .background(pageBackground.ignoresSafeArea())
38 .navigationTitle("Library")
39 .searchable(text: $query, prompt: "Saturn, Orion, M42, Voyager")
40 .navigationDestination(for: LibrarySection.self) { SectionList(section: $0) }
41 .navigationDestination(for: LibraryPage.self) { LibraryPageView(page: $0) }
42 }
43 .foregroundStyle(ink)
44 .tint(ink)
45 }
46}
47
48struct SectionList: View {
49 var section: LibrarySection
50
51 var body: some View {
52 List(Library.pages(in: section)) { page in
53 NavigationLink(value: page) { PageRow(page: page) }
54 .listRowBackground(pageBackground)
55 }
56 .listStyle(.plain)
57 .scrollContentBackground(.hidden)
58 .background(pageBackground.ignoresSafeArea())
59 .navigationTitle(section.title)
60 }
61}
62
63struct PageRow: View {
64 var page: LibraryPage
65
66 var body: some View {
67 VStack(alignment: .leading, spacing: 2) {
68 Text(page.title).font(.system(size: 17))
69 Text(page.subtitle).font(.system(size: 12)).opacity(0.6).lineLimit(1)
70 }
71 .padding(.vertical, 2)
72 }
73}
74
75struct LibraryPageView: View {
76 var page: LibraryPage
77
78 var body: some View {
79 ScrollView {
80 VStack(alignment: .leading, spacing: 0) {
81 Text(page.section.title).font(.system(size: 11, weight: .medium)).textCase(.uppercase).tracking(0.8).opacity(0.6)
82 Text(page.title).font(.system(size: 34, weight: .medium)).padding(.top, 4)
83 Text(page.subtitle).font(.system(size: 15)).opacity(0.75).padding(.top, 2)
84 Rectangle().fill(accent).frame(width: 48, height: 5).padding(.top, 10).padding(.bottom, 16)
85
86 PageDrawing(page: page, ink: ink, accent: accent)
87
88 facts.padding(.top, 16)
89
90 VStack(alignment: .leading, spacing: 12) {
91 ForEach(page.paragraphs, id: \.self) { Text($0).font(.system(size: 16)).lineSpacing(3) }
92 }
93 .padding(.top, 20)
94
95 if let image = page.image { photo(image).padding(.top, 24) }
96
97 sources.padding(.top, 24).padding(.bottom, 32)
98 }
99 .padding(.horizontal, 20)
100 .frame(maxWidth: .infinity, alignment: .leading)
101 }
102 .foregroundStyle(ink)
103 .background(pageBackground.ignoresSafeArea())
104 .navigationBarTitleDisplayMode(.inline)
105 }
106
107 private var facts: some View {
108 Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) {
109 ForEach(page.facts, id: \.self) { f in
110 GridRow(alignment: .firstTextBaseline) {
111 Text(f.label).font(.system(size: 12)).opacity(0.6)
112 Text(f.value).font(.system(size: 15).monospacedDigit())
113 }
114 Divider().overlay(ink.opacity(0.15))
115 }
116 }
117 }
118
119 private func photo(_ image: LibraryPage.Image) -> some View {
120 VStack(alignment: .leading, spacing: 6) {
121 AsyncImage(url: image.url) { phase in
122 switch phase {
123 case .success(let img): img.resizable().scaledToFit()
124 case .failure: Color.black.frame(height: 120).overlay(Text("Image unavailable").font(.system(size: 12)).opacity(0.6))
125 default: Color.black.frame(height: 200).overlay(ProgressView().tint(ink))
126 }
127 }
128 Text(image.caption).font(.system(size: 12)).opacity(0.75)
129 Link(destination: image.page) {
130 Text("\(image.credit) · \(image.licence)").font(.system(size: 10)).opacity(0.55)
131 }
132 }
133 }
134
135 private var sources: some View {
136 VStack(alignment: .leading, spacing: 6) {
137 Text("Sources").font(.system(size: 11, weight: .medium)).textCase(.uppercase).tracking(0.8).opacity(0.6)
138 ForEach(Array(page.sources.enumerated()), id: \.offset) { i, s in
139 Link(destination: s.url) {
140 Text("\(i + 1). \(s.title)").font(.system(size: 12)).underline().opacity(0.8)
141 }
142 }
143 }
144 }
145}
App/Sources/MoonriseApp.swift +1
@@ -15,6 +15,7 @@ struct MoonriseApp: App {
1515 Tab("System", systemImage: "circle.circle") { SystemScreen() }
1616 Tab("Sun", systemImage: "sun.max") { SunScreen() }
1717 Tab("Launches", systemImage: "airplane.departure") { LaunchesScreen() }
18 Tab("Library", systemImage: "books.vertical") { LibraryScreen() }
1819 }
1920 .preferredColorScheme(.dark)
2021 }
App/Sources/PageDrawing.swift added +182
@@ -0,0 +1,182 @@
1import Library
2import simd
3import SkyRenderer
4import SwiftUI
5
6/// The generated drawing at the top of a Library page, built from the page's data.
7struct PageDrawing: View {
8 var page: LibraryPage
9 var ink: Color
10 var accent: Color
11
12 var body: some View {
13 Group {
14 switch page.drawing?.kind {
15 case "planet": planet
16 case "constellation": constellation
17 case "star": star
18 case "deep-sky": deepSky
19 case "mission": mission
20 default: EmptyView()
21 }
22 }
23 .frame(maxWidth: .infinity)
24 }
25
26 // MARK: Planets, moons, the Sun
27
28 /// The body to scale with Earth beside it (or the Sun with Earth as a dot).
29 private var planet: some View {
30 let d = page.drawing!
31 let radius = d.radiusKm ?? 6371
32 let fill = Color(hex: d.color) ?? ink.opacity(0.6)
33 return Canvas { context, size in
34 let earthKm = 6371.0
35 let ringed = d.rings == true
36 // Rings reach about 2.2 body radii; leave room for the Earth label underneath.
37 let extent = ringed ? 2.25 : 1.0
38 let maxR = min(size.height / 2 - 16, (size.width - 24) / (2 * extent))
39 let scale = maxR / max(radius, earthKm)
40 let r = radius * scale, er = max(earthKm * scale, 1.5)
41 let c = CGPoint(x: size.width / 2, y: size.height / 2 - 8)
42 if ringed {
43 let ring = Path(ellipseIn: CGRect(x: c.x - r * 2.2, y: c.y - r * 0.45, width: r * 4.4, height: r * 0.9))
44 context.stroke(ring, with: .color(ink.opacity(0.7)), lineWidth: 1)
45 }
46 context.fill(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)), with: .color(fill))
47 context.stroke(Path(ellipseIn: CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)), with: .color(ink.opacity(0.8)), lineWidth: 0.8)
48 if let tilt = d.axialTiltDeg {
49 let a = tilt * .pi / 180
50 var axis = Path()
51 axis.move(to: CGPoint(x: c.x - sin(a) * r * 1.25, y: c.y - cos(a) * r * 1.25))
52 axis.addLine(to: CGPoint(x: c.x + sin(a) * r * 1.25, y: c.y + cos(a) * r * 1.25))
53 context.stroke(axis, with: .color(accent), style: StrokeStyle(lineWidth: 0.8, dash: [3, 3]))
54 }
55 if page.id != "earth" {
56 let ec = CGPoint(x: size.width - er - 6, y: size.height - er - 16)
57 context.fill(Path(ellipseIn: CGRect(x: ec.x - er, y: ec.y - er, width: 2 * er, height: 2 * er)), with: .color(Color(red: 0.35, green: 0.6, blue: 0.95)))
58 context.draw(Text("Earth").font(.system(size: 9)).foregroundStyle(ink.opacity(0.6)), at: CGPoint(x: size.width - 2, y: size.height - 6), anchor: .trailing)
59 }
60 }
61 .frame(height: 200)
62 .accessibilityLabel("\(page.title) drawn to scale with Earth")
63 }
64
65 // MARK: Constellations
66
67 private var constellation: some View {
68 let lines = Catalog.constellations.first { $0.id == page.link.constellation }?.lines ?? []
69 let byHR = Dictionary(uniqueKeysWithValues: Catalog.stars.map { ($0.hr, $0) })
70 let stars = Set(lines.joined()).compactMap { byHR[$0] }
71 return Canvas { context, size in
72 guard !stars.isEmpty else { return }
73 // Gnomonic projection about the figure's centre.
74 func unit(_ s: Star) -> SIMD3<Double> {
75 let ra = s.ra * .pi / 180, dec = s.dec * .pi / 180
76 return SIMD3(cos(dec) * cos(ra), cos(dec) * sin(ra), sin(dec))
77 }
78 var c = stars.map(unit).reduce(SIMD3<Double>(repeating: 0), +)
79 c /= (c * c).sum().squareRoot()
80 let e0 = SIMD3(-c.y, c.x, 0)
81 let east = simd_length(e0) < 1e-6 ? SIMD3<Double>(0, 1, 0) : simd_normalize(e0)
82 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)
83 func plane(_ s: Star) -> SIMD2<Double> {
84 let v = unit(s), k = (v * c).sum()
85 // East to the left, as on the sky.
86 return SIMD2(-(v * east).sum() / k, (v * north).sum() / k)
87 }
88 let pts = Dictionary(uniqueKeysWithValues: stars.map { ($0.hr, plane($0)) })
89 let xs = pts.values.map(\.x), ys = pts.values.map(\.y)
90 let span = max(xs.max()! - xs.min()!, ys.max()! - ys.min()!, 1e-6)
91 let scale = (min(size.width, size.height) - 24) / span
92 let mid = SIMD2((xs.max()! + xs.min()!) / 2, (ys.max()! + ys.min()!) / 2)
93 func point(_ hr: Int) -> CGPoint? {
94 pts[hr].map { CGPoint(x: size.width / 2 + ($0.x - mid.x) * scale, y: size.height / 2 - ($0.y - mid.y) * scale) }
95 }
96 var path = Path()
97 for line in lines {
98 for (a, b) in zip(line, line.dropFirst()) {
99 guard let pa = point(a), let pb = point(b) else { continue }
100 path.move(to: pa)
101 path.addLine(to: pb)
102 }
103 }
104 context.stroke(path, with: .color(ink.opacity(0.45)), lineWidth: 0.8)
105 for s in stars {
106 guard let p = point(s.hr) else { continue }
107 let r = max(1.2, 4.2 - 0.6 * s.magnitude)
108 context.fill(Path(ellipseIn: CGRect(x: p.x - r, y: p.y - r, width: 2 * r, height: 2 * r)), with: .color(ink))
109 }
110 }
111 .frame(height: 240)
112 .accessibilityLabel("Figure of \(page.title)")
113 }
114
115 // MARK: Stars
116
117 private var star: some View {
118 let bv = page.drawing?.colorIndex ?? 0.6
119 let t = min(max((bv + 0.3) / 2.0, 0), 1)
120 let color = Color(red: 0.75 + 0.25 * t, green: 0.82 - 0.1 * t, blue: 1.0 - 0.45 * t)
121 return VStack(spacing: 8) {
122 Circle().fill(color).frame(width: 90, height: 90)
123 if let sp = page.drawing?.spectralType {
124 Text("Spectral type \(sp) · B−V \(String(format: "%.2f", bv))").font(.system(size: 11)).opacity(0.6)
125 }
126 }
127 .frame(height: 140)
128 }
129
130 // MARK: Deep sky
131
132 /// The object's apparent size beside the full Moon (about 31′).
133 private var deepSky: some View {
134 let d = page.drawing!
135 let size = d.sizeArcmin ?? 5
136 return Canvas { context, canvas in
137 let moon = 31.0
138 let scale = (canvas.height - 30) / max(size, moon)
139 let r = size * scale / 2, mr = moon * scale / 2
140 let c = CGPoint(x: canvas.width / 2 - mr - 10, y: canvas.height / 2 - 8)
141 let m = CGPoint(x: canvas.width / 2 + r + 10, y: canvas.height / 2 - 8)
142 let rect = CGRect(x: c.x - r, y: c.y - r, width: 2 * r, height: 2 * r)
143 switch d.type {
144 case "galaxy":
145 context.fill(Path(ellipseIn: rect.insetBy(dx: 0, dy: r * 0.6)), with: .color(ink.opacity(0.5)))
146 case "planetary":
147 context.stroke(Path(ellipseIn: rect), with: .color(ink.opacity(0.8)), lineWidth: max(1, r * 0.25))
148 case "globular", "open":
149 context.fill(Path(ellipseIn: rect), with: .color(ink.opacity(d.type == "globular" ? 0.45 : 0.15)))
150 default:
151 context.fill(Path(ellipseIn: rect), with: .color(ink.opacity(0.3)))
152 }
153 context.stroke(Path(ellipseIn: CGRect(x: m.x - mr, y: m.y - mr, width: 2 * mr, height: 2 * mr)), with: .color(ink.opacity(0.5)), style: StrokeStyle(lineWidth: 0.8, dash: [3, 3]))
154 context.draw(Text("\(Int(size.rounded()))′").font(.system(size: 10)).foregroundStyle(ink.opacity(0.7)), at: CGPoint(x: c.x, y: canvas.height - 10))
155 context.draw(Text("Full Moon").font(.system(size: 10)).foregroundStyle(ink.opacity(0.7)), at: CGPoint(x: m.x, y: canvas.height - 10))
156 }
157 .frame(height: 170)
158 .accessibilityLabel("\(page.title) apparent size compared with the full Moon")
159 }
160
161 // MARK: Missions
162
163 private var mission: some View {
164 let d = page.drawing!
165 return HStack(spacing: 10) {
166 Text((d.status ?? "").uppercased())
167 .font(.system(size: 10, weight: .medium)).tracking(0.6)
168 .padding(.horizontal, 6).padding(.vertical, 2)
169 .foregroundStyle(d.status == "active" ? Color.black : ink)
170 .background(d.status == "active" ? ink : ink.opacity(0.15), in: .rect(cornerRadius: 2))
171 if let launch = d.launch { Text("Launched \(launch)").font(.system(size: 13).monospacedDigit()) }
172 Spacer()
173 }
174 }
175}
176
177extension Color {
178 init?(hex: String?) {
179 guard let hex, hex.hasPrefix("#"), hex.count == 7, let v = UInt32(hex.dropFirst(), radix: 16) else { return nil }
180 self.init(red: Double(v >> 16 & 0xFF) / 255, green: Double(v >> 8 & 0xFF) / 255, blue: Double(v & 0xFF) / 255)
181 }
182}
App/Sources/SkyScreen.swift +19
@@ -1,3 +1,4 @@
1import Library
12import SkyEngine
23import SkyRenderer
34import SwiftUI
@@ -10,6 +11,7 @@ struct SkyScreen: View {
1011 @State private var fieldOfView = 100.0
1112 @State private var night = false
1213 @State private var showToday = false
14 @State private var libraryPage: LibraryPage?
1315 @State private var selection: SkyObject?
1416 @State private var details: BodyDetails?
1517 @GestureState private var drag: CGSize = .zero
@@ -44,6 +46,9 @@ struct SkyScreen: View {
4446 }
4547 }
4648 .foregroundStyle(ink)
49 .sheet(item: $libraryPage) { page in
50 NavigationStack { LibraryPageView(page: page) }
51 }
4752 .sheet(isPresented: $showToday) {
4853 TodaySheet(model: model, ink: ink, background: style.ground)
4954 .presentationDetents([.medium, .large])
@@ -149,12 +154,26 @@ struct SkyScreen: View {
149154 case .constellation(let id):
150155 Text("Constellation · \(id)").font(.system(size: 13)).opacity(0.7)
151156 }
157 if let page = libraryPage(for: object) {
158 Button { libraryPage = page } label: {
159 Label("Read more", systemImage: "book").font(.system(size: 13, weight: .medium))
160 }
161 .buttonStyle(.plain)
162 .padding(.top, 4)
163 }
152164 }
153165 .padding(16)
154166 .background(style.background.opacity(0.92), in: .rect(cornerRadius: 12))
155167 .overlay(RoundedRectangle(cornerRadius: 12).stroke(ink.opacity(0.25), lineWidth: 0.5))
156168 }
157169
170 private func libraryPage(for object: SkyObject) -> LibraryPage? {
171 switch object {
172 case .body(let b): Library.page(body: b.rawValue)
173 case .constellation(let id): Library.page(constellation: id)
174 }
175 }
176
158177 private func title(for object: SkyObject) -> String {
159178 switch object {
160179 case .body(let b): b.rawValue.capitalized
App/Sources/SystemScreen.swift +10 −1
@@ -1,3 +1,4 @@
1import Library
12import SkyRenderer
23import SwiftUI
34
@@ -5,6 +6,7 @@ struct SystemScreen: View {
56 /// Days from now shown by the slider.
67 @State private var offsetDays = 0.0
78 @State private var playing = false
9 @State private var libraryPage: LibraryPage?
810
911 private let ink = Color(red: 0.95, green: 0.95, blue: 0.93)
1012 private let accent = Color(red: 0.88, green: 0.24, blue: 0.19)
@@ -53,7 +55,11 @@ struct SystemScreen: View {
5355 .font(.system(size: 11))
5456 ForEach(Spacecraft.all) { craft in
5557 GridRow {
56 Text(craft.name)
58 if let page = Library.page(spacecraft: craft.name) {
59 Button { libraryPage = page } label: { Text(craft.name).underline() }.buttonStyle(.plain)
60 } else {
61 Text(craft.name)
62 }
5763 if let d = craft.distance(at: date) {
5864 Text(String(format: "%.1f AU", d)).monospacedDigit()
5965 Text(lightTime(au: d)).monospacedDigit()
@@ -73,6 +79,9 @@ struct SystemScreen: View {
7379 }
7480 .foregroundStyle(ink)
7581 .background(background.ignoresSafeArea())
82 .sheet(item: $libraryPage) { page in
83 NavigationStack { LibraryPageView(page: page) }
84 }
7685 .task(id: playing) {
7786 while playing && !Task.isCancelled {
7887 try? await Task.sleep(for: .milliseconds(33))
App/project.yml +2
@@ -18,6 +18,8 @@ targets:
1818 product: SkyRenderer
1919 - package: MoonriseKit
2020 product: Feeds
21 - package: MoonriseKit
22 product: Library
2123 - target: MoonriseWidgets
2224 entitlements:
2325 path: Sources/Moonrise.entitlements
Packages/MoonriseKit/Package.swift +3
@@ -8,6 +8,7 @@ let package = Package(
88 .library(name: "SkyEngine", targets: ["SkyEngine"]),
99 .library(name: "SkyRenderer", targets: ["SkyRenderer"]),
1010 .library(name: "Feeds", targets: ["Feeds"]),
11 .library(name: "Library", targets: ["Library"]),
1112 ],
1213 targets: [
1314 // Astronomy Engine v2.1.19 (MIT), https://github.com/cosinekitty/astronomy
@@ -23,8 +24,10 @@ let package = Package(
2324 resources: [.copy("Resources/bsc5.json"), .copy("Resources/constellations.json"), .copy("Resources/coastline.json"), .copy("Resources/spacecraft.json")]
2425 ),
2526 .target(name: "Feeds"),
27 .target(name: "Library", resources: [.copy("Resources/pages")]),
2628 .testTarget(name: "SkyEngineTests", dependencies: ["SkyEngine"]),
2729 .testTarget(name: "FeedsTests", dependencies: ["Feeds"]),
30 .testTarget(name: "LibraryTests", dependencies: ["Library", "SkyRenderer", "SkyEngine"]),
2831 .testTarget(name: "SkyRendererTests", dependencies: ["SkyRenderer"]),
2932 ]
3033)
Packages/MoonriseKit/Sources/Library/Library.swift added +138
@@ -0,0 +1,138 @@
1import Foundation
2
3/// A reference page. Schema and writing rules: docs/library-content.org.
4public struct LibraryPage: Codable, Sendable, Identifiable, Hashable {
5 public struct Fact: Codable, Sendable, Hashable {
6 public var label: String
7 public var value: String
8 /// Index into `sources`.
9 public var source: Int
10 }
11
12 public struct Source: Codable, Sendable, Hashable {
13 public var title: String
14 public var url: URL
15 }
16
17 public struct Image: Codable, Sendable, Hashable {
18 public var url: URL
19 public var credit: String
20 public var licence: String
21 /// Page the image is published on.
22 public var page: URL
23 public var caption: String
24 }
25
26 /// Parameters for the page's generated drawing; which fields are set depends on `kind`.
27 public struct Drawing: Codable, Sendable, Hashable {
28 public var kind: String
29 public var radiusKm: Double?
30 public var rings: Bool?
31 public var axialTiltDeg: Double?
32 public var color: String?
33 public var spectralType: String?
34 public var colorIndex: Double?
35 public var type: String?
36 public var sizeArcmin: Double?
37 public var status: String?
38 public var launch: String?
39 }
40
41 /// What the page describes elsewhere in the app.
42 public struct Link: Codable, Sendable, Hashable {
43 public var body: String?
44 public var constellation: String?
45 public var hr: Int?
46 public var messier: Int?
47 public var spacecraft: String?
48 }
49
50 public var id: String
51 public var section: LibrarySection
52 public var title: String
53 public var subtitle: String
54 public var facts: [Fact]
55 public var essay: String
56 public var image: Image?
57 public var drawing: Drawing?
58 public var link: Link
59 public var sources: [Source]
60
61 public var paragraphs: [String] {
62 essay.components(separatedBy: "\n\n").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty }
63 }
64
65 public var wordCount: Int {
66 essay.split { $0.isWhitespace }.count
67 }
68}
69
70public enum LibrarySection: String, Codable, Sendable, CaseIterable, Identifiable {
71 case sun, planets, dwarfPlanets = "dwarf-planets", moons, constellations, stars, deepSky = "deep-sky", missions
72
73 public var id: String { rawValue }
74
75 public var title: String {
76 switch self {
77 case .sun: "The Sun"
78 case .planets: "Planets"
79 case .dwarfPlanets: "Dwarf planets"
80 case .moons: "Moons"
81 case .constellations: "Constellations"
82 case .stars: "Stars"
83 case .deepSky: "Deep sky"
84 case .missions: "Missions"
85 }
86 }
87}
88
89public enum Library {
90 /// All pages from the bundled `pages/*.json` files, in file order within each section.
91 public static let pages: [LibraryPage] = {
92 guard let dir = Bundle.module.url(forResource: "pages", withExtension: nil),
93 let files = try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil) else { return [] }
94 return files
95 .filter { $0.pathExtension == "json" }
96 .sorted { $0.lastPathComponent < $1.lastPathComponent }
97 .flatMap { url -> [LibraryPage] in
98 do {
99 return try JSONDecoder().decode([LibraryPage].self, from: Data(contentsOf: url))
100 } catch {
101 assertionFailure("\(url.lastPathComponent): \(error)")
102 return []
103 }
104 }
105 }()
106
107 public static func pages(in section: LibrarySection) -> [LibraryPage] {
108 pages.filter { $0.section == section }
109 }
110
111 public static func page(id: String) -> LibraryPage? {
112 pages.first { $0.id == id }
113 }
114
115 public static func page(body: String) -> LibraryPage? {
116 pages.first { $0.link.body == body }
117 }
118
119 public static func page(constellation: String) -> LibraryPage? {
120 pages.first { $0.link.constellation == constellation }
121 }
122
123 public static func page(spacecraft: String) -> LibraryPage? {
124 pages.first { $0.link.spacecraft == spacecraft }
125 }
126
127 /// Case- and diacritic-insensitive match on title and subtitle first, then facts and essay.
128 public static func search(_ query: String) -> [LibraryPage] {
129 let q = query.trimmingCharacters(in: .whitespaces)
130 guard !q.isEmpty else { return [] }
131 let opts: String.CompareOptions = [.caseInsensitive, .diacriticInsensitive]
132 let strong = pages.filter { $0.title.range(of: q, options: opts) != nil || $0.subtitle.range(of: q, options: opts) != nil || $0.id == q.lowercased() }
133 let weak = pages.filter { p in
134 !strong.contains(p) && (p.essay.range(of: q, options: opts) != nil || p.facts.contains { $0.value.range(of: q, options: opts) != nil })
135 }
136 return strong + weak
137 }
138}
Packages/MoonriseKit/Sources/Library/Resources/pages/.gitkeep added
Packages/MoonriseKit/Tests/LibraryTests/LibraryTests.swift added +49
@@ -0,0 +1,49 @@
1import Foundation
2import SkyEngine
3import Testing
4@testable import Library
5@testable import SkyRenderer
6
7// Checks every bundled page against the rules in docs/library-content.org.
8
9let allowedLicences: Set<String> = ["Public domain", "CC BY 4.0", "CC BY-SA 3.0 IGO"]
10
11@Test func idsAreUnique() {
12 let ids = Library.pages.map(\.id)
13 #expect(Set(ids).count == ids.count, "duplicates: \(Dictionary(grouping: ids, by: { $0 }).filter { $1.count > 1 }.keys.sorted())")
14}
15
16@Test(arguments: Library.pages)
17func pageFollowsTheBrief(_ p: LibraryPage) {
18 #expect(!p.title.isEmpty && !p.subtitle.isEmpty, "\(p.id): title/subtitle")
19 #expect((1...5).contains(p.sources.count), "\(p.id): \(p.sources.count) sources")
20 #expect((3...9).contains(p.facts.count), "\(p.id): \(p.facts.count) facts")
21 for f in p.facts {
22 #expect(p.sources.indices.contains(f.source), "\(p.id): fact '\(f.label)' cites missing source \(f.source)")
23 }
24 #expect(!p.sources.contains { $0.url.host()?.contains("wikipedia.org") == true }, "\(p.id): Wikipedia as a source")
25 #expect((120...300).contains(p.wordCount), "\(p.id): essay \(p.wordCount) words")
26 if let image = p.image {
27 #expect(allowedLicences.contains(image.licence), "\(p.id): licence \(image.licence)")
28 #expect(image.url.scheme == "https", "\(p.id): image URL")
29 #expect(!image.credit.isEmpty, "\(p.id): image credit")
30 } else {
31 #expect([.constellations, .stars, .missions].contains(p.section), "\(p.id): missing image")
32 }
33}
34
35@Test(arguments: Library.pages)
36func linksPointAtThingsTheAppKnows(_ p: LibraryPage) {
37 let l = p.link
38 if let body = l.body { #expect(Body(rawValue: body) != nil, "\(p.id): body \(body)") }
39 if let c = l.constellation { #expect(constellationNames[c] != nil, "\(p.id): constellation \(c)") }
40 if let hr = l.hr { #expect(Catalog.stars.contains { $0.hr == hr }, "\(p.id): HR \(hr)") }
41 if let m = l.messier { #expect((1...110).contains(m), "\(p.id): M\(m)") }
42 if let s = l.spacecraft { #expect(Spacecraft.all.contains { $0.name == s }, "\(p.id): spacecraft \(s)") }
43}
44
45@Test func searchFindsByTitleBeforeEssay() {
46 guard let first = Library.pages.first else { return }
47 #expect(Library.search(first.title).first?.id == first.id)
48 #expect(Library.search(" ").isEmpty)
49}
docs/library-content.org added +76
@@ -0,0 +1,76 @@
1#+title: Library content brief
2
3Pages live in =Packages/MoonriseKit/Sources/Library/Resources/pages/<section>.json=,
4one file per section, each a JSON array of pages.
5
6* Page schema
7#+begin_src json
8{
9 "id": "mars",
10 "section": "planets",
11 "title": "Mars",
12 "subtitle": "Fourth planet from the Sun",
13 "facts": [
14 { "label": "Mean distance from the Sun", "value": "1.524 AU (227.9 million km)", "source": 0 }
15 ],
16 "essay": "Plain text, paragraphs separated by a blank line.",
17 "image": {
18 "url": "https://… direct .jpg or .png",
19 "credit": "NASA/JPL-Caltech",
20 "licence": "Public domain",
21 "page": "https://… page the image is published on",
22 "caption": "One sentence on what the image shows."
23 },
24 "drawing": { "kind": "planet", "radiusKm": 3389.5, "rings": false, "axialTiltDeg": 25.19, "color": "#C1440E" },
25 "link": { "body": "mars" },
26 "sources": [
27 { "title": "NASA Mars Fact Sheet", "url": "https://nssdc.gsfc.nasa.gov/planetary/factsheet/marsfact.html" }
28 ]
29}
30#+end_src
31
32- =id=: lowercase, hyphenated, unique across all sections (e.g. =m42=, =ori=, =sirius=, =voyager-1=).
33- =section=: one of =sun=, =planets=, =dwarf-planets=, =moons=, =constellations=, =stars=, =deep-sky=, =missions=.
34- =facts=: 4–8 items. Every fact has =source=, an index into =sources=. Units in SI or AU, with conversions where helpful. Use tabular style: "10 h 33 m", "29.4 Earth years".
35- =sources=: 1–5 items, primary or authoritative (NASA NSSDC fact sheets, NASA science pages, JPL, IAU, ESA, NOIRLab, SIMBAD). Every URL must return HTTP 200 when checked.
36- =image=: optional only for constellations, stars and missions when no verifiable image exists (set to =null=).
37- =drawing=: per section:
38 - planets, dwarf planets, moons, sun: ={"kind": "planet", "radiusKm", "rings", "axialTiltDeg", "color"}=; =color= is a representative hex.
39 - constellations: ={"kind": "constellation"}= (the app draws the figure from its line data).
40 - stars: ={"kind": "star", "spectralType": "A1V", "colorIndex": 0.0}= (B−V).
41 - deep sky: ={"kind": "deep-sky", "type": "galaxy|globular|open|nebula|planetary|supernova-remnant", "sizeArcmin": 90}=.
42 - missions: ={"kind": "mission", "status": "active|complete|lost", "launch": "1977-09-05"}=.
43- =link=: what the page describes in the rest of the app: ={"body": "mars"}=, ={"constellation": "Ori"}= (IAU abbreviation), ={"hr": 2491}= (Bright Star Catalogue number), ={"messier": 42}=, ={"spacecraft": "Voyager 1"}=, or ={}=.
44
45* Essay voice
46- 150–250 words, two or three short paragraphs.
47- Plain, direct English, as a knowledgeable colleague would write. No hype,
48 no dramatic framing, no rhetorical questions, no "fascinating",
49 "stunning", "mysterious", "breathtaking".
50- Concrete facts over adjectives: what it is, how we know, one or two
51 things that make it distinctive, and for observable objects how to find
52 it or what it looks like to the eye or a small telescope.
53- Original wording. Draw on public-domain NASA/JPL text and on facts
54 from other sources, but never copy sentences from Wikipedia (CC BY-SA)
55 or any copyrighted text.
56- Present tense for current facts; dates as "1977", "2034".
57
58* Images and licences
59Allowed, in order of preference:
601. NASA (images-api.nasa.gov, images-assets.nasa.gov, science.nasa.gov,
61 photojournal.jpl.nasa.gov): =licence: "Public domain"=. Skip images
62 whose NASA page credits a third party or says they are copyrighted.
632. ESA/Hubble (esahubble.org), ESA/Webb (esawebb.org), NOIRLab
64 (noirlab.edu), ESO (eso.org): =licence: "CC BY 4.0"=, =credit= copied
65 exactly from the image page. No images showing identifiable people.
663. Historical star atlas plates in the public domain (Library of
67 Congress, Linda Hall Library) for constellations: =licence: "Public domain"=.
68
69Use a direct file URL of a moderate size (roughly 800–2000 px; NASA
70=~medium.jpg= or =~large.jpg=, ESA "large"/"screen" sizes). Check every
71image URL with =curl -sI= and keep only HTTP 200 with an image content type.
72
73* Do not
74- Invent values. If a fact cannot be sourced, leave it out.
75- Use Wikipedia as a =sources= entry.
76- Touch any file other than your own section file. No git commands.
scripts/check-library.py added +61
@@ -0,0 +1,61 @@
1#!/usr/bin/env python3
2"""Check Library pages: JSON shape, and that every source and image URL resolves.
3
4Usage: scripts/check-library.py [--offline]
5"""
6import json
7import pathlib
8import sys
9import urllib.request
10from concurrent.futures import ThreadPoolExecutor
11
12PAGES = pathlib.Path(__file__).resolve().parent.parent / "Packages/MoonriseKit/Sources/Library/Resources/pages"
13UA = {"User-Agent": "moonrise-library-check (sh.krz.moonrise)"}
14
15
16def status(url, image=False):
17 for method in ("HEAD", "GET"):
18 try:
19 req = urllib.request.Request(url, method=method, headers=UA)
20 with urllib.request.urlopen(req, timeout=20) as r:
21 kind = r.headers.get("content-type", "")
22 if image and not kind.startswith("image/"):
23 return f"{r.status} {kind}"
24 return r.status
25 except Exception as e:
26 err = getattr(e, "code", None) or type(e).__name__
27 return err
28
29
30def main():
31 offline = "--offline" in sys.argv
32 pages, problems = [], []
33 for f in sorted(PAGES.glob("*.json")):
34 for p in json.loads(f.read_text()):
35 pages.append((f.name, p))
36 print(f"{len(pages)} pages in {len(list(PAGES.glob('*.json')))} files")
37 if offline:
38 return 0
39 checks = []
40 for name, p in pages:
41 for s in p.get("sources", []):
42 checks.append((p["id"], "source", s["url"], False))
43 if p.get("image"):
44 checks.append((p["id"], "image", p["image"]["url"], True))
45 checks.append((p["id"], "image page", p["image"]["page"], False))
46 with ThreadPoolExecutor(8) as pool:
47 results = list(pool.map(lambda c: (c, status(c[2], c[3])), checks))
48 for (pid, kind, url, _), st in results:
49 # loc.gov item pages send scripts to a bot check (403); the items exist.
50 if st == 403 and kind == "image page" and url.startswith("https://www.loc.gov/item/"):
51 continue
52 if st != 200:
53 problems.append(f"{pid}: {kind} {st} {url}")
54 print(f"{len(checks)} URLs checked, {len(problems)} problems")
55 for line in problems:
56 print(" " + line)
57 return 1 if problems else 0
58
59
60if __name__ == "__main__":
61 sys.exit(main())