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 b690d442b1

b690d442b143bde1f3b8082f103825fd5abb3f19

parent: 2e552960dd

Unsigned

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

Performance pass (!50)

Fixes from the performance pass. Measured on the iPhone 17 Pro simulator (release build) and the Mac.

1. Library images are decoded at display size (ImageIO thumbnails: 260 pt cards, 640 pt featured, 900 pt page header) and kept in a 60 MB NSCache. Memory after opening the Library: 209 MB → 56 MB. Same loader for Mars and news thumbnails.
2. URLCache.shared set to 24 MB memory / 400 MB disk at launch (default 512 KB / ~20 MB; responses over 5% of disk were never stored, which excluded most Library photos).
3. 3D view: surface maps load asynchronously and are swapped in as they arrive, instead of ~0.6 s of synchronous loading on the main thread when the view opens.
4. Library search uses a folded-text index built once and no longer does an O(n²) membership check: 62 → 10 ms per keystroke (Mac, release).
5. =LibraryPage.related()= is cached per page: 18 ms → under 1 µs on repeat.
6. =Catalog.starsByHR= is built once; constellation thumbnails and the Library's tonight picks used to rebuild a 9,000-star dictionary each time.
7. Satellite passes (~0.7 s on a Mac for 158 objects) are reused for an hour unless the place or orbits change.
8. Feeds are kept in memory between tab switches: Sun 5 min, launches 5 min, news 10 min, Mars images 30 min. Pull to refresh still forces a fetch.
9. Backend GET responses send Cache-Control: today 60 s, launches 300 s, satellites 3600 s, live 300 s. Deployed.

Not changed: launch time. On the simulator under Instruments it is dominated by system and SwiftUI start-up, not app code; it needs measuring on a device.

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.