@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.
- package/package.json +32 -31
- package/src/NexradWeatherController.js +520 -510
- package/src/NwsWatchesWarningsOverlay.js +66 -11
- package/src/WeatherLayerManager.js +14 -1
- package/src/nexrad/loadNexradSites.ts +85 -7
- package/src/nexrad/nexradArchiveCache.ts +286 -66
- package/src/nexrad/nexradArchiveDiag.ts +26 -0
- package/src/nexrad/nexradLevel3Products.ts +581 -581
- package/src/nexrad/nexradMapboxFrameOpts.bundled.js +3 -2
- package/src/nexrad/nexradMapboxFrameOpts.ts +3 -2
- package/src/nexrad/nexradSitesDefault.json +1700 -0
- package/src/nexrad/radarArchiveCore.bundled.js +2807 -42
- package/src/nexrad/radarArchiveCore.ts +149 -30
- package/src/nexrad/radarDecode.worker.bundled.js +130 -126
- package/src/nexrad/radarDecode.worker.ts +13 -215
- package/src/nexrad/radarDecodeSlot.ts +195 -0
- package/src/nwsAlertsFetchSpec.js +114 -0
- package/src/nwsAlertsSupport.js +28 -0
- package/src/nwsSdkConstants.js +8 -0
|
@@ -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
|
|
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
|
-
|
|
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({
|
|
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 =
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
embeddedNyquistMs?: number | null;
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
// ─── Cache storage ────────────────────────────────────────────────────────────
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
}
|