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 2cf5396467

2cf539646755c63c687deab8b4264647b0a43a3b

parent: 2e552960dd

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-19 06:36 UTC

Performance: downsampled images, larger URL cache, async textures, faster search, cached passes and feeds, server cache headers

Layout: unified · split

App/Sources/LaunchesScreen.swift +5 −2
@@ -49,7 +49,7 @@ struct LaunchesScreen: View {
49 } 49 }
50 .foregroundStyle(ink) 50 .foregroundStyle(ink)
51 .background(background.ignoresSafeArea()) 51 .background(background.ignoresSafeArea())
52 .refreshable { if router.showNews { newsReload += 1 } else { await load() } } 52 .refreshable { if router.showNews { newsReload += 1 } else { await load(force: true) } }
53 .task { await load() } 53 .task { await load() }
54 .sheet(item: $selected) { LaunchDetail(launch: $0, ink: ink, accent: accent, background: background) } 54 .sheet(item: $selected) { LaunchDetail(launch: $0, ink: ink, accent: accent, background: background) }
55 } 55 }
@@ -71,10 +71,13 @@ struct LaunchesScreen: View {
71 } 71 }
72 } 72 }
73 73
74 private func load() async { 74 /// The backend polls every 10 minutes; a visit within 5 minutes of the last load reuses it.
75 private func load(force: Bool = false) async {
75 guard let apiBase else { error = "No backend configured for this build."; return } 76 guard let apiBase else { error = "No backend configured for this build."; return }
77 if !force, let cached: LaunchList = FeedMemo.get("launches", maxAge: 300) { list = cached; return }
76 do { 78 do {
77 list = try await LaunchFeed.fetch(base: apiBase) 79 list = try await LaunchFeed.fetch(base: apiBase)
80 if let list { FeedMemo.set("launches", list) }
78 error = nil 81 error = nil
79 } catch { 82 } catch {
80 self.error = "Couldn't load launches. Pull to retry." 83 self.error = "Couldn't load launches. Pull to retry."
App/Sources/LibraryScreen.swift +5 −17
@@ -39,10 +39,9 @@ private struct TonightPicks: Sendable {
39 } 39 }
40 // Constellations whose stars are on average more than 25° up two hours into the night. 40 // Constellations whose stars are on average more than 25° up two hours into the night.
41 let when = t.start.addingTimeInterval(2 * 3600) 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 42 let scored: [(LibraryPage, Double)] = Catalog.constellations.compactMap { c in
44 guard let page = Library.page(constellation: c.id) else { return nil } 43 guard let page = Library.page(constellation: c.id) else { return nil }
45 let stars = Set(c.lines.joined()).compactMap { byHR[$0] } 44 let stars = Set(c.lines.joined()).compactMap { Catalog.starsByHR[$0] }
46 guard !stars.isEmpty else { return nil } 45 guard !stars.isEmpty else { return nil }
47 let alts = SkyEngine.horizontal(j2000: stars.map { $0.position(at: when) }, at: when, from: observer).map(\.altitude) 46 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)) 47 return (page, alts.reduce(0, +) / Double(alts.count))
@@ -283,14 +282,9 @@ struct PageThumb: View {
283 ZStack { 282 ZStack {
284 tile 283 tile
285 if let image = page.image { 284 if let image = page.image {
286 AsyncImage(url: image.url) { phase in 285 // Library of Congress scans carry a frame and colour bars; crop to the plate.
287 if case .success(let img) = phase { 286 RemoteImage(url: image.url, points: large ? 640 : 260) { fallback }
288 // Library of Congress scans carry a frame and colour bars; crop to the plate. 287 .scaleEffect(image.url.host() == "tile.loc.gov" ? 1.45 : 1)
289 img.resizable().scaledToFill().scaleEffect(image.url.host() == "tile.loc.gov" ? 1.45 : 1)
290 } else {
291 fallback
292 }
293 }
294 } else { 288 } else {
295 fallback 289 fallback
296 } 290 }
@@ -529,13 +523,7 @@ struct LibraryPageView: View {
529 Color(white: 0.06) 523 Color(white: 0.06)
530 .frame(height: 340) 524 .frame(height: 340)
531 .overlay { 525 .overlay {
532 AsyncImage(url: image.url) { phase in 526 RemoteImage(url: image.url, points: 900) { ProgressView().tint(ink) }
533 switch phase {
534 case .success(let img): img.resizable().scaledToFill()
535 case .failure: Text("Image unavailable").font(.caption).dimmed(0.6)
536 default: ProgressView().tint(ink)
537 }
538 }
539 } 527 }
540 .clipped() 528 .clipped()
541 .accessibilityLabel(image.caption) 529 .accessibilityLabel(image.caption)
App/Sources/MarsSection.swift +6 −4
@@ -31,7 +31,11 @@ struct MarsSection: View {
31 } 31 }
32 Text("Local mean solar time. A Mars sol is 24 h 39 m 35 s. Raw images: NASA/JPL-Caltech.").font(.caption2).dimmed(0.5) 32 Text("Local mean solar time. A Mars sol is 24 h 39 m 35 s. Raw images: NASA/JPL-Caltech.").font(.caption2).dimmed(0.5)
33 } 33 }
34 .task { images = (try? await MarsImage.latest()) ?? [] } 34 .task {
35 // The rovers send a few batches a day; reuse a fetch from the last 30 minutes.
36 if let cached: [MarsImage] = FeedMemo.get("mars", maxAge: 1800) { images = cached; return }
37 if let fresh = try? await MarsImage.latest() { images = fresh; FeedMemo.set("mars", fresh) }
38 }
35 .sheet(item: $shown) { MarsImageView(image: $0) } 39 .sheet(item: $shown) { MarsImageView(image: $0) }
36 } 40 }
37 41
@@ -43,9 +47,7 @@ struct MarsSection: View {
43 HStack(spacing: 6) { 47 HStack(spacing: 6) {
44 ForEach(mine) { image in 48 ForEach(mine) { image in
45 Button { shown = image } label: { 49 Button { shown = image } label: {
46 AsyncImage(url: image.thumbnail) { phase in 50 RemoteImage(url: image.thumbnail, points: 130) { Color.white.opacity(0.06) }
47 if case .success(let img) = phase { img.resizable().scaledToFill() } else { Color.white.opacity(0.06) }
48 }
49 .frame(width: 88, height: 88) 51 .frame(width: 88, height: 88)
50 .clipped() 52 .clipped()
51 } 53 }
App/Sources/MoonriseApp.swift +1
@@ -36,6 +36,7 @@ struct MoonriseApp: App {
36 36
37final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { 37final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
38 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { 38 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
39 ImageLoader.configureURLCache()
39 UNUserNotificationCenter.current().delegate = self 40 UNUserNotificationCenter.current().delegate = self
40 application.registerForRemoteNotifications() 41 application.registerForRemoteNotifications()
41 return true 42 return true
App/Sources/NewsList.swift +6 −5
@@ -26,12 +26,15 @@ struct NewsList: View {
26 ProgressView().tint(ink).padding(.top, 40).frame(maxWidth: .infinity) 26 ProgressView().tint(ink).padding(.top, 40).frame(maxWidth: .infinity)
27 } 27 }
28 } 28 }
29 .task(id: reload) { await load() } 29 .task(id: reload) { await load(force: reload > 0) }
30 } 30 }
31 31
32 func load() async { 32 /// The API caches for 10 minutes; reuse a fetch from that window unless pulled to refresh.
33 func load(force: Bool) async {
34 if !force, let cached: [NewsArticle] = FeedMemo.get("news", maxAge: 600) { articles = cached; return }
33 do { 35 do {
34 articles = try await NewsArticle.latest() 36 articles = try await NewsArticle.latest()
37 if let articles { FeedMemo.set("news", articles) }
35 failed = false 38 failed = false
36 } catch { 39 } catch {
37 failed = articles == nil 40 failed = articles == nil
@@ -47,9 +50,7 @@ struct NewsList: View {
47 } 50 }
48 Spacer(minLength: 0) 51 Spacer(minLength: 0)
49 if let image = a.image { 52 if let image = a.image {
50 AsyncImage(url: image) { phase in 53 RemoteImage(url: image, points: 110) { ink.opacity(0.06) }
51 if case .success(let img) = phase { img.resizable().scaledToFill() } else { ink.opacity(0.06) }
52 }
53 .frame(width: 72, height: 72) 54 .frame(width: 72, height: 72)
54 .clipped() 55 .clipped()
55 .accessibilityHidden(true) 56 .accessibilityHidden(true)
App/Sources/PageDrawing.swift +1 −2
@@ -194,8 +194,7 @@ struct ConstellationFigure: View {
194 194
195 var body: some View { 195 var body: some View {
196 let lines = Catalog.constellations.first { $0.id == id }?.lines ?? [] 196 let lines = Catalog.constellations.first { $0.id == id }?.lines ?? []
197 let byHR = Dictionary(uniqueKeysWithValues: Catalog.stars.map { ($0.hr, $0) }) 197 let stars = Set(lines.joined()).compactMap { Catalog.starsByHR[$0] }
198 let stars = Set(lines.joined()).compactMap { byHR[$0] }
199 Canvas { context, size in 198 Canvas { context, size in
200 guard !stars.isEmpty else { return } 199 guard !stars.isEmpty else { return }
201 // Gnomonic projection about the figure's centre. 200 // Gnomonic projection about the figure's centre.
App/Sources/RemoteImage.swift added +88
@@ -0,0 +1,88 @@
1import ImageIO
2import SwiftUI
3import UIKit
4
5/// Images from the network, decoded at the size they are shown rather than full size, and kept in memory.
6/// Downloads go through URLSession.shared, so the disk cache set up in `configureURLCache` applies.
7enum ImageLoader {
8 // NSCache is thread-safe.
9 nonisolated(unsafe) private static let cache: NSCache<NSString, UIImage> = {
10 let c = NSCache<NSString, UIImage>()
11 c.totalCostLimit = 60 * 1024 * 1024
12 return c
13 }()
14
15 static func cached(_ url: URL, maxPixels: Int) -> UIImage? {
16 cache.object(forKey: key(url, maxPixels))
17 }
18
19 static func load(_ url: URL, maxPixels: Int) async throws -> UIImage {
20 if let hit = cached(url, maxPixels: maxPixels) { return hit }
21 let (data, _) = try await URLSession.shared.data(from: url)
22 let image = try await Task.detached(priority: .userInitiated) { try downsample(data, maxPixels: maxPixels) }.value
23 let cost = Int(image.size.width * image.size.height * image.scale * image.scale * 4)
24 cache.setObject(image, forKey: key(url, maxPixels), cost: cost)
25 return image
26 }
27
28 private static func key(_ url: URL, _ maxPixels: Int) -> NSString { "\(maxPixels)|\(url.absoluteString)" as NSString }
29
30 /// Decodes straight to a thumbnail no larger than `maxPixels` on its long side.
31 nonisolated static func downsample(_ data: Data, maxPixels: Int) throws -> UIImage {
32 guard let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
33 let cg = CGImageSourceCreateThumbnailAtIndex(source, 0, [
34 kCGImageSourceCreateThumbnailFromImageAlways: true,
35 kCGImageSourceCreateThumbnailWithTransform: true,
36 kCGImageSourceShouldCacheImmediately: true,
37 kCGImageSourceThumbnailMaxPixelSize: maxPixels,
38 ] as CFDictionary)
39 else { throw URLError(.cannotDecodeContentData) }
40 return UIImage(cgImage: cg)
41 }
42
43 /// A bigger shared cache than the default (512 KB memory, about 20 MB disk), so Library photos and
44 /// feed responses survive between visits. Responses over 5% of the disk size are never stored.
45 static func configureURLCache() {
46 URLCache.shared = URLCache(memoryCapacity: 24 * 1024 * 1024, diskCapacity: 400 * 1024 * 1024)
47 }
48}
49
50/// A remote image decoded for `points` (the longer side it is drawn at), with a placeholder while loading.
51struct RemoteImage<Placeholder: View>: View {
52 var url: URL?
53 var points: CGFloat
54 var contentMode: ContentMode = .fill
55 @ViewBuilder var placeholder: () -> Placeholder
56 @Environment(\.displayScale) private var scale
57 @State private var image: UIImage?
58
59 private var maxPixels: Int { Int(points * scale) }
60
61 var body: some View {
62 Group {
63 if let image = image ?? url.flatMap({ ImageLoader.cached($0, maxPixels: maxPixels) }) {
64 Image(uiImage: image).resizable().aspectRatio(contentMode: contentMode)
65 } else {
66 placeholder()
67 }
68 }
69 .task(id: url) {
70 guard let url, image == nil else { return }
71 image = try? await ImageLoader.load(url, maxPixels: maxPixels)
72 }
73 }
74}
75
76/// Recent feed results kept in memory, so switching tabs or views doesn't refetch them.
77/// Pull to refresh bypasses it.
78@MainActor
79enum FeedMemo {
80 private static var store: [String: (at: Date, value: Any)] = [:]
81
82 static func get<T>(_ key: String, maxAge: TimeInterval) -> T? {
83 guard let hit = store[key], Date.now.timeIntervalSince(hit.at) < maxAge else { return nil }
84 return hit.value as? T
85 }
86
87 static func set(_ key: String, _ value: Any) { store[key] = (.now, value) }
88}
App/Sources/SkyModel.swift +8
@@ -55,6 +55,7 @@ final class SkyModel: NSObject, CLLocationManagerDelegate {
55 private(set) var passes: [SatellitePass] = [] 55 private(set) var passes: [SatellitePass] = []
56 private var elements: [OrbitalElements] = [] 56 private var elements: [OrbitalElements] = []
57 private var elementsFetched: Date? 57 private var elementsFetched: Date?
58 private var passesComputed: (at: Date, observer: Observer, elementsFetched: Date?)?
58 private var weatherFetched: Date? 59 private var weatherFetched: Date?
59 /// Hours from now shown by the time scrubber. 60 /// Hours from now shown by the time scrubber.
60 var offsetHours = 0.0 { didSet { refresh() } } 61 var offsetHours = 0.0 { didSet { refresh() } }
@@ -154,10 +155,17 @@ final class SkyModel: NSObject, CLLocationManagerDelegate {
154 elementsFetched = .now 155 elementsFetched = .now
155 } 156 }
156 } 157 }
158 // Reuse the last result for an hour unless the place or the orbits changed (about 1 s of work for 158 objects).
159 if let last = passesComputed, last.observer == observer, last.elementsFetched == elementsFetched,
160 Date.now.timeIntervalSince(last.at) < 3600 {
161 passes.removeAll { $0.set < .now }
162 return
163 }
157 let elements = elements, observer = observer 164 let elements = elements, observer = observer
158 passes = await Task.detached { 165 passes = await Task.detached {
159 elements.flatMap { SkyEngine.passes(of: $0, from: .now, hours: 72, observer: observer) }.sorted { $0.rise < $1.rise } 166 elements.flatMap { SkyEngine.passes(of: $0, from: .now, hours: 72, observer: observer) }.sorted { $0.rise < $1.rise }
160 }.value 167 }.value
168 passesComputed = (.now, observer, elementsFetched)
161 } 169 }
162 170
163 /// SWPC asks for no more than once a minute; this checks every 10. 171 /// SWPC asks for no more than once a minute; this checks every 10.
App/Sources/SolarSystem3D.swift +21 −10
@@ -382,10 +382,7 @@ struct SolarSystem3DView: View {
382 e.camera.camera.fieldOfViewInDegrees = SolarCamera.fovDegrees 382 e.camera.camera.fieldOfViewInDegrees = SolarCamera.fovDegrees
383 e.root.addChild(e.camera) 383 e.root.addChild(e.camera)
384 384
385 var sunMaterial = UnlitMaterial(color: UIColor(red: 1, green: 0.86, blue: 0.5, alpha: 1)) 385 let sunMaterial = UnlitMaterial(color: UIColor(red: 1, green: 0.86, blue: 0.5, alpha: 1))
386 if let url = Bundle.main.url(forResource: "sun", withExtension: "jpg", subdirectory: "Globes"), let t = try? TextureResource.load(contentsOf: url) {
387 sunMaterial.color = .init(tint: .white, texture: .init(t))
388 }
389 e.sun.model = ModelComponent(mesh: .generateSphere(radius: 1), materials: [sunMaterial]) 386 e.sun.model = ModelComponent(mesh: .generateSphere(radius: 1), materials: [sunMaterial])
390 e.root.addChild(e.sun) 387 e.root.addChild(e.sun)
391 // Glow: nested translucent shells, fainter outward. 388 // Glow: nested translucent shells, fainter outward.
@@ -404,12 +401,8 @@ struct SolarSystem3DView: View {
404 spheres += SolarMoon.all.map { ($0.id, $0.color, 0) } 401 spheres += SolarMoon.all.map { ($0.id, $0.color, 0) }
405 for s in spheres { 402 for s in spheres {
406 let body = ModelEntity() 403 let body = ModelEntity()
407 var m = UnlitMaterial() 404 // Plain colour first; the map, if there is one, is swapped in once it has loaded.
408 if let url = GlobeView.mapURL(s.id), let texture = try? TextureResource.load(contentsOf: url) { 405 let m = UnlitMaterial(color: UIColor(Color(hex: s.color) ?? .gray))
409 m.color = .init(texture: .init(texture))
410 } else {
411 m.color = .init(tint: UIColor(Color(hex: s.color) ?? .gray))
412 }
413 body.model = ModelComponent(mesh: .generateSphere(radius: 1), materials: [m]) 406 body.model = ModelComponent(mesh: .generateSphere(radius: 1), materials: [m])
414 body.orientation = simd_quatf(angle: Float(s.tilt * .pi / 180), axis: [1, 0, 0]) 407 body.orientation = simd_quatf(angle: Float(s.tilt * .pi / 180), axis: [1, 0, 0])
415 if s.id == "saturn" { body.addChild(Self.annulus(inner: 1.24, outer: 2.27, color: UIColor(red: 0.85, green: 0.78, blue: 0.62, alpha: 0.7))) } 408 if s.id == "saturn" { body.addChild(Self.annulus(inner: 1.24, outer: 2.27, color: UIColor(red: 0.85, green: 0.78, blue: 0.62, alpha: 0.7))) }
@@ -460,6 +453,24 @@ struct SolarSystem3DView: View {
460 } 453 }
461 e.bodies["earth"]?.addChild(e.aurora) 454 e.bodies["earth"]?.addChild(e.aurora)
462 place() 455 place()
456 loadTextures()
457 }
458
459 /// Loads surface maps off the main thread (about 0.6 s for all of them) and applies each as it arrives.
460 private func loadTextures() {
461 let e = entities
462 var jobs: [(entity: ModelEntity, url: URL, tint: UIColor)] = e.bodies.compactMap { id, entity in
463 GlobeView.mapURL(id).map { (entity, $0, .white) }
464 }
465 if let sun = Bundle.main.url(forResource: "sun", withExtension: "jpg", subdirectory: "Globes") { jobs.append((e.sun, sun, .white)) }
466 for job in jobs {
467 Task {
468 guard let texture = try? await TextureResource(contentsOf: job.url) else { return }
469 var m = UnlitMaterial()
470 m.color = .init(tint: job.tint, texture: .init(texture))
471 job.entity.model?.materials = [m]
472 }
473 }
463 } 474 }
464 475
465 /// Moves everything for the current date, scale, layers and camera. 476 /// Moves everything for the current date, scale, layers and camera.
App/Sources/SunScreen.swift +9 −3
@@ -47,11 +47,16 @@ struct SunScreen: View {
47 } 47 }
48 .foregroundStyle(ink) 48 .foregroundStyle(ink)
49 .background(background.ignoresSafeArea()) 49 .background(background.ignoresSafeArea())
50 .refreshable { await load() } 50 .refreshable { await load(force: true) }
51 .task { await load() } 51 .task { await load(force: false) }
52 } 52 }
53 53
54 private func load() async { 54 /// NOAA updates these every minute or so; a visit within 5 minutes of the last load reuses it.
55 private func load(force: Bool) async {
56 if !force, let cached: ([KpBar], [XRaySample], Ovation?) = FeedMemo.get("sun", maxAge: 300) {
57 (kp, xray, ovation) = cached
58 return
59 }
55 imageID = .now 60 imageID = .now
56 async let k = try? SpaceWeather.fetchKpBars() 61 async let k = try? SpaceWeather.fetchKpBars()
57 async let x = try? XRay.fetch() 62 async let x = try? XRay.fetch()
@@ -61,6 +66,7 @@ struct SunScreen: View {
61 xray = xrayResult ?? xray 66 xray = xrayResult ?? xray
62 ovation = ovationResult ?? ovation 67 ovation = ovationResult ?? ovation
63 failed = kpResult == nil || xrayResult == nil || ovationResult == nil 68 failed = kpResult == nil || xrayResult == nil || ovationResult == nil
69 if !failed { FeedMemo.set("sun", (kp, xray, ovation)) }
64 } 70 }
65 71
66 private var image: some View { 72 private var image: some View {
Packages/MoonriseKit/Sources/Library/Library.swift +32 −5
@@ -137,15 +137,38 @@ public enum Library {
137 137
138 /// Case- and diacritic-insensitive match on title and subtitle first, then facts and essay. 138 /// Case- and diacritic-insensitive match on title and subtitle first, then facts and essay.
139 public static func search(_ query: String) -> [LibraryPage] { 139 public static func search(_ query: String) -> [LibraryPage] {
140 let q = query.trimmingCharacters(in: .whitespaces) 140 let q = fold(query.trimmingCharacters(in: .whitespaces))
141 guard !q.isEmpty else { return [] } 141 guard !q.isEmpty else { return [] }
142 let opts: String.CompareOptions = [.caseInsensitive, .diacriticInsensitive] 142 var strong: [LibraryPage] = [], weak: [LibraryPage] = []
143 let strong = pages.filter { $0.title.range(of: q, options: opts) != nil || $0.subtitle.range(of: q, options: opts) != nil || $0.id == q.lowercased() } 143 for entry in searchIndex {
144 let weak = pages.filter { p in 144 if entry.head.contains(q) || entry.page.id == q { strong.append(entry.page) } else if entry.body.contains(q) { weak.append(entry.page) }
145 !strong.contains(p) && (p.essay.range(of: q, options: opts) != nil || p.facts.contains { $0.value.range(of: q, options: opts) != nil })
146 } 145 }
147 return strong + weak 146 return strong + weak
148 } 147 }
148
149 /// Lowercased, diacritic-free text per page, built once: title and subtitle, then essay and facts.
150 private static let searchIndex: [(page: LibraryPage, head: String, body: String)] = pages.map { p in
151 (p, fold(p.title + "\n" + p.subtitle), fold(p.essay + "\n" + p.facts.map(\.value).joined(separator: "\n")))
152 }
153
154 static func fold(_ s: String) -> String {
155 s.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil)
156 }
157
158 private static let relatedLock = NSLock()
159 nonisolated(unsafe) private static var relatedCache: [String: [LibraryPage]] = [:]
160
161 /// `page.related()` computed once per page.
162 static func cachedRelated(_ page: LibraryPage, compute: () -> [LibraryPage]) -> [LibraryPage] {
163 relatedLock.lock()
164 if let hit = relatedCache[page.id] { relatedLock.unlock(); return hit }
165 relatedLock.unlock()
166 let value = compute()
167 relatedLock.lock()
168 relatedCache[page.id] = value
169 relatedLock.unlock()
170 return value
171 }
149} 172}
150 173
151extension LibraryPage { 174extension LibraryPage {
@@ -153,6 +176,10 @@ extension LibraryPage {
153 /// A title also matches by its part before a dash or bracket ("Cassini" for "Cassini–Huygens"). 176 /// A title also matches by its part before a dash or bracket ("Cassini" for "Cassini–Huygens").
154 /// Titles shorter than five characters ("Sun", "Moon", "Io") only count when they are mentioned here. 177 /// Titles shorter than five characters ("Sun", "Moon", "Io") only count when they are mentioned here.
155 public func related(limit: Int = 10) -> [LibraryPage] { 178 public func related(limit: Int = 10) -> [LibraryPage] {
179 Library.cachedRelated(self) { uncachedRelated(limit: limit) }
180 }
181
182 private func uncachedRelated(limit: Int) -> [LibraryPage] {
156 func mentions(_ text: String, _ title: String) -> Bool { 183 func mentions(_ text: String, _ title: String) -> Bool {
157 var search = text.startIndex..<text.endIndex 184 var search = text.startIndex..<text.endIndex
158 while let r = text.range(of: title, range: search) { 185 while let r = text.range(of: title, range: search) {
Packages/MoonriseKit/Sources/SkyRenderer/SkyScene.swift +3
@@ -52,6 +52,9 @@ public enum Catalog {
52 return rows.map { Star(hr: Int($0[0]), ra: $0[1], dec: $0[2], magnitude: $0[3], colorIndex: $0[4], pmRA: $0[5], pmDec: $0[6]) } 52 return rows.map { Star(hr: Int($0[0]), ra: $0[1], dec: $0[2], magnitude: $0[3], colorIndex: $0[4], pmRA: $0[5], pmDec: $0[6]) }
53 }() 53 }()
54 54
55 /// Stars by HR number.
56 public static let starsByHR: [Int: Star] = Dictionary(uniqueKeysWithValues: stars.map { ($0.hr, $0) })
57
55 /// Built by scripts/make-skycultures.py. 58 /// Built by scripts/make-skycultures.py.
56 public static let skyCultures: [SkyCulture] = { 59 public static let skyCultures: [SkyCulture] = {
57 let url = Bundle.module.url(forResource: "skycultures", withExtension: "json")! 60 let url = Bundle.module.url(forResource: "skycultures", withExtension: "json")!
server/src/index.ts +11 −7
@@ -29,7 +29,11 @@ interface DeviceRequest {
29const LAUNCH_CRON = "*/10 * * * *"; 29const LAUNCH_CRON = "*/10 * * * *";
30const SATELLITE_CRON = "7 */2 * * *"; 30const SATELLITE_CRON = "7 */2 * * *";
31 31
32const json = (body: unknown, status = 200) => Response.json(body, { status }); 32const json = (body: unknown, status = 200, maxAge?: number) =>
33 Response.json(body, { status, headers: maxAge && status === 200 ? { "cache-control": `public, max-age=${maxAge}` } : {} });
34
35/** How long clients may reuse each GET response, matched to how often its source is polled. */
36export const MAX_AGE = { today: 60, launches: 300, satellites: 3600, live: 300 } as const;
33 37
34function validDevice(d: Partial<DeviceRequest>): d is DeviceRequest { 38function validDevice(d: Partial<DeviceRequest>): d is DeviceRequest {
35 return ( 39 return (
@@ -42,12 +46,12 @@ function validDevice(d: Partial<DeviceRequest>): d is DeviceRequest {
42 46
43async function today(env: Env, now: Date): Promise<Response> { 47async function today(env: Env, now: Date): Promise<Response> {
44 const row = await env.DB.prepare("SELECT fetched_at, body FROM feed_snapshots WHERE feed = 'swpc' AND ok = 1").first<{ fetched_at: string; body: string }>(); 48 const row = await env.DB.prepare("SELECT fetched_at, body FROM feed_snapshots WHERE feed = 'swpc' AND ok = 1").first<{ fetched_at: string; body: string }>();
45 if (!row) return json({ spaceWeather: null }); 49 if (!row) return json({ spaceWeather: null }, 200, MAX_AGE.today);
46 const w = JSON.parse(row.body) as SpaceWeather; 50 const w = JSON.parse(row.body) as SpaceWeather;
47 w.forecast = w.forecast.map((f) => ({ ...f, start: new Date(f.start) })); 51 w.forecast = w.forecast.map((f) => ({ ...f, start: new Date(f.start) }));
48 return json({ 52 return json({
49 spaceWeather: { kpNow: w.kpNow, observedAt: w.observedAt, kpNext24h: next24h(w, now), fetchedAt: row.fetched_at }, 53 spaceWeather: { kpNow: w.kpNow, observedAt: w.observedAt, kpNext24h: next24h(w, now), fetchedAt: row.fetched_at },
50 }); 54 }, 200, MAX_AGE.today);
51} 55}
52 56
53export default { 57export default {
@@ -66,7 +70,7 @@ export default {
66 if (request.method === "GET" && url.pathname === "/v1/live") return live(env, url, now); 70 if (request.method === "GET" && url.pathname === "/v1/live") return live(env, url, now);
67 if (request.method === "GET" && url.pathname === "/v1/satellites") { 71 if (request.method === "GET" && url.pathname === "/v1/satellites") {
68 const row = await env.DB.prepare("SELECT fetched_at, body FROM feed_snapshots WHERE feed = 'celestrak' AND ok = 1").first<{ fetched_at: string; body: string }>(); 72 const row = await env.DB.prepare("SELECT fetched_at, body FROM feed_snapshots WHERE feed = 'celestrak' AND ok = 1").first<{ fetched_at: string; body: string }>();
69 return json({ fetchedAt: row?.fetched_at ?? null, satellites: row ? JSON.parse(row.body) : [] }); 73 return json({ fetchedAt: row?.fetched_at ?? null, satellites: row ? JSON.parse(row.body) : [] }, 200, MAX_AGE.satellites);
70 } 74 }
71 75
72 if (request.method === "POST" && url.pathname === "/v1/devices") { 76 if (request.method === "POST" && url.pathname === "/v1/devices") {
@@ -125,7 +129,7 @@ async function live(env: Env, url: URL, now: Date): Promise<Response> {
125 const at = atParam ? new Date(atParam) : now; 129 const at = atParam ? new Date(atParam) : now;
126 if (Number.isNaN(at.getTime())) return json({ error: "invalid at" }, 400); 130 if (Number.isNaN(at.getTime())) return json({ error: "invalid at" }, 400);
127 const event = activeEvent(at); 131 const event = activeEvent(at);
128 if (!event) return json({ event: null }); 132 if (!event) return json({ event: null }, 200, MAX_AGE.live);
129 133
130 const key = `live:${event.id}`; 134 const key = `live:${event.id}`;
131 const row = await env.DB.prepare("SELECT fetched_at, body FROM feed_snapshots WHERE feed = ?1 AND ok = 1").bind(key).first<{ fetched_at: string; body: string }>(); 135 const row = await env.DB.prepare("SELECT fetched_at, body FROM feed_snapshots WHERE feed = ?1 AND ok = 1").bind(key).first<{ fetched_at: string; body: string }>();
@@ -144,7 +148,7 @@ async function live(env: Env, url: URL, now: Date): Promise<Response> {
144 console.error("horizons", String(e)); 148 console.error("horizons", String(e));
145 } 149 }
146 } 150 }
147 return json({ event, at: at.toISOString(), points: points.filter((p) => p[0] * 1000 <= at.getTime()) }); 151 return json({ event, at: at.toISOString(), points: points.filter((p) => p[0] * 1000 <= at.getTime()) }, 200, MAX_AGE.live);
148} 152}
149 153
150async function launches(env: Env, now: Date): Promise<Response> { 154async function launches(env: Env, now: Date): Promise<Response> {
@@ -159,7 +163,7 @@ async function launches(env: Env, now: Date): Promise<Response> {
159 mission: r.mission, missionType: r.mission_type, missionDescription: r.mission_description, orbit: r.orbit, 163 mission: r.mission, missionType: r.mission_type, missionDescription: r.mission_description, orbit: r.orbit,
160 pad: r.pad, location: r.location, padLatitude: r.pad_latitude, padLongitude: r.pad_longitude, 164 pad: r.pad, location: r.location, padLatitude: r.pad_latitude, padLongitude: r.pad_longitude,
161 })), 165 })),
162 }); 166 }, 200, MAX_AGE.launches);
163} 167}
164 168
165// Failures are recorded under "<feed>:error" so the last good snapshot and its time survive. 169// Failures are recorded under "<feed>:error" so the last good snapshot and its time survive.