@aguacerowx/mapsgl 0.0.53 → 0.0.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,11 +22,17 @@ import {
22
22
  getNwwsProductKeyFromFeature,
23
23
  normalizeFeatureCollectionForMapboxGl,
24
24
  normalizeNwwsHttpAlertFeature,
25
+ nwsFeatureOverlapsUnixWindow,
25
26
  nwsRevisionVisibilityActive,
26
27
  parseNwwsWsDelta,
27
28
  pickBestNwsRevisionAmongConflictingFeatures,
28
29
  unwrapNwwsFeatureCollectionRoot,
29
30
  } from './nwsAlertsSupport.js';
31
+ import {
32
+ buildNwwsActiveAlertsUrl,
33
+ nwsAlertsFetchSpecCacheKey,
34
+ nwwsAlertsFetchUnixWindow,
35
+ } from './nwsAlertsFetchSpec.js';
30
36
 
31
37
  /** Aguacero base style: state polygon outlines — NWS alert lines render under this layer. */
32
38
  export const NWS_DEFAULT_LINE_BEFORE_LAYER_ID = 'AML_-_states';
@@ -131,6 +137,12 @@ export class NwsWatchesWarningsOverlay {
131
137
  this._destroyed = false;
132
138
  /** When true, baseline fetch + SSE have been started for the current enabled session. */
133
139
  this._started = false;
140
+ /** GET `/alerts?hours=` — same basis as aguacero-frontend observational duration (tier-clamped). */
141
+ this._alertsFetchHours = 1;
142
+ /** {@link nwsAlertsFetchSpecCacheKey} for the last baseline request; refetch when hours change. */
143
+ this._lastAlertsSpecKey = null;
144
+ /** Invalidates in-flight baseline HTTP when a newer hours spec is requested. */
145
+ this._baselineFetchGen = 0;
134
146
 
135
147
  /** @type {number | null | undefined} */
136
148
  this._timelineUnix = undefined;
@@ -275,6 +287,8 @@ export class NwsWatchesWarningsOverlay {
275
287
  this.syncWithMode({
276
288
  enabled: this.options.enabled,
277
289
  timelineUnix: this._timelineUnix,
290
+ timelineTimes: this._timelineTimes,
291
+ alertsFetchHours: this._alertsFetchHours,
278
292
  });
279
293
  }
280
294
 
@@ -664,13 +678,35 @@ export class NwsWatchesWarningsOverlay {
664
678
  }
665
679
 
666
680
  async _fetchBaseline() {
667
- const url = this.getAlertsUrl();
681
+ const myGen = ++this._baselineFetchGen;
682
+ const hours = Math.max(1, Math.floor(Number(this._alertsFetchHours) || 1));
683
+ const url = buildNwwsActiveAlertsUrl(this.getAlertsUrl(), hours);
668
684
  const res = await fetch(url);
669
685
  if (!res.ok) throw new Error(`NWS alerts HTTP ${res.status}`);
670
686
  const parsed = await res.json();
687
+ if (myGen !== this._baselineFetchGen) return null;
671
688
  const geojson = unwrapNwwsFeatureCollectionRoot(parsed);
672
689
  if (!geojson) return null;
673
- return this._normalizeSnapshot(geojson);
690
+ const { winStartSec, winEndSec } = nwwsAlertsFetchUnixWindow(hours, null);
691
+ const overlapping = (geojson.features ?? []).filter((f) =>
692
+ nwsFeatureOverlapsUnixWindow(f, winStartSec, winEndSec)
693
+ );
694
+ const filtered = { ...geojson, features: overlapping };
695
+ if (myGen !== this._baselineFetchGen) return null;
696
+ return this._normalizeSnapshot(filtered);
697
+ }
698
+
699
+ async _refetchBaselineForNewHours() {
700
+ if (this._destroyed || !this.options.enabled) return;
701
+ try {
702
+ const baseline = await this._fetchBaseline();
703
+ if (baseline && !this._destroyed && this.options.enabled) {
704
+ this._workingFc = baseline;
705
+ this._setWorkingData(baseline);
706
+ }
707
+ } catch (err) {
708
+ console.warn('[NwsWatchesWarningsOverlay] Baseline refetch (hours change) failed:', err);
709
+ }
674
710
  }
675
711
 
676
712
  _processInboundSocketText(text) {
@@ -806,14 +842,6 @@ export class NwsWatchesWarningsOverlay {
806
842
 
807
843
  async _initialLoad() {
808
844
  if (this._destroyed || !this.options.enabled) return;
809
- try {
810
- const baseline = await this._fetchBaseline();
811
- if (baseline) {
812
- this._workingFc = baseline;
813
- }
814
- } catch (err) {
815
- console.warn('[NwsWatchesWarningsOverlay] Baseline fetch failed:', err);
816
- }
817
845
 
818
846
  const paintWhenReady = () => {
819
847
  this._ensureLayers();
@@ -826,6 +854,18 @@ export class NwsWatchesWarningsOverlay {
826
854
  paintWhenReady();
827
855
  }
828
856
 
857
+ void this._fetchBaseline()
858
+ .then((baseline) => {
859
+ if (this._destroyed || !this.options.enabled) return;
860
+ if (baseline) {
861
+ this._workingFc = baseline;
862
+ }
863
+ this._setWorkingData(this._workingFc);
864
+ })
865
+ .catch((err) => {
866
+ console.warn('[NwsWatchesWarningsOverlay] Baseline fetch failed:', err);
867
+ });
868
+
829
869
  this._connectSse();
830
870
  }
831
871
 
@@ -884,9 +924,17 @@ export class NwsWatchesWarningsOverlay {
884
924
  * @param {number | null | undefined} opts.timelineUnix - Active scrubber unix for satellite / MRMS / NEXRAD.
885
925
  * @param {number[] | null | undefined} [opts.timelineTimes] - Full timeline for the current layer (for “live edge” = wall clock).
886
926
  * @param {boolean} [opts.hasObservationTimeline] - When false (model / base map only), scrub hold is cleared and wall clock is used until observation data returns.
927
+ * @param {number} [opts.alertsFetchHours] - GET `/alerts?hours=` (from WeatherLayerManager / {@link computeNwsAlertsFetchHoursFromAguaceroState}); refetches baseline when this changes.
887
928
  */
888
- syncWithMode({ enabled, timelineUnix, timelineTimes, hasObservationTimeline }) {
929
+ syncWithMode({ enabled, timelineUnix, timelineTimes, hasObservationTimeline, alertsFetchHours }) {
889
930
  if (this._destroyed) return;
931
+ const hours =
932
+ alertsFetchHours != null && Number.isFinite(Number(alertsFetchHours))
933
+ ? Math.max(1, Math.floor(Number(alertsFetchHours)))
934
+ : 1;
935
+ const specKey = nwsAlertsFetchSpecCacheKey(hours);
936
+ this._alertsFetchHours = hours;
937
+
890
938
  this.options.enabled = !!enabled;
891
939
  if (hasObservationTimeline === false) {
892
940
  this._stickyTimelineUnix = null;
@@ -898,6 +946,7 @@ export class NwsWatchesWarningsOverlay {
898
946
  if (!this.options.enabled) {
899
947
  this._stickyTimelineUnix = null;
900
948
  this._started = false;
949
+ this._lastAlertsSpecKey = null;
901
950
  this._sseFirstOpen = true;
902
951
  this._tearDownStream();
903
952
  this._removeLayers();
@@ -905,7 +954,13 @@ export class NwsWatchesWarningsOverlay {
905
954
  }
906
955
  if (!this._started) {
907
956
  this._started = true;
957
+ this._lastAlertsSpecKey = specKey;
908
958
  void this._initialLoad();
959
+ return;
960
+ }
961
+ if (this._lastAlertsSpecKey !== specKey) {
962
+ this._lastAlertsSpecKey = specKey;
963
+ void this._refetchBaselineForNewHours();
909
964
  }
910
965
  // When already running, time filtering is applied in setTimelineUnix (avoids double work
911
966
  // per scrub tick, matching aguacero-frontend: no full setData on slider move).
@@ -6,6 +6,7 @@ import { SatelliteShaderManager } from './SatelliteShaderManager.js';
6
6
  import { NexradWeatherController } from './NexradWeatherController.js';
7
7
  import { NexradSitesOverlay } from './NexradSitesOverlay.js';
8
8
  import { NwsWatchesWarningsOverlay } from './NwsWatchesWarningsOverlay.js';
9
+ import { computeNwsAlertsFetchHoursFromAguaceroState } from './nwsAlertsFetchSpec.js';
9
10
  import WorkerPool from './WorkerPool.js';
10
11
 
11
12
  import { DEFAULT_BASIS_BASE_URL } from './defaultBasisBaseUrl.js';
@@ -292,6 +293,10 @@ export class WeatherLayerManager extends EventEmitter {
292
293
  /**
293
294
  * NWS watches/warnings: same NWWS feed as aguacero-frontend. Shown whenever `enabled` is true (including
294
295
  * model-only maps — time filter uses wall clock until a satellite / MRMS / NEXRAD scrub timeline exists).
296
+ * Baseline GET `/alerts?hours=N` matches the **active** timeline span: NEXRAD uses `nexradDurationValue`,
297
+ * MRMS uses `mrmsDurationValue`, satellite uses `satelliteDurationValue`, model-only uses 1 hour.
298
+ * Hours are clamped by {@link AguaceroCore} `satelliteTier` (the core’s subscription tier field:
299
+ * `basic` / `enthusiast` / `professional`), same caps as the frontend radar duration menus.
295
300
  * By default `nwsAlertSettings.alertScope` is `'all'`. Use `'user'` with
296
301
  * `includedAlerts` (exact NWS `event_name` strings) to show only selected products.
297
302
  *
@@ -353,6 +358,8 @@ export class WeatherLayerManager extends EventEmitter {
353
358
  ? wopt.fillBeforeLayerId ?? null
354
359
  : null;
355
360
 
361
+ const alertsFetchHours = computeNwsAlertsFetchHoursFromAguaceroState(state);
362
+
356
363
  const nwsAlertSettingsForOpts = {
357
364
  ...(wopt.nwsAlertSettings ?? {}),
358
365
  ...(wopt.activeOnlyRealtime !== undefined ? { activeOnlyRealtime: wopt.activeOnlyRealtime } : {}),
@@ -384,7 +391,13 @@ export class WeatherLayerManager extends EventEmitter {
384
391
  nwsAlertSettings: nwsAlertSettingsForOpts,
385
392
  });
386
393
  }
387
- this._nwsOverlay.syncWithMode({ enabled, timelineUnix, timelineTimes, hasObservationTimeline });
394
+ this._nwsOverlay.syncWithMode({
395
+ enabled,
396
+ timelineUnix,
397
+ timelineTimes,
398
+ hasObservationTimeline,
399
+ alertsFetchHours,
400
+ });
388
401
  }
389
402
 
390
403
  /**
@@ -1,4 +1,6 @@
1
1
  import type { NexradSite } from './PreprocessedSweepParser.js';
2
+ import nexradSitesDefault from './nexradSitesDefault.json';
3
+ import { nexradArchiveDiag, redactApiKeyFromUrl } from './nexradArchiveDiag.js';
2
4
 
3
5
  type NexradSitesPayload = {
4
6
  sites?: NexradSite[];
@@ -13,22 +15,98 @@ export function setNexradSitesJsonUrl(url: string) {
13
15
  sitesUrl = url || sitesUrl;
14
16
  }
15
17
 
18
+ /**
19
+ * When sitesUrl is HTTPS (e.g. CloudFront), use the same auth as AguaceroCore grid fetches:
20
+ * `?apiKey=`, `x-api-key`, and `x-app-identifier` on React Native when bundle id is set.
21
+ */
22
+ let SITES_FETCH_API_KEY = '';
23
+ let SITES_FETCH_BUNDLE_ID = '';
24
+ export function setNexradSitesFetchAuth(apiKey: string, bundleId?: string) {
25
+ SITES_FETCH_API_KEY = apiKey || '';
26
+ if (bundleId !== undefined) {
27
+ SITES_FETCH_BUNDLE_ID = bundleId || '';
28
+ }
29
+ }
30
+
16
31
  let nexradSitesPayloadPromise: Promise<NexradSitesPayload> | null = null;
17
32
 
33
+ function sitesFetchUrl(): string {
34
+ if (!sitesUrl.startsWith('http') || !SITES_FETCH_API_KEY) {
35
+ return sitesUrl;
36
+ }
37
+ const sep = sitesUrl.includes('?') ? '&' : '?';
38
+ return `${sitesUrl}${sep}apiKey=${SITES_FETCH_API_KEY}`;
39
+ }
40
+
41
+ function sitesFetchHeaders(): Record<string, string> | undefined {
42
+ if (!SITES_FETCH_API_KEY) {
43
+ return undefined;
44
+ }
45
+ const headers: Record<string, string> = {
46
+ 'x-api-key': SITES_FETCH_API_KEY,
47
+ };
48
+ const nav = (globalThis as { navigator?: { product?: string } }).navigator;
49
+ if (nav?.product === 'ReactNative' && SITES_FETCH_BUNDLE_ID) {
50
+ headers['x-app-identifier'] = SITES_FETCH_BUNDLE_ID;
51
+ }
52
+ return headers;
53
+ }
54
+
55
+ function embeddedSitesPayload(): NexradSitesPayload {
56
+ return nexradSitesDefault as NexradSitesPayload;
57
+ }
58
+
18
59
  export function loadNexradSitesPayload(): Promise<NexradSitesPayload> {
19
60
  if (nexradSitesPayloadPromise) {
20
61
  return nexradSitesPayloadPromise;
21
62
  }
22
- nexradSitesPayloadPromise = fetch(sitesUrl)
23
- .then((response) => {
24
- if (!response.ok) throw new Error(`nexrad.json fetch failed: HTTP ${response.status}`);
25
- return response.json() as Promise<NexradSitesPayload>;
26
- })
27
- .catch((error) => {
63
+ nexradSitesPayloadPromise = (async (): Promise<NexradSitesPayload> => {
64
+ const url = sitesFetchUrl();
65
+ const headers = sitesFetchHeaders();
66
+ const headerKeys = headers ? Object.keys(headers).join(',') : '';
67
+ nexradArchiveDiag('sites.fetch.start', {
68
+ canonicalUrl: sitesUrl,
69
+ fetchUrl: redactApiKeyFromUrl(url),
70
+ hasApiKeyQuery: url.includes('apiKey='),
71
+ headerKeys: headerKeys || '(none)',
72
+ apiKeyLen: SITES_FETCH_API_KEY.length,
73
+ bundleIdLen: SITES_FETCH_BUNDLE_ID.length,
74
+ });
75
+ try {
76
+ const response = await fetch(url, headers ? { headers } : undefined);
77
+ if (response.ok) {
78
+ nexradArchiveDiag('sites.fetch.ok', {
79
+ status: response.status,
80
+ canonicalUrl: sitesUrl,
81
+ });
82
+ return (await response.json()) as NexradSitesPayload;
83
+ }
84
+ if (sitesUrl.startsWith('https://')) {
85
+ console.warn(
86
+ `[mapsgl] nexrad.json HTTP ${response.status} for ${sitesUrl} — using embedded site list`,
87
+ );
88
+ nexradArchiveDiag('sites.fetch.fallbackEmbedded', {
89
+ httpStatus: response.status,
90
+ reason: 'HTTPS non-OK — CloudFront may not host /data/nexrad.json; embedded list used',
91
+ canonicalUrl: sitesUrl,
92
+ });
93
+ return embeddedSitesPayload();
94
+ }
95
+ throw new Error(`nexrad.json fetch failed: HTTP ${response.status}`);
96
+ } catch (error) {
97
+ if (sitesUrl.startsWith('https://')) {
98
+ console.warn('[mapsgl] nexrad.json load failed — using embedded site list:', error);
99
+ nexradArchiveDiag('sites.fetch.catchFallbackEmbedded', {
100
+ message: error instanceof Error ? error.message : String(error),
101
+ canonicalUrl: sitesUrl,
102
+ });
103
+ return embeddedSitesPayload();
104
+ }
28
105
  console.error('[mapsgl] Could not load nexrad.json:', error);
29
106
  nexradSitesPayloadPromise = null;
30
107
  throw error;
31
- });
108
+ }
109
+ })();
32
110
  return nexradSitesPayloadPromise;
33
111
  }
34
112
 
@@ -1,66 +1,286 @@
1
- // Shared archive cache for decoded NEXRAD sweep frames.
2
- // Lives in a separate module so RadarLayer and GlobalStateContext can both access it
3
- // without creating a circular import dependency.
4
- //
5
- // Rolling cache across all radar sites: we do NOT clear on station or variable change,
6
- // so returning to a previously viewed site reuses cached frames. When the cache exceeds
7
- // MAX_ARCHIVE_CACHE_ENTRIES, the oldest entries (by insertion order) are evicted.
8
-
9
- // ─── Types ────────────────────────────────────────────────────────────────────
10
-
11
- export type DecodedRadarFrame = {
12
- gateData: Uint8Array;
13
- nRays: number;
14
- nGates: number;
15
- stationLat: number;
16
- stationLon: number;
17
- firstGateKm: number;
18
- gateWidthKm: number;
19
- valueScale: number;
20
- valueOffset: number;
21
- rayBoundariesDeg: Float32Array;
22
- /** Nyquist velocity (m/s) from Level-II archive header; used for VEL readout (aguacero-frontend parity). */
23
- embeddedNyquistMs?: number | null;
24
- };
25
-
26
- // ─── Cache storage ────────────────────────────────────────────────────────────
27
-
28
- // Rolling cache size. Keeps decoded frames across site/variable switches; evicts oldest when over.
29
- // ~14 tilts × ~72 time steps ≈ 1008 frames per site; 4 sites ≈ 4k frames.
30
- const MAX_ARCHIVE_CACHE_ENTRIES = 5000;
31
-
32
- export const archiveCache = new Map<string, DecodedRadarFrame | null>();
33
-
34
- export function setArchiveCache(url: string, archive: DecodedRadarFrame | null): void {
35
- archiveCache.delete(url);
36
- archiveCache.set(url, archive);
37
- while (archiveCache.size > MAX_ARCHIVE_CACHE_ENTRIES) {
38
- const oldestKey = archiveCache.keys().next().value;
39
- if (!oldestKey) break;
40
- archiveCache.delete(oldestKey);
41
- }
42
- }
43
-
44
- // ─── Optional explicit eviction ────────────────────────────────────────────────
45
-
46
- /**
47
- * Remove all archiveCache entries that belong to a given radar station.
48
- * Not used on station change (rolling cache); available for manual/optional clearing.
49
- */
50
- export function clearArchiveCacheForStation(stationId: string): void {
51
- const prefix = `/${stationId}_`;
52
- for (const url of Array.from(archiveCache.keys())) {
53
- if (url.includes(prefix)) archiveCache.delete(url);
54
- }
55
- }
56
-
57
- /**
58
- * Remove all archiveCache entries for a specific station + variable combination.
59
- * Not used on variable change (rolling cache); available for manual/optional clearing.
60
- */
61
- export function clearArchiveCacheForStationVariable(stationId: string, variable: string): void {
62
- const prefix = `/${stationId}_${variable}_`;
63
- for (const url of Array.from(archiveCache.keys())) {
64
- if (url.includes(prefix)) archiveCache.delete(url);
65
- }
66
- }
1
+ // Parity with aguacero-frontend `src/components/Map/layers/radar/nexradArchiveCache.ts`.
2
+ // Listing-synced pruning + ref-counted clears; hard caps as failsafe.
3
+ //
4
+ // ─── Public URL bases (must match radarArchiveCore `objectKeyToUrl`) ───────────
5
+
6
+ export const NEXRAD_ARCHIVE_LEVEL2_BASE_URL = 'https://d3dc62msmxkrd7.cloudfront.net/level-2';
7
+ export const NEXRAD_ARCHIVE_LEVEL3_BASE_URL = 'https://unidata-nexrad-level3.s3.amazonaws.com';
8
+
9
+ // ─── Types ────────────────────────────────────────────────────────────────────
10
+
11
+ export type DecodedRadarFrame = {
12
+ gateData: Uint8Array;
13
+ nRays: number;
14
+ nGates: number;
15
+ stationLat: number;
16
+ stationLon: number;
17
+ firstGateKm: number;
18
+ gateWidthKm: number;
19
+ valueScale: number;
20
+ valueOffset: number;
21
+ azimuthsDeg?: Float32Array;
22
+ rayBoundariesDeg: Float32Array;
23
+ embeddedNyquistMs?: number | null;
24
+ };
25
+
26
+ // ─── Cache storage ────────────────────────────────────────────────────────────
27
+
28
+ const MAX_ARCHIVE_CACHE_ENTRIES = 400;
29
+ const MAX_ARCHIVE_CACHE_BYTES = 180 * 1024 * 1024;
30
+
31
+ export const archiveCache = new Map<string, DecodedRadarFrame | null>();
32
+
33
+ let pruneAllowlistObjectKeys: ReadonlySet<string> | null = null;
34
+ let pruneSupplementalObjectKeys: ReadonlySet<string> = new Set();
35
+
36
+ export function setNexradArchivePruneSupplementalObjectKeys(keys: Set<string> | null): void {
37
+ pruneSupplementalObjectKeys = keys ?? new Set();
38
+ }
39
+
40
+ export function setNexradArchivePruneAllowlist(allowed: ReadonlySet<string> | null): void {
41
+ pruneAllowlistObjectKeys = allowed;
42
+ }
43
+
44
+ function canInsertCacheKey(cacheKey: string, frame: DecodedRadarFrame | null): boolean {
45
+ if (frame == null) return true;
46
+ if (pruneAllowlistObjectKeys == null) return true;
47
+ const o = extractObjectKeyFromCacheKey(cacheKey);
48
+ if (o == null) return true;
49
+ if (pruneAllowlistObjectKeys.has(o)) return true;
50
+ if (pruneSupplementalObjectKeys.has(o)) return true;
51
+ return false;
52
+ }
53
+
54
+ function estimateFrameBytes(value: DecodedRadarFrame | null | undefined): number {
55
+ if (value == null) return 0;
56
+ const g = value.gateData;
57
+ const r = value.rayBoundariesDeg;
58
+ const a = value.azimuthsDeg;
59
+ return (g?.byteLength ?? 0) + (r?.byteLength ?? 0) + (a?.byteLength ?? 0);
60
+ }
61
+
62
+ let cacheBytesTotal = 0;
63
+
64
+ let onArchiveCacheKeysRemoved: ((keys: readonly string[]) => void) | null = null;
65
+
66
+ export function setArchiveCacheKeyRemovalListener(fn: ((keys: readonly string[]) => void) | null): void {
67
+ onArchiveCacheKeysRemoved = fn;
68
+ }
69
+
70
+ function emitArchiveCacheKeysRemoved(keys: string[]): void {
71
+ if (keys.length === 0) return;
72
+ onArchiveCacheKeysRemoved?.(keys);
73
+ }
74
+
75
+ function recomputeCacheBytes(): void {
76
+ let n = 0;
77
+ for (const v of archiveCache.values()) {
78
+ n += estimateFrameBytes(v);
79
+ }
80
+ cacheBytesTotal = n;
81
+ }
82
+
83
+ function evictOldestWhileOverCap(): void {
84
+ const removed: string[] = [];
85
+ while (archiveCache.size > MAX_ARCHIVE_CACHE_ENTRIES || cacheBytesTotal > MAX_ARCHIVE_CACHE_BYTES) {
86
+ const oldestKey = archiveCache.keys().next().value;
87
+ if (!oldestKey) break;
88
+ const v = archiveCache.get(oldestKey);
89
+ archiveCache.delete(oldestKey);
90
+ removed.push(oldestKey);
91
+ cacheBytesTotal -= estimateFrameBytes(v);
92
+ if (cacheBytesTotal < 0) recomputeCacheBytes();
93
+ }
94
+ emitArchiveCacheKeysRemoved(removed);
95
+ }
96
+
97
+ export function setArchiveCache(cacheKey: string, archive: DecodedRadarFrame | null): void {
98
+ if (archive && !canInsertCacheKey(cacheKey, archive)) {
99
+ return;
100
+ }
101
+ const prev = archiveCache.get(cacheKey);
102
+ if (prev !== undefined) {
103
+ cacheBytesTotal -= estimateFrameBytes(prev);
104
+ }
105
+ archiveCache.delete(cacheKey);
106
+ archiveCache.set(cacheKey, archive);
107
+ if (archive) {
108
+ cacheBytesTotal += estimateFrameBytes(archive);
109
+ }
110
+ evictOldestWhileOverCap();
111
+ }
112
+
113
+ export function extractObjectKeyFromCacheKey(cacheKey: string): string | null {
114
+ const m = cacheKey.match(/:([A-Za-z0-9_]+):((?:level2|level3))(?:\|.*)?$/i);
115
+ if (!m || m.index == null || m.index < 1) return null;
116
+ const urlPart = cacheKey.slice(0, m.index);
117
+ return objectKeyFromArchiveUrlString(urlPart);
118
+ }
119
+
120
+ export function objectKeyFromArchiveUrlString(url: string): string | null {
121
+ for (const base of [NEXRAD_ARCHIVE_LEVEL2_BASE_URL, NEXRAD_ARCHIVE_LEVEL3_BASE_URL]) {
122
+ const prefix = base.endsWith('/') ? base : `${base}/`;
123
+ if (url.startsWith(prefix)) {
124
+ return url.slice(prefix.length);
125
+ }
126
+ }
127
+ if (url.startsWith('http')) {
128
+ try {
129
+ const u = new URL(url);
130
+ const p = u.pathname.replace(/^\/+/, '');
131
+ if (p.startsWith('level-2/')) return p.slice('level-2/'.length);
132
+ return p || null;
133
+ } catch {
134
+ return null;
135
+ }
136
+ }
137
+ return null;
138
+ }
139
+
140
+ type RadarLayerListPayload = {
141
+ timeToKeyMap?: Record<string, string>;
142
+ level3MotionTimeToKeyMap?: Record<string, string | undefined> | null;
143
+ unixTimes?: number[];
144
+ };
145
+
146
+ export function buildNexradArchiveAllowedObjectKeySet(
147
+ layers: Record<string, RadarLayerListPayload | undefined>,
148
+ radarLayerKeys: string[],
149
+ _matchedByRadarKey: Record<string, number | null | undefined>
150
+ ): Set<string> {
151
+ const s = new Set<string>();
152
+ for (const radarKey of radarLayerKeys) {
153
+ const L = layers[radarKey];
154
+ if (!L?.timeToKeyMap) continue;
155
+ for (const o of Object.values(L.timeToKeyMap)) {
156
+ if (o) s.add(o);
157
+ }
158
+ const mm = L.level3MotionTimeToKeyMap;
159
+ if (mm && typeof mm === 'object') {
160
+ for (const o of Object.values(mm)) {
161
+ if (o) s.add(o);
162
+ }
163
+ }
164
+ }
165
+ return s;
166
+ }
167
+
168
+ /**
169
+ * True if the cache key is for this station and radar variable (`url:VAR:level2|level3[|motion]`).
170
+ */
171
+ export function archiveCacheKeyMatchesStationVariable(
172
+ cacheKey: string,
173
+ stationId: string,
174
+ variable: string,
175
+ ): boolean {
176
+ if (!stationId || !variable) return false;
177
+ const v = variable.toUpperCase();
178
+ const m = cacheKey.match(/:([A-Za-z0-9_]+):((?:level2|level3))(?:\|.*)?$/i);
179
+ if (!m || m[1].toUpperCase() !== v) return false;
180
+ const sid = stationId.toUpperCase();
181
+ const o = extractObjectKeyFromCacheKey(cacheKey);
182
+ if (o) {
183
+ return o.toUpperCase().startsWith(`${sid}_`);
184
+ }
185
+ return cacheKey.toUpperCase().includes(`/${sid}_`);
186
+ }
187
+
188
+ /**
189
+ * Best-effort sweep time (unix sec) from a NEXRAD S3 object key.
190
+ */
191
+ export function parseNexradS3ObjectKeySweepUnixSec(objectKey: string): number | null {
192
+ if (!objectKey) return null;
193
+ const l2 = objectKey.match(/_(\d{10,12})_g[12](?:\.bin)?$/i);
194
+ if (l2) {
195
+ const u = parseInt(l2[1], 10);
196
+ if (u > 1e9 && u < 2e10) return u;
197
+ }
198
+ const m = objectKey.match(/_(\d{4})_(\d{2})_(\d{2})_(\d{2})_(\d{2})_(\d{2})(?:\.\w+)?$/);
199
+ if (m) {
200
+ const [, y, mo, d, h, mi, s] = m;
201
+ const unix = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s)) / 1000;
202
+ return Number.isFinite(unix) ? unix : null;
203
+ }
204
+ return null;
205
+ }
206
+
207
+ export function pruneArchiveCacheOutsideObservationalWindow(minSec: number, nowSec: number): void {
208
+ const removed: string[] = [];
209
+ for (const key of Array.from(archiveCache.keys())) {
210
+ const o = extractObjectKeyFromCacheKey(key);
211
+ if (o == null) {
212
+ continue;
213
+ }
214
+ if (pruneSupplementalObjectKeys.has(o)) continue;
215
+ const t = parseNexradS3ObjectKeySweepUnixSec(o);
216
+ if (t == null) {
217
+ continue;
218
+ }
219
+ if (t < minSec || t > nowSec) {
220
+ const v = archiveCache.get(key);
221
+ archiveCache.delete(key);
222
+ removed.push(key);
223
+ cacheBytesTotal -= estimateFrameBytes(v);
224
+ }
225
+ }
226
+ if (cacheBytesTotal < 0) recomputeCacheBytes();
227
+ evictOldestWhileOverCap();
228
+ emitArchiveCacheKeysRemoved(removed);
229
+ }
230
+
231
+ export function pruneArchiveCacheNotInAllowedObjectKeys(allowed: ReadonlySet<string>): void {
232
+ const removed: string[] = [];
233
+ for (const key of Array.from(archiveCache.keys())) {
234
+ const o = extractObjectKeyFromCacheKey(key);
235
+ if (o == null) {
236
+ const val = archiveCache.get(key);
237
+ archiveCache.delete(key);
238
+ removed.push(key);
239
+ cacheBytesTotal -= estimateFrameBytes(val);
240
+ continue;
241
+ }
242
+ if (allowed.has(o) || pruneSupplementalObjectKeys.has(o)) continue;
243
+ const v = archiveCache.get(key);
244
+ archiveCache.delete(key);
245
+ removed.push(key);
246
+ cacheBytesTotal -= estimateFrameBytes(v);
247
+ }
248
+ if (cacheBytesTotal < 0) recomputeCacheBytes();
249
+ evictOldestWhileOverCap();
250
+ emitArchiveCacheKeysRemoved(removed);
251
+ }
252
+
253
+ export function clearArchiveCacheForStation(stationId: string): void {
254
+ if (!stationId) return;
255
+ const prefix = `/${stationId}_`;
256
+ const removed: string[] = [];
257
+ for (const key of Array.from(archiveCache.keys())) {
258
+ if (key.includes(prefix)) {
259
+ const v = archiveCache.get(key);
260
+ archiveCache.delete(key);
261
+ removed.push(key);
262
+ cacheBytesTotal -= estimateFrameBytes(v);
263
+ }
264
+ }
265
+ if (cacheBytesTotal < 0) recomputeCacheBytes();
266
+ evictOldestWhileOverCap();
267
+ emitArchiveCacheKeysRemoved(removed);
268
+ }
269
+
270
+ /**
271
+ * Remove decoded frames for a station + radar variable. Keys are `...:VAR:level2|level3`, not in the S3 path.
272
+ */
273
+ export function clearArchiveCacheForStationVariable(stationId: string, variable: string): void {
274
+ if (!stationId || !variable) return;
275
+ const removed: string[] = [];
276
+ for (const key of Array.from(archiveCache.keys())) {
277
+ if (!archiveCacheKeyMatchesStationVariable(key, stationId, variable)) continue;
278
+ const val = archiveCache.get(key);
279
+ archiveCache.delete(key);
280
+ removed.push(key);
281
+ cacheBytesTotal -= estimateFrameBytes(val);
282
+ }
283
+ if (cacheBytesTotal < 0) recomputeCacheBytes();
284
+ evictOldestWhileOverCap();
285
+ emitArchiveCacheKeysRemoved(removed);
286
+ }