@aguacerowx/mapsgl 0.0.51 → 0.0.53
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 +3 -3
- package/src/NexradWeatherController.js +1 -1
- package/src/NwsWatchesWarningsOverlay.js +71 -7
- package/src/WeatherLayerManager.js +36 -13
- package/src/nexrad/nexradArchiveCache.ts +2 -0
- package/src/nexrad/nexradLevel3Products.ts +32 -0
- package/src/nexrad/nexradMapboxFrameOpts.bundled.js +244 -0
- package/src/nexrad/{nexradMapboxFrameOpts.js → nexradMapboxFrameOpts.ts} +38 -19
- package/src/nexrad/radarArchiveCore.bundled.js +118 -17
- package/src/nexrad/radarArchiveCore.ts +147 -29
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aguacerowx/mapsgl",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.53",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"type": "module",
|
|
10
10
|
"scripts": {
|
|
11
11
|
"prepublishOnly": "npm run bundle-nexrad",
|
|
12
|
-
"bundle-nexrad": "esbuild src/nexrad/radarDecode.worker.ts --bundle --format=esm --platform=browser --outfile=src/nexrad/radarDecode.worker.bundled.js && esbuild src/nexrad/radarArchiveCore.ts --bundle --format=esm --platform=browser --outfile=src/nexrad/radarArchiveCore.bundled.js --external:@aguacerowx/javascript-sdk && esbuild src/nexrad/MapboxRadarLayer.ts --bundle --format=esm --platform=browser --outfile=src/nexrad/MapboxRadarLayer.bundled.js --external:mapbox-gl --external:@aguacerowx/javascript-sdk && esbuild src/nexrad/nexradCrossSectionSampleAtLatLon.ts --format=esm --platform=browser --outfile=src/nexrad/nexradCrossSectionSampleAtLatLon.bundled.js && esbuild src/nexrad/radarFrameGpuMatch.ts --format=esm --platform=browser --outfile=src/nexrad/radarFrameGpuMatch.bundled.js",
|
|
12
|
+
"bundle-nexrad": "esbuild src/nexrad/radarDecode.worker.ts --bundle --format=esm --platform=browser --outfile=src/nexrad/radarDecode.worker.bundled.js && esbuild src/nexrad/radarArchiveCore.ts --bundle --format=esm --platform=browser --outfile=src/nexrad/radarArchiveCore.bundled.js --external:@aguacerowx/javascript-sdk && esbuild src/nexrad/MapboxRadarLayer.ts --bundle --format=esm --platform=browser --outfile=src/nexrad/MapboxRadarLayer.bundled.js --external:mapbox-gl --external:@aguacerowx/javascript-sdk && esbuild src/nexrad/nexradCrossSectionSampleAtLatLon.ts --format=esm --platform=browser --outfile=src/nexrad/nexradCrossSectionSampleAtLatLon.bundled.js && esbuild src/nexrad/radarFrameGpuMatch.ts --format=esm --platform=browser --outfile=src/nexrad/radarFrameGpuMatch.bundled.js && esbuild src/nexrad/nexradMapboxFrameOpts.ts --bundle --format=esm --platform=browser --outfile=src/nexrad/nexradMapboxFrameOpts.bundled.js --external:@aguacerowx/javascript-sdk",
|
|
13
13
|
"gen:nws-key": "esbuild ../../../aguacero-frontend/src/components/WarningsMenu/nwsWarningCustomizationKey.ts --bundle --format=esm --platform=neutral --outfile=src/nwsWarningCustomizationKey.gen.js"
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
"esbuild": "^0.21.5"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@aguacerowx/javascript-sdk": "^0.0.
|
|
24
|
+
"@aguacerowx/javascript-sdk": "^0.0.23",
|
|
25
25
|
"buffer": "^6.0.3",
|
|
26
26
|
"fzstd": "^0.1.1",
|
|
27
27
|
"mapbox-gl": "^3.4.0",
|
|
@@ -8,7 +8,7 @@ import { MapboxRadarLayer } from './nexrad/MapboxRadarLayer.bundled.js';
|
|
|
8
8
|
import { nexradBinGroupIdForKey, variableToNexradGroup } from '@aguacerowx/javascript-sdk';
|
|
9
9
|
import { sampleNexradFrameAtLatLon } from './nexrad/nexradCrossSectionSampleAtLatLon.bundled.js';
|
|
10
10
|
import { prepareRadarFrameForGpuReadout } from './nexrad/radarFrameGpuMatch.bundled.js';
|
|
11
|
-
import { mapboxFrameUploadOptionsForNexradState } from './nexrad/nexradMapboxFrameOpts.js';
|
|
11
|
+
import { mapboxFrameUploadOptionsForNexradState } from './nexrad/nexradMapboxFrameOpts.bundled.js';
|
|
12
12
|
|
|
13
13
|
function pickNearestLevel3ObjectKey(unixTime, timeToKeyMap, maxDeltaSec = 600) {
|
|
14
14
|
const direct = timeToKeyMap[String(unixTime)];
|
|
@@ -136,6 +136,8 @@ export class NwsWatchesWarningsOverlay {
|
|
|
136
136
|
this._timelineUnix = undefined;
|
|
137
137
|
/** Timeline unix list for the active layer (satellite / MRMS / NEXRAD) — used to match frontend “live edge” behavior. */
|
|
138
138
|
this._timelineTimes = null;
|
|
139
|
+
/** Last finite scrub time while sat/MRMS/NEXRAD is active — keeps filters during brief `nexradTimestamp: null` gaps. */
|
|
140
|
+
this._stickyTimelineUnix = null;
|
|
139
141
|
}
|
|
140
142
|
|
|
141
143
|
getAlertsUrl() {
|
|
@@ -161,6 +163,9 @@ export class NwsWatchesWarningsOverlay {
|
|
|
161
163
|
/** Merge top-level options (e.g. `deltaDebounceMs`, `alertsBaseUrl`) and/or nested `nwsAlertSettings`. */
|
|
162
164
|
updateOptions(partial) {
|
|
163
165
|
if (!partial || typeof partial !== 'object') return;
|
|
166
|
+
const prevScope = this._nwsAlertSettings.alertScope ?? 'all';
|
|
167
|
+
const prevIncludedKey = JSON.stringify(this._nwsAlertSettings.includedAlerts ?? []);
|
|
168
|
+
const prevActiveOnly = this._nwsAlertSettings.activeOnlyRealtime;
|
|
164
169
|
for (const [k, v] of Object.entries(partial)) {
|
|
165
170
|
if (v === undefined) continue;
|
|
166
171
|
if (k === 'nwsAlertSettings' && v && typeof v === 'object') {
|
|
@@ -173,8 +178,16 @@ export class NwsWatchesWarningsOverlay {
|
|
|
173
178
|
}
|
|
174
179
|
}
|
|
175
180
|
if (this.map?.getLayer?.(this._fillId)) {
|
|
181
|
+
const nextScope = this._nwsAlertSettings.alertScope ?? 'all';
|
|
182
|
+
const nextIncludedKey = JSON.stringify(this._nwsAlertSettings.includedAlerts ?? []);
|
|
183
|
+
const scopeFilterChanged = prevScope !== nextScope || prevIncludedKey !== nextIncludedKey;
|
|
184
|
+
const activeOnlyChanged = prevActiveOnly !== this._nwsAlertSettings.activeOnlyRealtime;
|
|
176
185
|
this._refreshPaint();
|
|
177
|
-
|
|
186
|
+
if (scopeFilterChanged) {
|
|
187
|
+
this._resyncSourceDataFilters();
|
|
188
|
+
} else if (activeOnlyChanged) {
|
|
189
|
+
this._applyTimeFilters();
|
|
190
|
+
}
|
|
178
191
|
this._syncClickHandler();
|
|
179
192
|
this._applyLayerOrder();
|
|
180
193
|
}
|
|
@@ -280,6 +293,9 @@ export class NwsWatchesWarningsOverlay {
|
|
|
280
293
|
*/
|
|
281
294
|
setTimelineUnix(unixSeconds) {
|
|
282
295
|
this._timelineUnix = unixSeconds;
|
|
296
|
+
if (unixSeconds != null && Number.isFinite(Number(unixSeconds))) {
|
|
297
|
+
this._stickyTimelineUnix = Number(unixSeconds);
|
|
298
|
+
}
|
|
283
299
|
this._applyTimeFilters();
|
|
284
300
|
}
|
|
285
301
|
|
|
@@ -295,8 +311,39 @@ export class NwsWatchesWarningsOverlay {
|
|
|
295
311
|
return this._nwsAlertSettings.flashStyles ?? {};
|
|
296
312
|
}
|
|
297
313
|
|
|
314
|
+
_hasNonEmptyTimelineTimes() {
|
|
315
|
+
const times = this._timelineTimes;
|
|
316
|
+
return Array.isArray(times) && times.length > 0;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Scrub reference: live `timelineUnix`, else last good scrub while observation data is loading, else newest tick.
|
|
321
|
+
*/
|
|
322
|
+
_effectiveScrubUnix() {
|
|
323
|
+
const raw = this._timelineUnix;
|
|
324
|
+
if (raw != null && Number.isFinite(Number(raw))) {
|
|
325
|
+
return Number(raw);
|
|
326
|
+
}
|
|
327
|
+
if (this._stickyTimelineUnix != null && Number.isFinite(this._stickyTimelineUnix)) {
|
|
328
|
+
return this._stickyTimelineUnix;
|
|
329
|
+
}
|
|
330
|
+
const times = this._timelineTimes;
|
|
331
|
+
if (Array.isArray(times) && times.length > 0) {
|
|
332
|
+
let latest = -Infinity;
|
|
333
|
+
for (const t of times) {
|
|
334
|
+
if (typeof t === 'number' && Number.isFinite(t) && t > latest) {
|
|
335
|
+
latest = t;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
if (Number.isFinite(latest)) {
|
|
339
|
+
return latest;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
298
345
|
_isFollowingLatestTimelineAnchor() {
|
|
299
|
-
const current = this.
|
|
346
|
+
const current = this._effectiveScrubUnix();
|
|
300
347
|
if (current == null || !Number.isFinite(current)) return false;
|
|
301
348
|
const times = this._timelineTimes;
|
|
302
349
|
if (!Array.isArray(times) || times.length === 0) return false;
|
|
@@ -316,11 +363,22 @@ export class NwsWatchesWarningsOverlay {
|
|
|
316
363
|
*/
|
|
317
364
|
_resolveDisplayTimeMode() {
|
|
318
365
|
const userPinnedRealtime = this._nwsAlertSettings.activeOnlyRealtime === true;
|
|
366
|
+
const effectiveUnix = this._effectiveScrubUnix();
|
|
319
367
|
const followRealtimeAtLatestAnchor = !userPinnedRealtime && this._isFollowingLatestTimelineAnchor();
|
|
320
|
-
const
|
|
368
|
+
const noScrubObservationTimelineUseWallClock =
|
|
369
|
+
!userPinnedRealtime &&
|
|
370
|
+
effectiveUnix == null &&
|
|
371
|
+
!this._hasNonEmptyTimelineTimes();
|
|
372
|
+
let useRealtime =
|
|
373
|
+
userPinnedRealtime || followRealtimeAtLatestAnchor || noScrubObservationTimelineUseWallClock;
|
|
374
|
+
let timelineUnixOut = useRealtime ? undefined : effectiveUnix;
|
|
375
|
+
if (!useRealtime && (timelineUnixOut == null || !Number.isFinite(timelineUnixOut))) {
|
|
376
|
+
useRealtime = true;
|
|
377
|
+
timelineUnixOut = undefined;
|
|
378
|
+
}
|
|
321
379
|
return {
|
|
322
380
|
useRealtime,
|
|
323
|
-
timelineUnix:
|
|
381
|
+
timelineUnix: timelineUnixOut,
|
|
324
382
|
};
|
|
325
383
|
}
|
|
326
384
|
|
|
@@ -825,15 +883,20 @@ export class NwsWatchesWarningsOverlay {
|
|
|
825
883
|
* @param {boolean} opts.enabled
|
|
826
884
|
* @param {number | null | undefined} opts.timelineUnix - Active scrubber unix for satellite / MRMS / NEXRAD.
|
|
827
885
|
* @param {number[] | null | undefined} [opts.timelineTimes] - Full timeline for the current layer (for “live edge” = wall clock).
|
|
886
|
+
* @param {boolean} [opts.hasObservationTimeline] - When false (model / base map only), scrub hold is cleared and wall clock is used until observation data returns.
|
|
828
887
|
*/
|
|
829
|
-
syncWithMode({ enabled, timelineUnix, timelineTimes }) {
|
|
888
|
+
syncWithMode({ enabled, timelineUnix, timelineTimes, hasObservationTimeline }) {
|
|
830
889
|
if (this._destroyed) return;
|
|
831
890
|
this.options.enabled = !!enabled;
|
|
891
|
+
if (hasObservationTimeline === false) {
|
|
892
|
+
this._stickyTimelineUnix = null;
|
|
893
|
+
}
|
|
832
894
|
if (timelineTimes !== undefined) {
|
|
833
895
|
this._timelineTimes = Array.isArray(timelineTimes) ? timelineTimes : null;
|
|
834
896
|
}
|
|
835
897
|
this.setTimelineUnix(timelineUnix);
|
|
836
898
|
if (!this.options.enabled) {
|
|
899
|
+
this._stickyTimelineUnix = null;
|
|
837
900
|
this._started = false;
|
|
838
901
|
this._sseFirstOpen = true;
|
|
839
902
|
this._tearDownStream();
|
|
@@ -843,14 +906,15 @@ export class NwsWatchesWarningsOverlay {
|
|
|
843
906
|
if (!this._started) {
|
|
844
907
|
this._started = true;
|
|
845
908
|
void this._initialLoad();
|
|
846
|
-
} else {
|
|
847
|
-
this._applyTimeFilters();
|
|
848
909
|
}
|
|
910
|
+
// When already running, time filtering is applied in setTimelineUnix (avoids double work
|
|
911
|
+
// per scrub tick, matching aguacero-frontend: no full setData on slider move).
|
|
849
912
|
}
|
|
850
913
|
|
|
851
914
|
destroy() {
|
|
852
915
|
this._destroyed = true;
|
|
853
916
|
this._started = false;
|
|
917
|
+
this._stickyTimelineUnix = null;
|
|
854
918
|
this.map?.off?.('click', this._boundClick);
|
|
855
919
|
this._tearDownStream();
|
|
856
920
|
this._removeLayers();
|
|
@@ -103,6 +103,8 @@ export class WeatherLayerManager extends EventEmitter {
|
|
|
103
103
|
...(options.watchesWarnings ?? {}),
|
|
104
104
|
};
|
|
105
105
|
this._nwsOverlay = null;
|
|
106
|
+
/** Dedupes {@link NwsWatchesWarningsOverlay#updateOptions} when only the observation timeline (slider) changes. */
|
|
107
|
+
this._nwsOptsKey = null;
|
|
106
108
|
|
|
107
109
|
/**
|
|
108
110
|
* Style layer id to insert custom weather layers below when using a non-Aguacero Mapbox style
|
|
@@ -288,8 +290,9 @@ export class WeatherLayerManager extends EventEmitter {
|
|
|
288
290
|
}
|
|
289
291
|
|
|
290
292
|
/**
|
|
291
|
-
* NWS watches/warnings: same NWWS feed as aguacero-frontend
|
|
292
|
-
*
|
|
293
|
+
* NWS watches/warnings: same NWWS feed as aguacero-frontend. Shown whenever `enabled` is true (including
|
|
294
|
+
* model-only maps — time filter uses wall clock until a satellite / MRMS / NEXRAD scrub timeline exists).
|
|
295
|
+
* By default `nwsAlertSettings.alertScope` is `'all'`. Use `'user'` with
|
|
293
296
|
* `includedAlerts` (exact NWS `event_name` strings) to show only selected products.
|
|
294
297
|
*
|
|
295
298
|
* @param {object} partial
|
|
@@ -320,8 +323,9 @@ export class WeatherLayerManager extends EventEmitter {
|
|
|
320
323
|
_syncNwsOverlay(state) {
|
|
321
324
|
const wopt = this._watchesWarningsOptions ?? {};
|
|
322
325
|
const masterEnabled = wopt.enabled === true;
|
|
323
|
-
const
|
|
324
|
-
|
|
326
|
+
const hasObservationTimeline = state.isSatellite || state.isNexrad || state.isMRMS;
|
|
327
|
+
/** Watches/warnings may run on model-only maps: use wall-clock “active now” when no sat/MRMS/NEXRAD timeline. */
|
|
328
|
+
const enabled = masterEnabled;
|
|
325
329
|
|
|
326
330
|
let timelineUnix = null;
|
|
327
331
|
/** Same list the core uses for the time slider — enables NWS “live edge” (wall clock at latest step) like aguacero-frontend. */
|
|
@@ -349,22 +353,38 @@ export class WeatherLayerManager extends EventEmitter {
|
|
|
349
353
|
? wopt.fillBeforeLayerId ?? null
|
|
350
354
|
: null;
|
|
351
355
|
|
|
352
|
-
|
|
356
|
+
const nwsAlertSettingsForOpts = {
|
|
357
|
+
...(wopt.nwsAlertSettings ?? {}),
|
|
358
|
+
...(wopt.activeOnlyRealtime !== undefined ? { activeOnlyRealtime: wopt.activeOnlyRealtime } : {}),
|
|
359
|
+
};
|
|
360
|
+
const nwsOptsKey = JSON.stringify({
|
|
361
|
+
weatherLayerId,
|
|
362
|
+
fillBeforeLayerId,
|
|
363
|
+
lineBeforeLayerId: wopt.lineBeforeLayerId,
|
|
353
364
|
alertsBaseUrl: wopt.alertsBaseUrl,
|
|
354
365
|
alertInteractionEnabled: wopt.alertInteractionEnabled,
|
|
355
366
|
deltaDebounceMs: wopt.deltaDebounceMs,
|
|
356
367
|
fillOpacity: wopt.fillOpacity,
|
|
357
368
|
lineOpacity: wopt.lineOpacity,
|
|
358
369
|
lineWidth: wopt.lineWidth,
|
|
359
|
-
|
|
360
|
-
fillBeforeLayerId,
|
|
361
|
-
lineBeforeLayerId: wopt.lineBeforeLayerId,
|
|
362
|
-
nwsAlertSettings: {
|
|
363
|
-
...(wopt.nwsAlertSettings ?? {}),
|
|
364
|
-
...(wopt.activeOnlyRealtime !== undefined ? { activeOnlyRealtime: wopt.activeOnlyRealtime } : {}),
|
|
365
|
-
},
|
|
370
|
+
nwsAlertSettings: nwsAlertSettingsForOpts,
|
|
366
371
|
});
|
|
367
|
-
this.
|
|
372
|
+
if (this._nwsOptsKey !== nwsOptsKey) {
|
|
373
|
+
this._nwsOptsKey = nwsOptsKey;
|
|
374
|
+
this._nwsOverlay.updateOptions({
|
|
375
|
+
alertsBaseUrl: wopt.alertsBaseUrl,
|
|
376
|
+
alertInteractionEnabled: wopt.alertInteractionEnabled,
|
|
377
|
+
deltaDebounceMs: wopt.deltaDebounceMs,
|
|
378
|
+
fillOpacity: wopt.fillOpacity,
|
|
379
|
+
lineOpacity: wopt.lineOpacity,
|
|
380
|
+
lineWidth: wopt.lineWidth,
|
|
381
|
+
weatherLayerId,
|
|
382
|
+
fillBeforeLayerId,
|
|
383
|
+
lineBeforeLayerId: wopt.lineBeforeLayerId,
|
|
384
|
+
nwsAlertSettings: nwsAlertSettingsForOpts,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
this._nwsOverlay.syncWithMode({ enabled, timelineUnix, timelineTimes, hasObservationTimeline });
|
|
368
388
|
}
|
|
369
389
|
|
|
370
390
|
/**
|
|
@@ -748,6 +768,9 @@ export class WeatherLayerManager extends EventEmitter {
|
|
|
748
768
|
async setNexradTilt(tilt) {
|
|
749
769
|
return this.core.setNexradTilt(tilt);
|
|
750
770
|
}
|
|
771
|
+
async setNexradStormRelative(enabled) {
|
|
772
|
+
return this.core.setNexradStormRelative(enabled);
|
|
773
|
+
}
|
|
751
774
|
async setNexradTimestamp(timestamp) {
|
|
752
775
|
return this.core.setNexradTimestamp(timestamp);
|
|
753
776
|
}
|
|
@@ -19,6 +19,8 @@ export type DecodedRadarFrame = {
|
|
|
19
19
|
valueScale: number;
|
|
20
20
|
valueOffset: number;
|
|
21
21
|
rayBoundariesDeg: Float32Array;
|
|
22
|
+
/** Nyquist velocity (m/s) from Level-II archive header; used for VEL readout (aguacero-frontend parity). */
|
|
23
|
+
embeddedNyquistMs?: number | null;
|
|
22
24
|
};
|
|
23
25
|
|
|
24
26
|
// ─── Cache storage ────────────────────────────────────────────────────────────
|
|
@@ -195,6 +195,12 @@ export const NEXRAD_LEVEL3_DHC_PRODUCTS: readonly string[] = [
|
|
|
195
195
|
const NEXRAD_LEVEL3_KDP_PRODUCTS_FOR_MANIFEST: readonly string[] = NEXRAD_LEVEL3_KDP_PRODUCTS.slice(3);
|
|
196
196
|
const NEXRAD_LEVEL3_DHC_PRODUCTS_FOR_MANIFEST: readonly string[] = NEXRAD_LEVEL3_DHC_PRODUCTS.slice(3);
|
|
197
197
|
|
|
198
|
+
/** Super-res base radial velocity (154): S3 mnemonic per elevation (ICD order, low → high). */
|
|
199
|
+
export const NEXRAD_LEVEL3_VELOCITY_PRODUCTS: readonly string[] = [
|
|
200
|
+
'NXG', 'NYG', 'NZG', 'N0G', 'NAG', 'N1G', 'NBG', 'N2G', 'N3G',
|
|
201
|
+
];
|
|
202
|
+
const NEXRAD_LEVEL3_VELOCITY_PRODUCTS_FOR_MANIFEST: readonly string[] = NEXRAD_LEVEL3_VELOCITY_PRODUCTS.slice(3);
|
|
203
|
+
|
|
198
204
|
/**
|
|
199
205
|
* Level-III KDP / digital HC (N0H): one S3 mnemonic per manifest slot in
|
|
200
206
|
* {@link NEXRAD_LEVEL3_KDP_PRODUCTS_FOR_MANIFEST} / {@link NEXRAD_LEVEL3_DHC_PRODUCTS_FOR_MANIFEST}.
|
|
@@ -207,6 +213,32 @@ export function getNexradLevel3RadarTilts(siteId: string, radarVariable: string)
|
|
|
207
213
|
return getRadarTilts(siteId, radarVariable).slice(0, NEXRAD_LEVEL3_MANIFEST_TILT_COUNT);
|
|
208
214
|
}
|
|
209
215
|
|
|
216
|
+
/** Tilts for super-res L3 velocity (N*G), same manifest cap as KDP/N0H. */
|
|
217
|
+
export function getNexradLevel3RadarTiltsForVelocity(siteId: string): number[] {
|
|
218
|
+
return getRadarTilts(siteId, 'VEL').slice(0, NEXRAD_LEVEL3_MANIFEST_TILT_COUNT);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Map site tilt to L3 super-res base velocity mnemonic (N0G, NAG, …) for Unidata S3 keys / stable GPU layout — aguacero-frontend parity.
|
|
223
|
+
*/
|
|
224
|
+
export function nexradLevel3S3VelocityProductForSiteTilt(siteId: string, tilt: number): string {
|
|
225
|
+
const products = NEXRAD_LEVEL3_VELOCITY_PRODUCTS_FOR_MANIFEST;
|
|
226
|
+
const tilts = getNexradLevel3RadarTiltsForVelocity(siteId);
|
|
227
|
+
if (!tilts.length) {
|
|
228
|
+
return products[0]!;
|
|
229
|
+
}
|
|
230
|
+
const clamped = clampNexradTiltForVariable(siteId, 'VEL', tilt);
|
|
231
|
+
let idx = tilts.indexOf(clamped);
|
|
232
|
+
if (idx === -1) {
|
|
233
|
+
idx = tilts.reduce(
|
|
234
|
+
(bestI, t, i) => (Math.abs(t - clamped) < Math.abs(tilts[bestI]! - clamped) ? i : bestI),
|
|
235
|
+
0,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
idx = Math.min(idx, products.length - 1);
|
|
239
|
+
return products[idx]!;
|
|
240
|
+
}
|
|
241
|
+
|
|
210
242
|
export function nexradLevel3IsTiltIndexedKdpProduct(product: string): boolean {
|
|
211
243
|
return NEXRAD_LEVEL3_KDP_PRODUCTS.includes(product);
|
|
212
244
|
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
// src/nexrad/nexradMapboxFrameOpts.ts
|
|
2
|
+
import { clampNexradTiltForVariable as clampNexradTiltForVariable2, getDefaultRadarTilt as getDefaultRadarTilt2, getRadarTilts as getRadarTilts2 } from "@aguacerowx/javascript-sdk";
|
|
3
|
+
|
|
4
|
+
// src/nexrad/nexradLevel3Products.ts
|
|
5
|
+
import {
|
|
6
|
+
clampNexradTiltForVariable,
|
|
7
|
+
formatTiltForApi,
|
|
8
|
+
getDefaultRadarTilt,
|
|
9
|
+
getRadarTilts,
|
|
10
|
+
isTerminalRadar
|
|
11
|
+
} from "@aguacerowx/javascript-sdk";
|
|
12
|
+
var NEXRAD_LEVEL3_MENU = [
|
|
13
|
+
{
|
|
14
|
+
radarKey: "VEL",
|
|
15
|
+
product: "N0G",
|
|
16
|
+
menuLabel: "Storm Relative Velocity",
|
|
17
|
+
fldKey: "nexrad_vel",
|
|
18
|
+
cmapPropertyKey: "nexrad_vel",
|
|
19
|
+
decodeMode: "velocity",
|
|
20
|
+
stormRelativeVelocity: true
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
radarKey: "KDP",
|
|
24
|
+
product: "N0K",
|
|
25
|
+
menuLabel: "Specific Differential Phase",
|
|
26
|
+
fldKey: "nexrad_l3_n0k",
|
|
27
|
+
cmapPropertyKey: "nexrad_kdp",
|
|
28
|
+
defaultUnit: "deg/km",
|
|
29
|
+
decodeMode: "generic_physical"
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
radarKey: "N0H",
|
|
33
|
+
product: "N0H",
|
|
34
|
+
menuLabel: "Hydrometeor Classification",
|
|
35
|
+
fldKey: "nexrad_l3_n0h",
|
|
36
|
+
cmapPropertyKey: "nexrad_l3_n0h",
|
|
37
|
+
defaultUnit: "None",
|
|
38
|
+
decodeMode: "categorical"
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
radarKey: "HHC",
|
|
42
|
+
product: "HHC",
|
|
43
|
+
menuLabel: "Hybrid Hydrometeor Classification",
|
|
44
|
+
fldKey: "nexrad_l3_hhc",
|
|
45
|
+
cmapPropertyKey: "nexrad_l3_hhc",
|
|
46
|
+
defaultUnit: "None",
|
|
47
|
+
decodeMode: "categorical"
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
radarKey: "EET",
|
|
51
|
+
product: "EET",
|
|
52
|
+
menuLabel: "Enhanced Echo Tops",
|
|
53
|
+
fldKey: "nexrad_l3_eet",
|
|
54
|
+
cmapPropertyKey: "nexrad_l3_eet",
|
|
55
|
+
defaultUnit: "kft",
|
|
56
|
+
decodeMode: "tops_kft"
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
radarKey: "DVL",
|
|
60
|
+
product: "DVL",
|
|
61
|
+
menuLabel: "Vertically Integrated Liquid",
|
|
62
|
+
fldKey: "nexrad_l3_dvl",
|
|
63
|
+
cmapPropertyKey: "nexrad_l3_dvl",
|
|
64
|
+
defaultUnit: "kg/m\xB2",
|
|
65
|
+
decodeMode: "vil"
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
radarKey: "DAA",
|
|
69
|
+
product: "DAA",
|
|
70
|
+
menuLabel: "1-Hour Precipitation",
|
|
71
|
+
fldKey: "nexrad_l3_daa",
|
|
72
|
+
cmapPropertyKey: "tp_0_1",
|
|
73
|
+
defaultUnit: "in",
|
|
74
|
+
decodeMode: "precip"
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
radarKey: "DU3",
|
|
78
|
+
product: "DU3",
|
|
79
|
+
menuLabel: "3-Hour Precipitation",
|
|
80
|
+
fldKey: "nexrad_l3_du3",
|
|
81
|
+
cmapPropertyKey: "tp_0_total",
|
|
82
|
+
defaultUnit: "in",
|
|
83
|
+
decodeMode: "precip"
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
radarKey: "DTA",
|
|
87
|
+
product: "DTA",
|
|
88
|
+
menuLabel: "Storm Total Precipitation",
|
|
89
|
+
fldKey: "nexrad_l3_dta",
|
|
90
|
+
cmapPropertyKey: "tp_0_total",
|
|
91
|
+
defaultUnit: "in",
|
|
92
|
+
decodeMode: "precip"
|
|
93
|
+
}
|
|
94
|
+
];
|
|
95
|
+
var byRadarKey = /* @__PURE__ */ new Map();
|
|
96
|
+
for (const e of NEXRAD_LEVEL3_MENU) {
|
|
97
|
+
byRadarKey.set(e.radarKey, e);
|
|
98
|
+
if (e.radarKey === "DVL") byRadarKey.set("NVL", e);
|
|
99
|
+
}
|
|
100
|
+
var NEXRAD_LEVEL3_KDP_PRODUCTS = [
|
|
101
|
+
"NXK",
|
|
102
|
+
"NYK",
|
|
103
|
+
"NZK",
|
|
104
|
+
"N0K",
|
|
105
|
+
"NAK",
|
|
106
|
+
"N1K",
|
|
107
|
+
"NBK",
|
|
108
|
+
"N2K",
|
|
109
|
+
"N3K"
|
|
110
|
+
];
|
|
111
|
+
var NEXRAD_LEVEL3_DHC_PRODUCTS = [
|
|
112
|
+
"NXH",
|
|
113
|
+
"NYH",
|
|
114
|
+
"NZH",
|
|
115
|
+
"N0H",
|
|
116
|
+
"NAH",
|
|
117
|
+
"N1H",
|
|
118
|
+
"NBH",
|
|
119
|
+
"N2H",
|
|
120
|
+
"N3H"
|
|
121
|
+
];
|
|
122
|
+
var NEXRAD_LEVEL3_KDP_PRODUCTS_FOR_MANIFEST = NEXRAD_LEVEL3_KDP_PRODUCTS.slice(3);
|
|
123
|
+
var NEXRAD_LEVEL3_DHC_PRODUCTS_FOR_MANIFEST = NEXRAD_LEVEL3_DHC_PRODUCTS.slice(3);
|
|
124
|
+
var NEXRAD_LEVEL3_VELOCITY_PRODUCTS = [
|
|
125
|
+
"NXG",
|
|
126
|
+
"NYG",
|
|
127
|
+
"NZG",
|
|
128
|
+
"N0G",
|
|
129
|
+
"NAG",
|
|
130
|
+
"N1G",
|
|
131
|
+
"NBG",
|
|
132
|
+
"N2G",
|
|
133
|
+
"N3G"
|
|
134
|
+
];
|
|
135
|
+
var NEXRAD_LEVEL3_VELOCITY_PRODUCTS_FOR_MANIFEST = NEXRAD_LEVEL3_VELOCITY_PRODUCTS.slice(3);
|
|
136
|
+
var NEXRAD_LEVEL3_MANIFEST_TILT_COUNT = NEXRAD_LEVEL3_KDP_PRODUCTS_FOR_MANIFEST.length;
|
|
137
|
+
function getNexradLevel3RadarTiltsForVelocity(siteId) {
|
|
138
|
+
return getRadarTilts(siteId, "VEL").slice(0, NEXRAD_LEVEL3_MANIFEST_TILT_COUNT);
|
|
139
|
+
}
|
|
140
|
+
function nexradLevel3S3VelocityProductForSiteTilt(siteId, tilt) {
|
|
141
|
+
const products = NEXRAD_LEVEL3_VELOCITY_PRODUCTS_FOR_MANIFEST;
|
|
142
|
+
const tilts = getNexradLevel3RadarTiltsForVelocity(siteId);
|
|
143
|
+
if (!tilts.length) {
|
|
144
|
+
return products[0];
|
|
145
|
+
}
|
|
146
|
+
const clamped = clampNexradTiltForVariable(siteId, "VEL", tilt);
|
|
147
|
+
let idx = tilts.indexOf(clamped);
|
|
148
|
+
if (idx === -1) {
|
|
149
|
+
idx = tilts.reduce(
|
|
150
|
+
(bestI, t, i) => Math.abs(t - clamped) < Math.abs(tilts[bestI] - clamped) ? i : bestI,
|
|
151
|
+
0
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
idx = Math.min(idx, products.length - 1);
|
|
155
|
+
return products[idx];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// src/nexrad/nexradMapboxFrameOpts.ts
|
|
159
|
+
var NEXRAD_LEVEL3_KDP_PRODUCTS_FOR_MANIFEST2 = [
|
|
160
|
+
"N0K",
|
|
161
|
+
"NAK",
|
|
162
|
+
"N1K",
|
|
163
|
+
"NBK",
|
|
164
|
+
"N2K",
|
|
165
|
+
"N3K"
|
|
166
|
+
];
|
|
167
|
+
var NEXRAD_LEVEL3_DHC_PRODUCTS_FOR_MANIFEST2 = [
|
|
168
|
+
"N0H",
|
|
169
|
+
"NAH",
|
|
170
|
+
"N1H",
|
|
171
|
+
"NBH",
|
|
172
|
+
"N2H",
|
|
173
|
+
"N3H"
|
|
174
|
+
];
|
|
175
|
+
var NEXRAD_LEVEL3_MANIFEST_TILT_COUNT2 = NEXRAD_LEVEL3_KDP_PRODUCTS_FOR_MANIFEST2.length;
|
|
176
|
+
function nearestTiltInSortedList(tilts, target) {
|
|
177
|
+
if (!tilts.length) return target;
|
|
178
|
+
let best = tilts[0];
|
|
179
|
+
let bestD = Math.abs(best - target);
|
|
180
|
+
for (let i = 1; i < tilts.length; i++) {
|
|
181
|
+
const t = tilts[i];
|
|
182
|
+
const d = Math.abs(t - target);
|
|
183
|
+
if (d < bestD || d === bestD && t < best) {
|
|
184
|
+
best = t;
|
|
185
|
+
bestD = d;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return best;
|
|
189
|
+
}
|
|
190
|
+
function getNexradLevel3RadarTilts(siteId, radarVariable) {
|
|
191
|
+
return getRadarTilts2(siteId, radarVariable).slice(0, NEXRAD_LEVEL3_MANIFEST_TILT_COUNT2);
|
|
192
|
+
}
|
|
193
|
+
function nexradLevel3UsesTiltIndexedS3Products(radarVariable) {
|
|
194
|
+
const v = radarVariable || "";
|
|
195
|
+
return v === "KDP" || v === "N0H";
|
|
196
|
+
}
|
|
197
|
+
function clampTiltForLevel3CompositeKey(siteId, radarVariable, tilt) {
|
|
198
|
+
if (!nexradLevel3UsesTiltIndexedS3Products(radarVariable)) {
|
|
199
|
+
return clampNexradTiltForVariable2(siteId, radarVariable, tilt);
|
|
200
|
+
}
|
|
201
|
+
const tilts = getNexradLevel3RadarTilts(siteId, radarVariable);
|
|
202
|
+
const c = clampNexradTiltForVariable2(siteId, radarVariable, tilt);
|
|
203
|
+
if (!tilts.length) return c;
|
|
204
|
+
return nearestTiltInSortedList(tilts, c);
|
|
205
|
+
}
|
|
206
|
+
function nexradLevel3S3ProductForSiteTilt(siteId, radarVariable, tilt) {
|
|
207
|
+
const products = radarVariable === "KDP" ? NEXRAD_LEVEL3_KDP_PRODUCTS_FOR_MANIFEST2 : NEXRAD_LEVEL3_DHC_PRODUCTS_FOR_MANIFEST2;
|
|
208
|
+
const tilts = getNexradLevel3RadarTilts(siteId, radarVariable);
|
|
209
|
+
if (!tilts.length) {
|
|
210
|
+
return products[0];
|
|
211
|
+
}
|
|
212
|
+
const clamped = clampTiltForLevel3CompositeKey(siteId, radarVariable, tilt);
|
|
213
|
+
let idx = tilts.indexOf(clamped);
|
|
214
|
+
if (idx === -1) {
|
|
215
|
+
idx = tilts.reduce(
|
|
216
|
+
(bestI, t, i) => Math.abs(t - clamped) < Math.abs(tilts[bestI] - clamped) ? i : bestI,
|
|
217
|
+
0
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
idx = Math.min(idx, products.length - 1);
|
|
221
|
+
return products[idx];
|
|
222
|
+
}
|
|
223
|
+
function mapboxFrameUploadOptionsForNexradState(state) {
|
|
224
|
+
if (!state || state.nexradDataSource !== "level3") return void 0;
|
|
225
|
+
const site = state.nexradSite;
|
|
226
|
+
const radarVar = (state.nexradProduct || "REF").toUpperCase();
|
|
227
|
+
if (nexradLevel3UsesTiltIndexedS3Products(radarVar) && site) {
|
|
228
|
+
const tilt = Number.isFinite(state.nexradTilt) ? Number(state.nexradTilt) : getDefaultRadarTilt2(site);
|
|
229
|
+
const kind = radarVar === "KDP" ? "KDP" : "N0H";
|
|
230
|
+
const mnemonic = nexradLevel3S3ProductForSiteTilt(site, kind, tilt);
|
|
231
|
+
return { geometryLayoutKey: `${site}|${radarVar}|${mnemonic}` };
|
|
232
|
+
}
|
|
233
|
+
if (radarVar === "VEL" && site) {
|
|
234
|
+
const tilt = Number.isFinite(state.nexradTilt) ? Number(state.nexradTilt) : getDefaultRadarTilt2(site);
|
|
235
|
+
const mnemonic = nexradLevel3S3VelocityProductForSiteTilt(site, tilt);
|
|
236
|
+
return { geometryLayoutKey: `${site}|${radarVar}|${mnemonic}` };
|
|
237
|
+
}
|
|
238
|
+
return { geometryCacheKeysRayBoundaries: true };
|
|
239
|
+
}
|
|
240
|
+
export {
|
|
241
|
+
mapboxFrameUploadOptionsForNexradState,
|
|
242
|
+
nexradLevel3S3ProductForSiteTilt,
|
|
243
|
+
nexradLevel3UsesTiltIndexedS3Products
|
|
244
|
+
};
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MapboxRadarLayer upload options for NEXRAD — matches aguacero-frontend `RadarLayer` / MapboxRadarLayer docs:
|
|
3
|
-
* Level-III KDP/N0H
|
|
4
|
-
* Other Level-III radials
|
|
3
|
+
* Level-III KDP/N0H/VEL use {@link geometryLayoutKey} so frames use canonical azimuth bins (stable mesh; fast scrub).
|
|
4
|
+
* Other Level-III radials use ray-boundary-keyed geometry when layout cannot be fixed to a manifest product.
|
|
5
5
|
*/
|
|
6
6
|
import { clampNexradTiltForVariable, getDefaultRadarTilt, getRadarTilts } from '@aguacerowx/javascript-sdk';
|
|
7
|
+
import { nexradLevel3S3VelocityProductForSiteTilt } from './nexradLevel3Products.js';
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
export type MapboxRadarFrameUploadOptions = {
|
|
10
|
+
geometryLayoutKey?: string;
|
|
11
|
+
geometryCacheKeysRayBoundaries?: boolean;
|
|
12
|
+
};
|
|
9
13
|
|
|
10
14
|
const NEXRAD_LEVEL3_KDP_PRODUCTS_FOR_MANIFEST = [
|
|
11
15
|
'N0K',
|
|
@@ -27,12 +31,12 @@ const NEXRAD_LEVEL3_DHC_PRODUCTS_FOR_MANIFEST = [
|
|
|
27
31
|
|
|
28
32
|
const NEXRAD_LEVEL3_MANIFEST_TILT_COUNT = NEXRAD_LEVEL3_KDP_PRODUCTS_FOR_MANIFEST.length;
|
|
29
33
|
|
|
30
|
-
function nearestTiltInSortedList(tilts, target) {
|
|
34
|
+
function nearestTiltInSortedList(tilts: number[], target: number): number {
|
|
31
35
|
if (!tilts.length) return target;
|
|
32
|
-
let best = tilts[0]
|
|
36
|
+
let best = tilts[0]!;
|
|
33
37
|
let bestD = Math.abs(best - target);
|
|
34
38
|
for (let i = 1; i < tilts.length; i++) {
|
|
35
|
-
const t = tilts[i]
|
|
39
|
+
const t = tilts[i]!;
|
|
36
40
|
const d = Math.abs(t - target);
|
|
37
41
|
if (d < bestD || (d === bestD && t < best)) {
|
|
38
42
|
best = t;
|
|
@@ -42,16 +46,16 @@ function nearestTiltInSortedList(tilts, target) {
|
|
|
42
46
|
return best;
|
|
43
47
|
}
|
|
44
48
|
|
|
45
|
-
function getNexradLevel3RadarTilts(siteId, radarVariable) {
|
|
49
|
+
function getNexradLevel3RadarTilts(siteId: string, radarVariable: string): number[] {
|
|
46
50
|
return getRadarTilts(siteId, radarVariable).slice(0, NEXRAD_LEVEL3_MANIFEST_TILT_COUNT);
|
|
47
51
|
}
|
|
48
52
|
|
|
49
|
-
export function nexradLevel3UsesTiltIndexedS3Products(radarVariable) {
|
|
53
|
+
export function nexradLevel3UsesTiltIndexedS3Products(radarVariable: string | undefined): boolean {
|
|
50
54
|
const v = radarVariable || '';
|
|
51
55
|
return v === 'KDP' || v === 'N0H';
|
|
52
56
|
}
|
|
53
57
|
|
|
54
|
-
function clampTiltForLevel3CompositeKey(siteId, radarVariable, tilt) {
|
|
58
|
+
function clampTiltForLevel3CompositeKey(siteId: string, radarVariable: string, tilt: number): number {
|
|
55
59
|
if (!nexradLevel3UsesTiltIndexedS3Products(radarVariable)) {
|
|
56
60
|
return clampNexradTiltForVariable(siteId, radarVariable, tilt);
|
|
57
61
|
}
|
|
@@ -63,34 +67,43 @@ function clampTiltForLevel3CompositeKey(siteId, radarVariable, tilt) {
|
|
|
63
67
|
|
|
64
68
|
/**
|
|
65
69
|
* Map site tilt to Level-III S3 product mnemonic (N0K, NAK, … / N0H, …) — aguacero-frontend parity.
|
|
66
|
-
* @param {string} siteId
|
|
67
|
-
* @param {'KDP' | 'N0H'} radarVariable
|
|
68
|
-
* @param {number} tilt
|
|
69
70
|
*/
|
|
70
|
-
export function nexradLevel3S3ProductForSiteTilt(
|
|
71
|
+
export function nexradLevel3S3ProductForSiteTilt(
|
|
72
|
+
siteId: string,
|
|
73
|
+
radarVariable: 'KDP' | 'N0H',
|
|
74
|
+
tilt: number,
|
|
75
|
+
): string {
|
|
71
76
|
const products =
|
|
72
77
|
radarVariable === 'KDP' ? NEXRAD_LEVEL3_KDP_PRODUCTS_FOR_MANIFEST : NEXRAD_LEVEL3_DHC_PRODUCTS_FOR_MANIFEST;
|
|
73
78
|
const tilts = getNexradLevel3RadarTilts(siteId, radarVariable);
|
|
74
79
|
if (!tilts.length) {
|
|
75
|
-
return products[0]
|
|
80
|
+
return products[0]!;
|
|
76
81
|
}
|
|
77
82
|
const clamped = clampTiltForLevel3CompositeKey(siteId, radarVariable, tilt);
|
|
78
83
|
let idx = tilts.indexOf(clamped);
|
|
79
84
|
if (idx === -1) {
|
|
80
85
|
idx = tilts.reduce(
|
|
81
|
-
(bestI, t, i) => (Math.abs(t - clamped) < Math.abs(tilts[bestI] - clamped) ? i : bestI),
|
|
86
|
+
(bestI, t, i) => (Math.abs(t - clamped) < Math.abs(tilts[bestI]! - clamped) ? i : bestI),
|
|
82
87
|
0,
|
|
83
88
|
);
|
|
84
89
|
}
|
|
85
90
|
idx = Math.min(idx, products.length - 1);
|
|
86
|
-
return products[idx]
|
|
91
|
+
return products[idx]!;
|
|
87
92
|
}
|
|
88
93
|
|
|
94
|
+
type NexradStateForMapbox = {
|
|
95
|
+
nexradDataSource?: string;
|
|
96
|
+
nexradSite?: string | null;
|
|
97
|
+
nexradProduct?: string | null;
|
|
98
|
+
nexradTilt?: number | null;
|
|
99
|
+
};
|
|
100
|
+
|
|
89
101
|
/**
|
|
90
|
-
* @param
|
|
91
|
-
* @returns {MapboxRadarFrameUploadOptions | undefined}
|
|
102
|
+
* @param state - AguaceroCore state (or state:change payload) with NEXRAD fields
|
|
92
103
|
*/
|
|
93
|
-
export function mapboxFrameUploadOptionsForNexradState(
|
|
104
|
+
export function mapboxFrameUploadOptionsForNexradState(
|
|
105
|
+
state: NexradStateForMapbox | null | undefined,
|
|
106
|
+
): MapboxRadarFrameUploadOptions | undefined {
|
|
94
107
|
if (!state || state.nexradDataSource !== 'level3') return undefined;
|
|
95
108
|
const site = state.nexradSite;
|
|
96
109
|
const radarVar = (state.nexradProduct || 'REF').toUpperCase();
|
|
@@ -102,5 +115,11 @@ export function mapboxFrameUploadOptionsForNexradState(state) {
|
|
|
102
115
|
return { geometryLayoutKey: `${site}|${radarVar}|${mnemonic}` };
|
|
103
116
|
}
|
|
104
117
|
|
|
118
|
+
if (radarVar === 'VEL' && site) {
|
|
119
|
+
const tilt = Number.isFinite(state.nexradTilt) ? Number(state.nexradTilt) : getDefaultRadarTilt(site);
|
|
120
|
+
const mnemonic = nexradLevel3S3VelocityProductForSiteTilt(site, tilt);
|
|
121
|
+
return { geometryLayoutKey: `${site}|${radarVar}|${mnemonic}` };
|
|
122
|
+
}
|
|
123
|
+
|
|
105
124
|
return { geometryCacheKeysRayBoundaries: true };
|
|
106
125
|
}
|
|
@@ -2779,6 +2779,18 @@ var NEXRAD_LEVEL3_DHC_PRODUCTS = [
|
|
|
2779
2779
|
];
|
|
2780
2780
|
var NEXRAD_LEVEL3_KDP_PRODUCTS_FOR_MANIFEST = NEXRAD_LEVEL3_KDP_PRODUCTS.slice(3);
|
|
2781
2781
|
var NEXRAD_LEVEL3_DHC_PRODUCTS_FOR_MANIFEST = NEXRAD_LEVEL3_DHC_PRODUCTS.slice(3);
|
|
2782
|
+
var NEXRAD_LEVEL3_VELOCITY_PRODUCTS = [
|
|
2783
|
+
"NXG",
|
|
2784
|
+
"NYG",
|
|
2785
|
+
"NZG",
|
|
2786
|
+
"N0G",
|
|
2787
|
+
"NAG",
|
|
2788
|
+
"N1G",
|
|
2789
|
+
"NBG",
|
|
2790
|
+
"N2G",
|
|
2791
|
+
"N3G"
|
|
2792
|
+
];
|
|
2793
|
+
var NEXRAD_LEVEL3_VELOCITY_PRODUCTS_FOR_MANIFEST = NEXRAD_LEVEL3_VELOCITY_PRODUCTS.slice(3);
|
|
2782
2794
|
var NEXRAD_LEVEL3_MANIFEST_TILT_COUNT = NEXRAD_LEVEL3_KDP_PRODUCTS_FOR_MANIFEST.length;
|
|
2783
2795
|
function level3EetHeightKmFromLevel(level, dataMaskRaw, scaleRaw, offsetRaw) {
|
|
2784
2796
|
if (level < 2) return null;
|
|
@@ -3932,14 +3944,17 @@ function decodeLevel3RasterProduct(buffer, objectKey, radarVariable) {
|
|
|
3932
3944
|
return null;
|
|
3933
3945
|
}
|
|
3934
3946
|
}
|
|
3935
|
-
var GROUP2_VARS = ["
|
|
3947
|
+
var GROUP2_VARS = ["SW"];
|
|
3948
|
+
var GROUP2_COMBINED_3_VARS = ["REF", "VEL", "SW"];
|
|
3936
3949
|
var GROUP1_VARS = ["REF", "ZDR", "RHO", "PHI"];
|
|
3937
3950
|
var GROUP2_COMBINED_7_VARS = ["REF", "ZDR", "RHO", "PHI", "KDP", "VEL", "SW"];
|
|
3938
3951
|
var GROUP2_COMBINED_VARS = ["REF", "ZDR", "RHO", "PHI", "VEL", "SW"];
|
|
3939
3952
|
var FILE_HDR_BYTES = 64;
|
|
3940
|
-
var
|
|
3953
|
+
var LEVEL2_AZ_BLOCK_RAYS = 720;
|
|
3954
|
+
var LEVEL2_AZ_BLOCK_BYTES = LEVEL2_AZ_BLOCK_RAYS * 4;
|
|
3955
|
+
var LEVEL2_FILE_NYQUIST_BYTES = 4;
|
|
3941
3956
|
var SLOT_INDEX_ENTRY = 18;
|
|
3942
|
-
var MAX_SLOTS =
|
|
3957
|
+
var MAX_SLOTS = 7;
|
|
3943
3958
|
function int16ToFloat16(val) {
|
|
3944
3959
|
const sign = (val & 32768) / 32768;
|
|
3945
3960
|
const exponent = (val & 31744) / 1024;
|
|
@@ -3949,7 +3964,25 @@ function int16ToFloat16(val) {
|
|
|
3949
3964
|
}
|
|
3950
3965
|
return Math.pow(-1, sign) * Math.pow(2, exponent - 16) * (1 + fraction / Math.pow(2, 10));
|
|
3951
3966
|
}
|
|
3952
|
-
function
|
|
3967
|
+
function nexradLevel2ResourceTotalBytes(resp) {
|
|
3968
|
+
const cr = resp.headers.get("Content-Range");
|
|
3969
|
+
if (cr) {
|
|
3970
|
+
const m = cr.trim().match(/\/(\d+)\s*$/);
|
|
3971
|
+
if (m) {
|
|
3972
|
+
const n = Number(m[1]);
|
|
3973
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
3974
|
+
}
|
|
3975
|
+
}
|
|
3976
|
+
if (resp.status === 200) {
|
|
3977
|
+
const cl = resp.headers.get("Content-Length");
|
|
3978
|
+
if (cl) {
|
|
3979
|
+
const n = Number(cl);
|
|
3980
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
3981
|
+
}
|
|
3982
|
+
}
|
|
3983
|
+
return null;
|
|
3984
|
+
}
|
|
3985
|
+
function parseFileHeader(buffer, azBlockBytes) {
|
|
3953
3986
|
const view = new DataView(buffer);
|
|
3954
3987
|
const magic = view.getUint32(0, false);
|
|
3955
3988
|
if (magic !== 1314406980) throw new Error(`Bad magic: 0x${magic.toString(16)}`);
|
|
@@ -3960,8 +3993,15 @@ function parseFileHeader(buffer) {
|
|
|
3960
3993
|
const firstGateKm = view.getFloat32(16, false);
|
|
3961
3994
|
const gateWidthKm = view.getFloat32(20, false);
|
|
3962
3995
|
const nSlots = view.getUint16(24, false);
|
|
3963
|
-
const azimuthsBuffer = buffer.slice(FILE_HDR_BYTES, FILE_HDR_BYTES +
|
|
3964
|
-
const
|
|
3996
|
+
const azimuthsBuffer = buffer.slice(FILE_HDR_BYTES, FILE_HDR_BYTES + azBlockBytes);
|
|
3997
|
+
const azEnd = FILE_HDR_BYTES + azBlockBytes;
|
|
3998
|
+
const idxStart = azEnd + LEVEL2_FILE_NYQUIST_BYTES;
|
|
3999
|
+
const needBytes = idxStart + nSlots * SLOT_INDEX_ENTRY;
|
|
4000
|
+
if (buffer.byteLength < needBytes) {
|
|
4001
|
+
throw new Error(`level2 header buffer too small: ${buffer.byteLength} < ${needBytes}`);
|
|
4002
|
+
}
|
|
4003
|
+
const nyqCandidate = view.getFloat32(azEnd, false);
|
|
4004
|
+
const embeddedNyquistMs = Number.isFinite(nyqCandidate) && nyqCandidate > 0.5 && nyqCandidate < 128 ? nyqCandidate : null;
|
|
3965
4005
|
const slots = [];
|
|
3966
4006
|
for (let i = 0; i < nSlots; i++) {
|
|
3967
4007
|
const base = idxStart + i * SLOT_INDEX_ENTRY;
|
|
@@ -3981,7 +4021,8 @@ function parseFileHeader(buffer) {
|
|
|
3981
4021
|
gateWidthKm,
|
|
3982
4022
|
nSlots,
|
|
3983
4023
|
azimuthsBuffer,
|
|
3984
|
-
slots
|
|
4024
|
+
slots,
|
|
4025
|
+
embeddedNyquistMs
|
|
3985
4026
|
};
|
|
3986
4027
|
}
|
|
3987
4028
|
function decodeSweepInWorker(objectKey, slotBuffer, header, sites, priority, signal) {
|
|
@@ -4030,7 +4071,24 @@ function decodeSweepInWorker(objectKey, slotBuffer, header, sites, priority, sig
|
|
|
4030
4071
|
}, [slotBuffer, azCopy]);
|
|
4031
4072
|
});
|
|
4032
4073
|
}
|
|
4074
|
+
var N0S_MOTION_CACHE_MAX = 128;
|
|
4075
|
+
var n0sMotionVectorCache = /* @__PURE__ */ new Map();
|
|
4076
|
+
function getCachedN0sMotion(motionObjectKey) {
|
|
4077
|
+
return n0sMotionVectorCache.get(motionObjectKey);
|
|
4078
|
+
}
|
|
4079
|
+
function setCachedN0sMotion(motionObjectKey, motion) {
|
|
4080
|
+
n0sMotionVectorCache.set(motionObjectKey, motion);
|
|
4081
|
+
while (n0sMotionVectorCache.size > N0S_MOTION_CACHE_MAX) {
|
|
4082
|
+
const first = n0sMotionVectorCache.keys().next().value;
|
|
4083
|
+
if (first === void 0) break;
|
|
4084
|
+
n0sMotionVectorCache.delete(first);
|
|
4085
|
+
}
|
|
4086
|
+
}
|
|
4033
4087
|
async function applyStormMotionFromN0sObjectKey(frame, motionObjectKey, logLabel) {
|
|
4088
|
+
const cached = getCachedN0sMotion(motionObjectKey);
|
|
4089
|
+
if (cached) {
|
|
4090
|
+
return applyLevel3StormRelativeToFrame(frame, cached.speedMs, cached.directionDeg);
|
|
4091
|
+
}
|
|
4034
4092
|
const motionUrl = objectKeyToUrl(motionObjectKey, "level3");
|
|
4035
4093
|
try {
|
|
4036
4094
|
const motionResp = await fetch(motionUrl);
|
|
@@ -4038,6 +4096,7 @@ async function applyStormMotionFromN0sObjectKey(frame, motionObjectKey, logLabel
|
|
|
4038
4096
|
const motionBuf = await motionResp.arrayBuffer();
|
|
4039
4097
|
const motion = parseLevel3StormMotionFromBuffer(motionBuf);
|
|
4040
4098
|
if (motion) {
|
|
4099
|
+
setCachedN0sMotion(motionObjectKey, motion);
|
|
4041
4100
|
return applyLevel3StormRelativeToFrame(frame, motion.speedMs, motion.directionDeg);
|
|
4042
4101
|
}
|
|
4043
4102
|
}
|
|
@@ -4104,7 +4163,8 @@ async function fetchAndParseArchive(url, objectKey, radarVariable, groupId, rada
|
|
|
4104
4163
|
}
|
|
4105
4164
|
return decoded2;
|
|
4106
4165
|
}
|
|
4107
|
-
const
|
|
4166
|
+
const azBlockBytes = LEVEL2_AZ_BLOCK_BYTES;
|
|
4167
|
+
const INDEX_FETCH_BYTES = FILE_HDR_BYTES + azBlockBytes + LEVEL2_FILE_NYQUIST_BYTES + MAX_SLOTS * SLOT_INDEX_ENTRY;
|
|
4108
4168
|
const indexResp = await fetch(url, {
|
|
4109
4169
|
headers: {
|
|
4110
4170
|
"x-api-key": NEXRAD_ARCHIVE_API_KEY,
|
|
@@ -4115,12 +4175,26 @@ async function fetchAndParseArchive(url, objectKey, radarVariable, groupId, rada
|
|
|
4115
4175
|
throw new Error(`HTTP ${indexResp.status} fetching level2 index ${url}`);
|
|
4116
4176
|
}
|
|
4117
4177
|
const indexBuffer = await indexResp.arrayBuffer();
|
|
4118
|
-
const
|
|
4178
|
+
const indexResourceTotal = nexradLevel2ResourceTotalBytes(indexResp);
|
|
4179
|
+
const header = parseFileHeader(indexBuffer, azBlockBytes);
|
|
4180
|
+
const slotIndexBytes = FILE_HDR_BYTES + azBlockBytes + LEVEL2_FILE_NYQUIST_BYTES + header.nSlots * SLOT_INDEX_ENTRY;
|
|
4181
|
+
if (indexBuffer.byteLength < slotIndexBytes) {
|
|
4182
|
+
console.warn("[RadarLayer] level2 index response shorter than slot table", {
|
|
4183
|
+
objectKey,
|
|
4184
|
+
byteLength: indexBuffer.byteLength,
|
|
4185
|
+
need: slotIndexBytes,
|
|
4186
|
+
nSlots: header.nSlots
|
|
4187
|
+
});
|
|
4188
|
+
setArchiveCache(cacheKey, null);
|
|
4189
|
+
return null;
|
|
4190
|
+
}
|
|
4119
4191
|
let varList;
|
|
4120
4192
|
if (groupId === 2 && header.nSlots === 7) {
|
|
4121
4193
|
varList = GROUP2_COMBINED_7_VARS;
|
|
4122
4194
|
} else if (groupId === 2 && header.nSlots === 6) {
|
|
4123
4195
|
varList = GROUP2_COMBINED_VARS;
|
|
4196
|
+
} else if (groupId === 2 && header.nSlots === 3) {
|
|
4197
|
+
varList = GROUP2_COMBINED_3_VARS;
|
|
4124
4198
|
} else if (groupId === 2) {
|
|
4125
4199
|
varList = GROUP2_VARS;
|
|
4126
4200
|
} else {
|
|
@@ -4138,17 +4212,43 @@ async function fetchAndParseArchive(url, objectKey, radarVariable, groupId, rada
|
|
|
4138
4212
|
setArchiveCache(cacheKey, null);
|
|
4139
4213
|
return null;
|
|
4140
4214
|
}
|
|
4141
|
-
const
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
|
|
4215
|
+
const slotEndExclusive = slot.offset + slot.compressedSize;
|
|
4216
|
+
if (indexResourceTotal != null && slotEndExclusive > indexResourceTotal) {
|
|
4217
|
+
console.warn("[RadarLayer] level2 slot extends past object size (bad index or stale CDN)", {
|
|
4218
|
+
objectKey,
|
|
4219
|
+
radarVariable,
|
|
4220
|
+
slotIdx,
|
|
4221
|
+
offset: slot.offset,
|
|
4222
|
+
compressedSize: slot.compressedSize,
|
|
4223
|
+
indexResourceTotal
|
|
4224
|
+
});
|
|
4225
|
+
setArchiveCache(cacheKey, null);
|
|
4226
|
+
return null;
|
|
4227
|
+
}
|
|
4228
|
+
const slotRangeEnd = slotEndExclusive - 1;
|
|
4229
|
+
const slotHeaders = {
|
|
4230
|
+
"x-api-key": NEXRAD_ARCHIVE_API_KEY,
|
|
4231
|
+
"Range": `bytes=${slot.offset}-${slotRangeEnd}`
|
|
4232
|
+
};
|
|
4233
|
+
let slotResp = await fetch(url, { headers: slotHeaders });
|
|
4234
|
+
let slotBuffer;
|
|
4235
|
+
if (slotResp.ok || slotResp.status === 206) {
|
|
4236
|
+
slotBuffer = await slotResp.arrayBuffer();
|
|
4237
|
+
} else if (slotResp.status === 416) {
|
|
4238
|
+
const fullResp = await fetch(url, { headers: { "x-api-key": NEXRAD_ARCHIVE_API_KEY } });
|
|
4239
|
+
if (!fullResp.ok) {
|
|
4240
|
+
throw new Error(`HTTP ${fullResp.status} full fetch after 416 for level2 ${url}`);
|
|
4146
4241
|
}
|
|
4147
|
-
|
|
4148
|
-
|
|
4242
|
+
const fullBuf = await fullResp.arrayBuffer();
|
|
4243
|
+
if (slot.offset >= fullBuf.byteLength || slotEndExclusive > fullBuf.byteLength) {
|
|
4244
|
+
throw new Error(
|
|
4245
|
+
`level2 slot out of bounds after 416 fallback (offset=${slot.offset}, end=${slotEndExclusive}, file=${fullBuf.byteLength}) for ${url}`
|
|
4246
|
+
);
|
|
4247
|
+
}
|
|
4248
|
+
slotBuffer = fullBuf.slice(slot.offset, slotEndExclusive);
|
|
4249
|
+
} else {
|
|
4149
4250
|
throw new Error(`HTTP ${slotResp.status} fetching level2 slot ${url}`);
|
|
4150
4251
|
}
|
|
4151
|
-
const slotBuffer = await slotResp.arrayBuffer();
|
|
4152
4252
|
const sites = await loadNexradSites();
|
|
4153
4253
|
let decoded = await decodeSweepInWorker(
|
|
4154
4254
|
objectKey,
|
|
@@ -4162,6 +4262,7 @@ async function fetchAndParseArchive(url, objectKey, radarVariable, groupId, rada
|
|
|
4162
4262
|
setArchiveCache(cacheKey, null);
|
|
4163
4263
|
return null;
|
|
4164
4264
|
}
|
|
4265
|
+
decoded = { ...decoded, embeddedNyquistMs: header.embeddedNyquistMs };
|
|
4165
4266
|
const l2MotionKey = options?.level3MotionObjectKey;
|
|
4166
4267
|
if (l2MotionKey && radarVariable === "VEL") {
|
|
4167
4268
|
decoded = await applyStormMotionFromN0sObjectKey(
|
|
@@ -1420,18 +1420,23 @@ function decodeLevel3RasterProduct(buffer: ArrayBuffer, objectKey: string, radar
|
|
|
1420
1420
|
}
|
|
1421
1421
|
}
|
|
1422
1422
|
|
|
1423
|
-
const GROUP2_VARS = ['
|
|
1423
|
+
const GROUP2_VARS = ['SW'];
|
|
1424
|
+
/** g2 split-cut in clear-air mode (VCP 35): REF + VEL + SW in one file (legacy archives only). */
|
|
1425
|
+
const GROUP2_COMBINED_3_VARS = ['REF', 'VEL', 'SW'];
|
|
1424
1426
|
/** g1 dual-pol + REF; order must match writer. KDP is Level-III (N0K), not in g1 bins. */
|
|
1425
1427
|
const GROUP1_VARS = ['REF', 'ZDR', 'RHO', 'PHI'] as const;
|
|
1426
1428
|
/** g2 combined tilt with KDP (7 slots); order must match writer. */
|
|
1427
1429
|
const GROUP2_COMBINED_7_VARS = ['REF', 'ZDR', 'RHO', 'PHI', 'KDP', 'VEL', 'SW'];
|
|
1428
1430
|
const GROUP2_COMBINED_VARS = ['REF', 'ZDR', 'RHO', 'PHI', 'VEL', 'SW'];
|
|
1429
1431
|
|
|
1430
|
-
// Fixed sizes matching the Python writer
|
|
1431
|
-
const FILE_HDR_BYTES
|
|
1432
|
-
const
|
|
1433
|
-
const
|
|
1434
|
-
|
|
1432
|
+
// Fixed sizes matching the Python writer (`lambda_function.py` MAX_RAYS / AZ_BLOCK_BYTES).
|
|
1433
|
+
const FILE_HDR_BYTES = 64;
|
|
1434
|
+
const LEVEL2_AZ_BLOCK_RAYS = 720;
|
|
1435
|
+
const LEVEL2_AZ_BLOCK_BYTES = LEVEL2_AZ_BLOCK_RAYS * 4;
|
|
1436
|
+
/** Per-sweep Nyquist (m/s), big-endian float32 after azimuth block and before slot index (writer `nyquist_bytes`). */
|
|
1437
|
+
const LEVEL2_FILE_NYQUIST_BYTES = 4;
|
|
1438
|
+
const SLOT_INDEX_ENTRY = 18;
|
|
1439
|
+
const MAX_SLOTS = 7; // g2 combined tilt can write 7 fields (GROUP2_COMBINED_7_VARS)
|
|
1435
1440
|
|
|
1436
1441
|
interface FileHeader {
|
|
1437
1442
|
unixTime: number;
|
|
@@ -1441,8 +1446,10 @@ interface FileHeader {
|
|
|
1441
1446
|
firstGateKm: number;
|
|
1442
1447
|
gateWidthKm: number;
|
|
1443
1448
|
nSlots: number;
|
|
1444
|
-
azimuthsBuffer: ArrayBuffer; // raw big-endian float32 bytes
|
|
1449
|
+
azimuthsBuffer: ArrayBuffer; // raw big-endian float32 bytes (720*4)
|
|
1445
1450
|
slots: Array<{ offset: number; compressedSize: number; uncompressedSize: number }>;
|
|
1451
|
+
/** Nyquist velocity (m/s) from file header after azimuths; null if non-finite / out of band. */
|
|
1452
|
+
embeddedNyquistMs: number | null;
|
|
1446
1453
|
}
|
|
1447
1454
|
|
|
1448
1455
|
function int16ToFloat16(val: number): number {
|
|
@@ -1455,7 +1462,30 @@ function int16ToFloat16(val: number): number {
|
|
|
1455
1462
|
return Math.pow(-1, sign) * Math.pow(2, exponent - 16) * (1 + fraction / Math.pow(2, 10));
|
|
1456
1463
|
}
|
|
1457
1464
|
|
|
1458
|
-
|
|
1465
|
+
/**
|
|
1466
|
+
* Total Level-II object size when known.
|
|
1467
|
+
* For 206 partial responses, only `Content-Range: bytes a-b/total` gives `total` — `Content-Length` is the segment size.
|
|
1468
|
+
*/
|
|
1469
|
+
function nexradLevel2ResourceTotalBytes(resp: Response): number | null {
|
|
1470
|
+
const cr = resp.headers.get('Content-Range');
|
|
1471
|
+
if (cr) {
|
|
1472
|
+
const m = cr.trim().match(/\/(\d+)\s*$/);
|
|
1473
|
+
if (m) {
|
|
1474
|
+
const n = Number(m[1]);
|
|
1475
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
if (resp.status === 200) {
|
|
1479
|
+
const cl = resp.headers.get('Content-Length');
|
|
1480
|
+
if (cl) {
|
|
1481
|
+
const n = Number(cl);
|
|
1482
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
return null;
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
function parseFileHeader(buffer: ArrayBuffer, azBlockBytes: number): FileHeader {
|
|
1459
1489
|
const view = new DataView(buffer);
|
|
1460
1490
|
const magic = view.getUint32(0, false);
|
|
1461
1491
|
if (magic !== 0x4E584244) throw new Error(`Bad magic: 0x${magic.toString(16)}`);
|
|
@@ -1468,24 +1498,42 @@ function parseFileHeader(buffer: ArrayBuffer): FileHeader {
|
|
|
1468
1498
|
const gateWidthKm = view.getFloat32(20, false);
|
|
1469
1499
|
const nSlots = view.getUint16(24, false);
|
|
1470
1500
|
|
|
1471
|
-
|
|
1472
|
-
|
|
1501
|
+
const azimuthsBuffer = buffer.slice(FILE_HDR_BYTES, FILE_HDR_BYTES + azBlockBytes);
|
|
1502
|
+
|
|
1503
|
+
const azEnd = FILE_HDR_BYTES + azBlockBytes;
|
|
1504
|
+
const idxStart = azEnd + LEVEL2_FILE_NYQUIST_BYTES;
|
|
1505
|
+
const needBytes = idxStart + nSlots * SLOT_INDEX_ENTRY;
|
|
1506
|
+
if (buffer.byteLength < needBytes) {
|
|
1507
|
+
throw new Error(`level2 header buffer too small: ${buffer.byteLength} < ${needBytes}`);
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
const nyqCandidate = view.getFloat32(azEnd, false);
|
|
1511
|
+
const embeddedNyquistMs =
|
|
1512
|
+
Number.isFinite(nyqCandidate) && nyqCandidate > 0.5 && nyqCandidate < 128 ? nyqCandidate : null;
|
|
1473
1513
|
|
|
1474
|
-
|
|
1475
|
-
const idxStart = FILE_HDR_BYTES + AZ_BLOCK_BYTES;
|
|
1476
|
-
const slots = [];
|
|
1514
|
+
const slots: Array<{ offset: number; compressedSize: number; uncompressedSize: number }> = [];
|
|
1477
1515
|
for (let i = 0; i < nSlots; i++) {
|
|
1478
1516
|
const base = idxStart + i * SLOT_INDEX_ENTRY;
|
|
1479
|
-
const offsetHigh = view.getUint32(base,
|
|
1480
|
-
const offsetLow
|
|
1517
|
+
const offsetHigh = view.getUint32(base, false);
|
|
1518
|
+
const offsetLow = view.getUint32(base + 4, false);
|
|
1481
1519
|
const offset = offsetHigh * 2 ** 32 + offsetLow;
|
|
1482
|
-
const compressedSize
|
|
1520
|
+
const compressedSize = view.getUint32(base + 8, false);
|
|
1483
1521
|
const uncompressedSize = view.getUint32(base + 12, false);
|
|
1484
1522
|
slots.push({ offset, compressedSize, uncompressedSize });
|
|
1485
1523
|
}
|
|
1486
1524
|
|
|
1487
|
-
return {
|
|
1488
|
-
|
|
1525
|
+
return {
|
|
1526
|
+
unixTime,
|
|
1527
|
+
nRays,
|
|
1528
|
+
nGates,
|
|
1529
|
+
elevAngle,
|
|
1530
|
+
firstGateKm,
|
|
1531
|
+
gateWidthKm,
|
|
1532
|
+
nSlots,
|
|
1533
|
+
azimuthsBuffer,
|
|
1534
|
+
slots,
|
|
1535
|
+
embeddedNyquistMs,
|
|
1536
|
+
};
|
|
1489
1537
|
}
|
|
1490
1538
|
|
|
1491
1539
|
function decodeSweepInWorker(
|
|
@@ -1550,11 +1598,34 @@ function resolveLevel3SrvMotionObjectKey(
|
|
|
1550
1598
|
return pickNearestLevel3ObjectKey(unixTime, motionMap);
|
|
1551
1599
|
}
|
|
1552
1600
|
|
|
1601
|
+
/** Reuse parsed N0S motion across time steps (SRV: same motion key is common while scrubbing). */
|
|
1602
|
+
const N0S_MOTION_CACHE_MAX = 128;
|
|
1603
|
+
const n0sMotionVectorCache = new Map<string, { speedMs: number; directionDeg: number }>();
|
|
1604
|
+
|
|
1605
|
+
function getCachedN0sMotion(motionObjectKey: string) {
|
|
1606
|
+
return n0sMotionVectorCache.get(motionObjectKey);
|
|
1607
|
+
}
|
|
1608
|
+
function setCachedN0sMotion(
|
|
1609
|
+
motionObjectKey: string,
|
|
1610
|
+
motion: { speedMs: number; directionDeg: number },
|
|
1611
|
+
) {
|
|
1612
|
+
n0sMotionVectorCache.set(motionObjectKey, motion);
|
|
1613
|
+
while (n0sMotionVectorCache.size > N0S_MOTION_CACHE_MAX) {
|
|
1614
|
+
const first = n0sMotionVectorCache.keys().next().value as string | undefined;
|
|
1615
|
+
if (first === undefined) break;
|
|
1616
|
+
n0sMotionVectorCache.delete(first);
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
|
|
1553
1620
|
async function applyStormMotionFromN0sObjectKey(
|
|
1554
1621
|
frame: DecodedRadarFrame,
|
|
1555
1622
|
motionObjectKey: string,
|
|
1556
1623
|
logLabel: string,
|
|
1557
1624
|
): Promise<DecodedRadarFrame> {
|
|
1625
|
+
const cached = getCachedN0sMotion(motionObjectKey);
|
|
1626
|
+
if (cached) {
|
|
1627
|
+
return applyLevel3StormRelativeToFrame(frame, cached.speedMs, cached.directionDeg);
|
|
1628
|
+
}
|
|
1558
1629
|
const motionUrl = objectKeyToUrl(motionObjectKey, 'level3');
|
|
1559
1630
|
try {
|
|
1560
1631
|
const motionResp = await fetch(motionUrl);
|
|
@@ -1562,6 +1633,7 @@ async function applyStormMotionFromN0sObjectKey(
|
|
|
1562
1633
|
const motionBuf = await motionResp.arrayBuffer();
|
|
1563
1634
|
const motion = parseLevel3StormMotionFromBuffer(motionBuf);
|
|
1564
1635
|
if (motion) {
|
|
1636
|
+
setCachedN0sMotion(motionObjectKey, motion);
|
|
1565
1637
|
return applyLevel3StormRelativeToFrame(frame, motion.speedMs, motion.directionDeg);
|
|
1566
1638
|
}
|
|
1567
1639
|
}
|
|
@@ -1648,7 +1720,9 @@ export async function fetchAndParseArchive(
|
|
|
1648
1720
|
|
|
1649
1721
|
// ── Level-2 two-request range path ───────────────────────────────────────
|
|
1650
1722
|
// Request 1: fetch just the header + slot index to find byte offsets
|
|
1651
|
-
const
|
|
1723
|
+
const azBlockBytes = LEVEL2_AZ_BLOCK_BYTES;
|
|
1724
|
+
const INDEX_FETCH_BYTES =
|
|
1725
|
+
FILE_HDR_BYTES + azBlockBytes + LEVEL2_FILE_NYQUIST_BYTES + MAX_SLOTS * SLOT_INDEX_ENTRY;
|
|
1652
1726
|
const indexResp = await fetch(url, {
|
|
1653
1727
|
headers: {
|
|
1654
1728
|
'x-api-key': NEXRAD_ARCHIVE_API_KEY,
|
|
@@ -1659,7 +1733,21 @@ export async function fetchAndParseArchive(
|
|
|
1659
1733
|
throw new Error(`HTTP ${indexResp.status} fetching level2 index ${url}`);
|
|
1660
1734
|
}
|
|
1661
1735
|
const indexBuffer = await indexResp.arrayBuffer();
|
|
1662
|
-
const
|
|
1736
|
+
const indexResourceTotal = nexradLevel2ResourceTotalBytes(indexResp);
|
|
1737
|
+
const header = parseFileHeader(indexBuffer, azBlockBytes);
|
|
1738
|
+
|
|
1739
|
+
const slotIndexBytes =
|
|
1740
|
+
FILE_HDR_BYTES + azBlockBytes + LEVEL2_FILE_NYQUIST_BYTES + header.nSlots * SLOT_INDEX_ENTRY;
|
|
1741
|
+
if (indexBuffer.byteLength < slotIndexBytes) {
|
|
1742
|
+
console.warn('[RadarLayer] level2 index response shorter than slot table', {
|
|
1743
|
+
objectKey,
|
|
1744
|
+
byteLength: indexBuffer.byteLength,
|
|
1745
|
+
need: slotIndexBytes,
|
|
1746
|
+
nSlots: header.nSlots,
|
|
1747
|
+
});
|
|
1748
|
+
setArchiveCache(cacheKey, null);
|
|
1749
|
+
return null;
|
|
1750
|
+
}
|
|
1663
1751
|
|
|
1664
1752
|
// ── Step 2: find slot for this variable ─────────────────────
|
|
1665
1753
|
let varList: string[];
|
|
@@ -1667,6 +1755,8 @@ export async function fetchAndParseArchive(
|
|
|
1667
1755
|
varList = GROUP2_COMBINED_7_VARS;
|
|
1668
1756
|
} else if (groupId === 2 && header.nSlots === 6) {
|
|
1669
1757
|
varList = GROUP2_COMBINED_VARS;
|
|
1758
|
+
} else if (groupId === 2 && header.nSlots === 3) {
|
|
1759
|
+
varList = GROUP2_COMBINED_3_VARS;
|
|
1670
1760
|
} else if (groupId === 2) {
|
|
1671
1761
|
varList = GROUP2_VARS;
|
|
1672
1762
|
} else {
|
|
@@ -1688,17 +1778,44 @@ export async function fetchAndParseArchive(
|
|
|
1688
1778
|
}
|
|
1689
1779
|
|
|
1690
1780
|
// ── Step 3: fetch exactly the slot bytes ────────────────────
|
|
1691
|
-
const
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1781
|
+
const slotEndExclusive = slot.offset + slot.compressedSize;
|
|
1782
|
+
if (indexResourceTotal != null && slotEndExclusive > indexResourceTotal) {
|
|
1783
|
+
console.warn('[RadarLayer] level2 slot extends past object size (bad index or stale CDN)', {
|
|
1784
|
+
objectKey,
|
|
1785
|
+
radarVariable,
|
|
1786
|
+
slotIdx,
|
|
1787
|
+
offset: slot.offset,
|
|
1788
|
+
compressedSize: slot.compressedSize,
|
|
1789
|
+
indexResourceTotal,
|
|
1790
|
+
});
|
|
1791
|
+
setArchiveCache(cacheKey, null);
|
|
1792
|
+
return null;
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
const slotRangeEnd = slotEndExclusive - 1;
|
|
1796
|
+
const slotHeaders: Record<string, string> = {
|
|
1797
|
+
'x-api-key': NEXRAD_ARCHIVE_API_KEY,
|
|
1798
|
+
'Range': `bytes=${slot.offset}-${slotRangeEnd}`,
|
|
1799
|
+
};
|
|
1800
|
+
let slotResp = await fetch(url, { headers: slotHeaders });
|
|
1801
|
+
let slotBuffer: ArrayBuffer;
|
|
1802
|
+
if (slotResp.ok || slotResp.status === 206) {
|
|
1803
|
+
slotBuffer = await slotResp.arrayBuffer();
|
|
1804
|
+
} else if (slotResp.status === 416) {
|
|
1805
|
+
const fullResp = await fetch(url, { headers: { 'x-api-key': NEXRAD_ARCHIVE_API_KEY } });
|
|
1806
|
+
if (!fullResp.ok) {
|
|
1807
|
+
throw new Error(`HTTP ${fullResp.status} full fetch after 416 for level2 ${url}`);
|
|
1808
|
+
}
|
|
1809
|
+
const fullBuf = await fullResp.arrayBuffer();
|
|
1810
|
+
if (slot.offset >= fullBuf.byteLength || slotEndExclusive > fullBuf.byteLength) {
|
|
1811
|
+
throw new Error(
|
|
1812
|
+
`level2 slot out of bounds after 416 fallback (offset=${slot.offset}, end=${slotEndExclusive}, file=${fullBuf.byteLength}) for ${url}`,
|
|
1813
|
+
);
|
|
1814
|
+
}
|
|
1815
|
+
slotBuffer = fullBuf.slice(slot.offset, slotEndExclusive);
|
|
1816
|
+
} else {
|
|
1699
1817
|
throw new Error(`HTTP ${slotResp.status} fetching level2 slot ${url}`);
|
|
1700
1818
|
}
|
|
1701
|
-
const slotBuffer = await slotResp.arrayBuffer();
|
|
1702
1819
|
|
|
1703
1820
|
// ── Step 4: decode in worker ────────────────────────────────
|
|
1704
1821
|
const sites = await loadNexradSites();
|
|
@@ -1709,6 +1826,7 @@ export async function fetchAndParseArchive(
|
|
|
1709
1826
|
);
|
|
1710
1827
|
|
|
1711
1828
|
if (!decoded) { setArchiveCache(cacheKey, null); return null; }
|
|
1829
|
+
decoded = { ...decoded, embeddedNyquistMs: header.embeddedNyquistMs };
|
|
1712
1830
|
const l2MotionKey = options?.level3MotionObjectKey;
|
|
1713
1831
|
if (l2MotionKey && radarVariable === 'VEL') {
|
|
1714
1832
|
decoded = await applyStormMotionFromN0sObjectKey(
|