Performance pass !50

merged merged by cmc on 2026-10-19 06:36 UTC · krz/moonrise:perf into main

13 files changed, +196 −55

Layout: unified · split

App/Sources/LaunchesScreen.swift +5 −2
@@ -49,7 +49,7 @@ struct LaunchesScreen: View {
4949 }
5050 .foregroundStyle(ink)
5151 .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) } }
5353 .task { await load() }
5454 .sheet(item: $selected) { LaunchDetail(launch: $0, ink: ink, accent: accent, background: background) }
5555 }
@@ -71,10 +71,13 @@ struct LaunchesScreen: View {
7171 }
7272 }
7373
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 {
7576 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 }
7678 do {
7779 list = try await LaunchFeed.fetch(base: apiBase)
80 if let list { FeedMemo.set("launches", list) }
7881 error = nil
7982 } catch {
8083 self.error = "Couldn't load launches. Pull to retry."
App/Sources/LibraryScreen.swift +5 −17
@@ -39,10 +39,9 @@ private struct TonightPicks: Sendable {
3939 }
4040 // Constellations whose stars are on average more than 25° up two hours into the night.
4141 let when = t.start.addingTimeInterval(2 * 3600)
42 let byHR = Dictionary(uniqueKeysWithValues: Catalog.stars.map { ($0.hr, $0) })
4342 let scored: [(LibraryPage, Double)] = Catalog.constellations.compactMap { c in
4443 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] }
4645 guard !stars.isEmpty else { return nil }
4746 let alts = SkyEngine.horizontal(j2000: stars.map { $0.position(at: when) }, at: when, from: observer).map(\.altitude)
4847 return (page, alts.reduce(0, +) / Double(alts.count))
@@ -283,14 +282,9 @@ struct PageThumb: View {
283282 ZStack {
284283 tile
285284 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)
294288 } else {
295289 fallback
296290 }
@@ -529,13 +523,7 @@ struct LibraryPageView: View {
529523 Color(white: 0.06)
530524 .frame(height: 340)
531525 .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) }
539527 }
540528 .clipped()
541529 .accessibilityLabel(image.caption)
App/Sources/MarsSection.swift +6 −4
@@ -31,7 +31,11 @@ struct MarsSection: View {
3131 }
3232 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)
3333 }
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 }
3539 .sheet(item: $shown) { MarsImageView(image: $0) }
3640 }
3741
@@ -43,9 +47,7 @@ struct MarsSection: View {
4347 HStack(spacing: 6) {
4448 ForEach(mine) { image in
4549 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) }
4951 .frame(width: 88, height: 88)
5052 .clipped()
5153 }
App/Sources/MoonriseApp.swift +1
@@ -36,6 +36,7 @@ struct MoonriseApp: App {
3636
3737final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
3838 func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
39 ImageLoader.configureURLCache()
3940 UNUserNotificationCenter.current().delegate = self
4041 application.registerForRemoteNotifications()
4142 return true
App/Sources/NewsList.swift +6 −5
@@ -26,12 +26,15 @@ struct NewsList: View {
2626 ProgressView().tint(ink).padding(.top, 40).frame(maxWidth: .infinity)
2727 }
2828 }
29 .task(id: reload) { await load() }
29 .task(id: reload) { await load(force: reload > 0) }
3030 }
3131
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 }
3335 do {
3436 articles = try await NewsArticle.latest()
37 if let articles { FeedMemo.set("news", articles) }
3538 failed = false
3639 } catch {
3740 failed = articles == nil
@@ -47,9 +50,7 @@ struct NewsList: View {
4750 }
4851 Spacer(minLength: 0)
4952 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) }
5354 .frame(width: 72, height: 72)
5455 .clipped()
5556 .accessibilityHidden(true)
App/Sources/PageDrawing.swift +1 −2
@@ -194,8 +194,7 @@ struct ConstellationFigure: View {
194194
195195 var body: some View {
196196 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] }
199198 Canvas { context, size in
200199 guard !stars.isEmpty else { return }
201200 // 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 {
5555 private(set) var passes: [SatellitePass] = []
5656 private var elements: [OrbitalElements] = []
5757 private var elementsFetched: Date?
58 private var passesComputed: (at: Date, observer: Observer, elementsFetched: Date?)?
5859 private var weatherFetched: Date?
5960 /// Hours from now shown by the time scrubber.
6061 var offsetHours = 0.0 { didSet { refresh() } }
@@ -154,10 +155,17 @@ final class SkyModel: NSObject, CLLocationManagerDelegate {
154155 elementsFetched = .now
155156 }
156157 }
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 }
157164 let elements = elements, observer = observer
158165 passes = await Task.detached {
159166 elements.flatMap { SkyEngine.passes(of: $0, from: .now, hours: 72, observer: observer) }.sorted { $0.rise < $1.rise }
160167 }.value
168 passesComputed = (.now, observer, elementsFetched)
161169 }
162170
163171 /// 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 {
382382 e.camera.camera.fieldOfViewInDegrees = SolarCamera.fovDegrees
383383 e.root.addChild(e.camera)
384384
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))
389386 e.sun.model = ModelComponent(mesh: .generateSphere(radius: 1), materials: [sunMaterial])
390387 e.root.addChild(e.sun)
391388 // Glow: nested translucent shells, fainter outward.
@@ -404,12 +401,8 @@ struct SolarSystem3DView: View {
404401 spheres += SolarMoon.all.map { ($0.id, $0.color, 0) }
405402 for s in spheres {
406403 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))
413406 body.model = ModelComponent(mesh: .generateSphere(radius: 1), materials: [m])
414407 body.orientation = simd_quatf(angle: Float(s.tilt * .pi / 180), axis: [1, 0, 0])
415408 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 {
460453 }
461454 e.bodies["earth"]?.addChild(e.aurora)
462455 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 }
463474 }
464475
465476 /// Moves everything for the current date, scale, layers and camera.
App/Sources/SunScreen.swift +9 −3
@@ -47,11 +47,16 @@ struct SunScreen: View {
4747 }
4848 .foregroundStyle(ink)
4949 .background(background.ignoresSafeArea())
50 .refreshable { await load() }
51 .task { await load() }
50 .refreshable { await load(force: true) }
51 .task { await load(force: false) }
5252 }
5353
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 }
5560 imageID = .now
5661 async let k = try? SpaceWeather.fetchKpBars()
5762 async let x = try? XRay.fetch()
@@ -61,6 +66,7 @@ struct SunScreen: View {
6166 xray = xrayResult ?? xray
6267 ovation = ovationResult ?? ovation
6368 failed = kpResult == nil || xrayResult == nil || ovationResult == nil
69 if !failed { FeedMemo.set("sun", (kp, xray, ovation)) }
6470 }
6571
6672 private var image: some View {
Packages/MoonriseKit/Sources/Library/Library.swift +32 −5
@@ -137,15 +137,38 @@ public enum Library {
137137
138138 /// Case- and diacritic-insensitive match on title and subtitle first, then facts and essay.
139139 public static func search(_ query: String) -> [LibraryPage] {
140 let q = query.trimmingCharacters(in: .whitespaces)
140 let q = fold(query.trimmingCharacters(in: .whitespaces))
141141 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) }
146145 }
147146 return strong + weak
148147 }
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 }
149172}
150173
151174extension LibraryPage {
@@ -153,6 +176,10 @@ extension LibraryPage {
153176 /// A title also matches by its part before a dash or bracket ("Cassini" for "Cassini–Huygens").
154177 /// Titles shorter than five characters ("Sun", "Moon", "Io") only count when they are mentioned here.
155178 public func related(limit: Int = 10) -> [LibraryPage] {
179 Library.cachedRelated(self) { uncachedRelated(limit: limit) }
180 }
181
182 private func uncachedRelated(limit: Int) -> [LibraryPage] {
156183 func mentions(_ text: String, _ title: String) -> Bool {
157184 var search = text.startIndex..<text.endIndex
158185 while let r = text.range(of: title, range: search) {
Packages/MoonriseKit/Sources/SkyRenderer/SkyScene.swift +3
@@ -52,6 +52,9 @@ public enum Catalog {
5252 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]) }
5353 }()
5454
55 /// Stars by HR number.
56 public static let starsByHR: [Int: Star] = Dictionary(uniqueKeysWithValues: stars.map { ($0.hr, $0) })
57
5558 /// Built by scripts/make-skycultures.py.
5659 public static let skyCultures: [SkyCulture] = {
5760 let url = Bundle.module.url(forResource: "skycultures", withExtension: "json")!
server/src/index.ts +11 −7
@@ -29,7 +29,11 @@ interface DeviceRequest {
2929const LAUNCH_CRON = "*/10 * * * *";
3030const SATELLITE_CRON = "7 */2 * * *";
3131
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;
3337
3438function validDevice(d: Partial<DeviceRequest>): d is DeviceRequest {
3539 return (
@@ -42,12 +46,12 @@ function validDevice(d: Partial<DeviceRequest>): d is DeviceRequest {
4246
4347async function today(env: Env, now: Date): Promise<Response> {
4448 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);
4650 const w = JSON.parse(row.body) as SpaceWeather;
4751 w.forecast = w.forecast.map((f) => ({ ...f, start: new Date(f.start) }));
4852 return json({
4953 spaceWeather: { kpNow: w.kpNow, observedAt: w.observedAt, kpNext24h: next24h(w, now), fetchedAt: row.fetched_at },
50 });
54 }, 200, MAX_AGE.today);
5155}
5256
5357export default {
@@ -66,7 +70,7 @@ export default {
6670 if (request.method === "GET" && url.pathname === "/v1/live") return live(env, url, now);
6771 if (request.method === "GET" && url.pathname === "/v1/satellites") {
6872 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);
7074 }
7175
7276 if (request.method === "POST" && url.pathname === "/v1/devices") {
@@ -125,7 +129,7 @@ async function live(env: Env, url: URL, now: Date): Promise<Response> {
125129 const at = atParam ? new Date(atParam) : now;
126130 if (Number.isNaN(at.getTime())) return json({ error: "invalid at" }, 400);
127131 const event = activeEvent(at);
128 if (!event) return json({ event: null });
132 if (!event) return json({ event: null }, 200, MAX_AGE.live);
129133
130134 const key = `live:${event.id}`;
131135 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> {
144148 console.error("horizons", String(e));
145149 }
146150 }
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);
148152}
149153
150154async function launches(env: Env, now: Date): Promise<Response> {
@@ -159,7 +163,7 @@ async function launches(env: Env, now: Date): Promise<Response> {
159163 mission: r.mission, missionType: r.mission_type, missionDescription: r.mission_description, orbit: r.orbit,
160164 pad: r.pad, location: r.location, padLatitude: r.pad_latitude, padLongitude: r.pad_longitude,
161165 })),
162 });
166 }, 200, MAX_AGE.launches);
163167}
164168
165169// Failures are recorded under "<feed>:error" so the last good snapshot and its time survive.