Performance pass !50
13 files changed, +196 −55
Layout: unified · split
App/Sources/LaunchesScreen.swift +5 −2
| @@ -49,7 +49,7 @@ struct LaunchesScreen: View { | ||
| 49 | 49 | } |
| 50 | 50 | .foregroundStyle(ink) |
| 51 | 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 | 53 | .task { await load() } |
| 54 | 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 | 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 | 78 | do { |
| 77 | 79 | list = try await LaunchFeed.fetch(base: apiBase) |
| 80 | if let list { FeedMemo.set("launches", list) } | |
| 78 | 81 | error = nil |
| 79 | 82 | } catch { |
| 80 | 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 | 40 | // Constellations whose stars are on average more than 25° up two hours into the night. |
| 41 | 41 | let when = t.start.addingTimeInterval(2 * 3600) |
| 42 | let byHR = Dictionary(uniqueKeysWithValues: Catalog.stars.map { ($0.hr, $0) }) | |
| 43 | 42 | let scored: [(LibraryPage, Double)] = Catalog.constellations.compactMap { c in |
| 44 | 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 | 45 | guard !stars.isEmpty else { return nil } |
| 47 | 46 | let alts = SkyEngine.horizontal(j2000: stars.map { $0.position(at: when) }, at: when, from: observer).map(\.altitude) |
| 48 | 47 | return (page, alts.reduce(0, +) / Double(alts.count)) |
| @@ -283,14 +282,9 @@ struct PageThumb: View { | ||
| 283 | 282 | ZStack { |
| 284 | 283 | tile |
| 285 | 284 | if let image = page.image { |
| 286 | AsyncImage(url: image.url) { phase in | |
| 287 | if case .success(let img) = phase { | |
| 288 | // Library of Congress scans carry a frame and colour bars; crop to the plate. | |
| 289 | img.resizable().scaledToFill().scaleEffect(image.url.host() == "tile.loc.gov" ? 1.45 : 1) | |
| 290 | } else { | |
| 291 | fallback | |
| 292 | } | |
| 293 | } | |
| 285 | // Library of Congress scans carry a frame and colour bars; crop to the plate. | |
| 286 | RemoteImage(url: image.url, points: large ? 640 : 260) { fallback } | |
| 287 | .scaleEffect(image.url.host() == "tile.loc.gov" ? 1.45 : 1) | |
| 294 | 288 | } else { |
| 295 | 289 | fallback |
| 296 | 290 | } |
| @@ -529,13 +523,7 @@ struct LibraryPageView: View { | ||
| 529 | 523 | Color(white: 0.06) |
| 530 | 524 | .frame(height: 340) |
| 531 | 525 | .overlay { |
| 532 | AsyncImage(url: image.url) { phase in | |
| 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 | } | |
| 526 | RemoteImage(url: image.url, points: 900) { ProgressView().tint(ink) } | |
| 539 | 527 | } |
| 540 | 528 | .clipped() |
| 541 | 529 | .accessibilityLabel(image.caption) |
App/Sources/MarsSection.swift +6 −4
| @@ -31,7 +31,11 @@ struct MarsSection: View { | ||
| 31 | 31 | } |
| 32 | 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 | 39 | .sheet(item: $shown) { MarsImageView(image: $0) } |
| 36 | 40 | } |
| 37 | 41 | |
| @@ -43,9 +47,7 @@ struct MarsSection: View { | ||
| 43 | 47 | HStack(spacing: 6) { |
| 44 | 48 | ForEach(mine) { image in |
| 45 | 49 | Button { shown = image } label: { |
| 46 | AsyncImage(url: image.thumbnail) { phase in | |
| 47 | if case .success(let img) = phase { img.resizable().scaledToFill() } else { Color.white.opacity(0.06) } | |
| 48 | } | |
| 50 | RemoteImage(url: image.thumbnail, points: 130) { Color.white.opacity(0.06) } | |
| 49 | 51 | .frame(width: 88, height: 88) |
| 50 | 52 | .clipped() |
| 51 | 53 | } |
App/Sources/MoonriseApp.swift +1
| @@ -36,6 +36,7 @@ struct MoonriseApp: App { | ||
| 36 | 36 | |
| 37 | 37 | final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { |
| 38 | 38 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { |
| 39 | ImageLoader.configureURLCache() | |
| 39 | 40 | UNUserNotificationCenter.current().delegate = self |
| 40 | 41 | application.registerForRemoteNotifications() |
| 41 | 42 | return true |
App/Sources/NewsList.swift +6 −5
| @@ -26,12 +26,15 @@ struct NewsList: View { | ||
| 26 | 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 | 35 | do { |
| 34 | 36 | articles = try await NewsArticle.latest() |
| 37 | if let articles { FeedMemo.set("news", articles) } | |
| 35 | 38 | failed = false |
| 36 | 39 | } catch { |
| 37 | 40 | failed = articles == nil |
| @@ -47,9 +50,7 @@ struct NewsList: View { | ||
| 47 | 50 | } |
| 48 | 51 | Spacer(minLength: 0) |
| 49 | 52 | if let image = a.image { |
| 50 | AsyncImage(url: image) { phase in | |
| 51 | if case .success(let img) = phase { img.resizable().scaledToFill() } else { ink.opacity(0.06) } | |
| 52 | } | |
| 53 | RemoteImage(url: image, points: 110) { ink.opacity(0.06) } | |
| 53 | 54 | .frame(width: 72, height: 72) |
| 54 | 55 | .clipped() |
| 55 | 56 | .accessibilityHidden(true) |
App/Sources/PageDrawing.swift +1 −2
| @@ -194,8 +194,7 @@ struct ConstellationFigure: View { | ||
| 194 | 194 | |
| 195 | 195 | var body: some View { |
| 196 | 196 | let lines = Catalog.constellations.first { $0.id == id }?.lines ?? [] |
| 197 | let byHR = Dictionary(uniqueKeysWithValues: Catalog.stars.map { ($0.hr, $0) }) | |
| 198 | let stars = Set(lines.joined()).compactMap { byHR[$0] } | |
| 197 | let stars = Set(lines.joined()).compactMap { Catalog.starsByHR[$0] } | |
| 199 | 198 | Canvas { context, size in |
| 200 | 199 | guard !stars.isEmpty else { return } |
| 201 | 200 | // Gnomonic projection about the figure's centre. |
App/Sources/RemoteImage.swift added +88
| @@ -0,0 +1,88 @@ | ||
| 1 | import ImageIO | |
| 2 | import SwiftUI | |
| 3 | import 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. | |
| 7 | enum 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. | |
| 51 | struct 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 | |
| 79 | enum 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 | 55 | private(set) var passes: [SatellitePass] = [] |
| 56 | 56 | private var elements: [OrbitalElements] = [] |
| 57 | 57 | private var elementsFetched: Date? |
| 58 | private var passesComputed: (at: Date, observer: Observer, elementsFetched: Date?)? | |
| 58 | 59 | private var weatherFetched: Date? |
| 59 | 60 | /// Hours from now shown by the time scrubber. |
| 60 | 61 | var offsetHours = 0.0 { didSet { refresh() } } |
| @@ -154,10 +155,17 @@ final class SkyModel: NSObject, CLLocationManagerDelegate { | ||
| 154 | 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 | 164 | let elements = elements, observer = observer |
| 158 | 165 | passes = await Task.detached { |
| 159 | 166 | elements.flatMap { SkyEngine.passes(of: $0, from: .now, hours: 72, observer: observer) }.sorted { $0.rise < $1.rise } |
| 160 | 167 | }.value |
| 168 | passesComputed = (.now, observer, elementsFetched) | |
| 161 | 169 | } |
| 162 | 170 | |
| 163 | 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 | 382 | e.camera.camera.fieldOfViewInDegrees = SolarCamera.fovDegrees |
| 383 | 383 | e.root.addChild(e.camera) |
| 384 | 384 | |
| 385 | var 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 | } | |
| 385 | let sunMaterial = UnlitMaterial(color: UIColor(red: 1, green: 0.86, blue: 0.5, alpha: 1)) | |
| 389 | 386 | e.sun.model = ModelComponent(mesh: .generateSphere(radius: 1), materials: [sunMaterial]) |
| 390 | 387 | e.root.addChild(e.sun) |
| 391 | 388 | // Glow: nested translucent shells, fainter outward. |
| @@ -404,12 +401,8 @@ struct SolarSystem3DView: View { | ||
| 404 | 401 | spheres += SolarMoon.all.map { ($0.id, $0.color, 0) } |
| 405 | 402 | for s in spheres { |
| 406 | 403 | let body = ModelEntity() |
| 407 | var m = UnlitMaterial() | |
| 408 | if let url = GlobeView.mapURL(s.id), let texture = try? TextureResource.load(contentsOf: url) { | |
| 409 | m.color = .init(texture: .init(texture)) | |
| 410 | } else { | |
| 411 | m.color = .init(tint: UIColor(Color(hex: s.color) ?? .gray)) | |
| 412 | } | |
| 404 | // Plain colour first; the map, if there is one, is swapped in once it has loaded. | |
| 405 | let m = UnlitMaterial(color: UIColor(Color(hex: s.color) ?? .gray)) | |
| 413 | 406 | body.model = ModelComponent(mesh: .generateSphere(radius: 1), materials: [m]) |
| 414 | 407 | body.orientation = simd_quatf(angle: Float(s.tilt * .pi / 180), axis: [1, 0, 0]) |
| 415 | 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 | 454 | e.bodies["earth"]?.addChild(e.aurora) |
| 462 | 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 | 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 | 48 | .foregroundStyle(ink) |
| 49 | 49 | .background(background.ignoresSafeArea()) |
| 50 | .refreshable { await load() } | |
| 51 | .task { await load() } | |
| 50 | .refreshable { await load(force: true) } | |
| 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 | 60 | imageID = .now |
| 56 | 61 | async let k = try? SpaceWeather.fetchKpBars() |
| 57 | 62 | async let x = try? XRay.fetch() |
| @@ -61,6 +66,7 @@ struct SunScreen: View { | ||
| 61 | 66 | xray = xrayResult ?? xray |
| 62 | 67 | ovation = ovationResult ?? ovation |
| 63 | 68 | failed = kpResult == nil || xrayResult == nil || ovationResult == nil |
| 69 | if !failed { FeedMemo.set("sun", (kp, xray, ovation)) } | |
| 64 | 70 | } |
| 65 | 71 | |
| 66 | 72 | private var image: some View { |
Packages/MoonriseKit/Sources/Library/Library.swift +32 −5
| @@ -137,15 +137,38 @@ public enum Library { | ||
| 137 | 137 | |
| 138 | 138 | /// Case- and diacritic-insensitive match on title and subtitle first, then facts and essay. |
| 139 | 139 | public static func search(_ query: String) -> [LibraryPage] { |
| 140 | let q = query.trimmingCharacters(in: .whitespaces) | |
| 140 | let q = fold(query.trimmingCharacters(in: .whitespaces)) | |
| 141 | 141 | guard !q.isEmpty else { return [] } |
| 142 | let opts: String.CompareOptions = [.caseInsensitive, .diacriticInsensitive] | |
| 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() } | |
| 144 | let weak = pages.filter { p in | |
| 145 | !strong.contains(p) && (p.essay.range(of: q, options: opts) != nil || p.facts.contains { $0.value.range(of: q, options: opts) != nil }) | |
| 142 | var strong: [LibraryPage] = [], weak: [LibraryPage] = [] | |
| 143 | for entry in searchIndex { | |
| 144 | if entry.head.contains(q) || entry.page.id == q { strong.append(entry.page) } else if entry.body.contains(q) { weak.append(entry.page) } | |
| 146 | 145 | } |
| 147 | 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 | |
| 151 | 174 | extension LibraryPage { |
| @@ -153,6 +176,10 @@ extension LibraryPage { | ||
| 153 | 176 | /// A title also matches by its part before a dash or bracket ("Cassini" for "Cassini–Huygens"). |
| 154 | 177 | /// Titles shorter than five characters ("Sun", "Moon", "Io") only count when they are mentioned here. |
| 155 | 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 | 183 | func mentions(_ text: String, _ title: String) -> Bool { |
| 157 | 184 | var search = text.startIndex..<text.endIndex |
| 158 | 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 | 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 | 58 | /// Built by scripts/make-skycultures.py. |
| 56 | 59 | public static let skyCultures: [SkyCulture] = { |
| 57 | 60 | let url = Bundle.module.url(forResource: "skycultures", withExtension: "json")! |
server/src/index.ts +11 −7
| @@ -29,7 +29,11 @@ interface DeviceRequest { | ||
| 29 | 29 | const LAUNCH_CRON = "*/10 * * * *"; |
| 30 | 30 | const SATELLITE_CRON = "7 */2 * * *"; |
| 31 | 31 | |
| 32 | const json = (body: unknown, status = 200) => Response.json(body, { status }); | |
| 32 | const 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. */ | |
| 36 | export const MAX_AGE = { today: 60, launches: 300, satellites: 3600, live: 300 } as const; | |
| 33 | 37 | |
| 34 | 38 | function validDevice(d: Partial<DeviceRequest>): d is DeviceRequest { |
| 35 | 39 | return ( |
| @@ -42,12 +46,12 @@ function validDevice(d: Partial<DeviceRequest>): d is DeviceRequest { | ||
| 42 | 46 | |
| 43 | 47 | async function today(env: Env, now: Date): Promise<Response> { |
| 44 | 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 | 50 | const w = JSON.parse(row.body) as SpaceWeather; |
| 47 | 51 | w.forecast = w.forecast.map((f) => ({ ...f, start: new Date(f.start) })); |
| 48 | 52 | return json({ |
| 49 | 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 | |
| 53 | 57 | export default { |
| @@ -66,7 +70,7 @@ export default { | ||
| 66 | 70 | if (request.method === "GET" && url.pathname === "/v1/live") return live(env, url, now); |
| 67 | 71 | if (request.method === "GET" && url.pathname === "/v1/satellites") { |
| 68 | 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 | 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 | 129 | const at = atParam ? new Date(atParam) : now; |
| 126 | 130 | if (Number.isNaN(at.getTime())) return json({ error: "invalid at" }, 400); |
| 127 | 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 | 134 | const key = `live:${event.id}`; |
| 131 | 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 | 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 | |
| 150 | 154 | async function launches(env: Env, now: Date): Promise<Response> { |
| @@ -159,7 +163,7 @@ async function launches(env: Env, now: Date): Promise<Response> { | ||
| 159 | 163 | mission: r.mission, missionType: r.mission_type, missionDescription: r.mission_description, orbit: r.orbit, |
| 160 | 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 | 169 | // Failures are recorded under "<feed>:error" so the last good snapshot and its time survive. |