Commit b690d442b1
Unsigned
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 | ||
| 37 | final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate { | 37 | final 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 @@ | |||
| 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 | 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 | ||
| 151 | extension LibraryPage { | 174 | extension 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 { | |||
| 29 | const LAUNCH_CRON = "*/10 * * * *"; | 29 | const LAUNCH_CRON = "*/10 * * * *"; |
| 30 | const SATELLITE_CRON = "7 */2 * * *"; | 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 | function validDevice(d: Partial<DeviceRequest>): d is DeviceRequest { | 38 | function 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 | ||
| 43 | async function today(env: Env, now: Date): Promise<Response> { | 47 | async 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 | ||
| 53 | export default { | 57 | export 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 | ||
| 150 | async function launches(env: Env, now: Date): Promise<Response> { | 154 | async 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. |