@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aguacerowx/mapsgl",
3
- "version": "0.0.51",
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.22",
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
- this._resyncSourceDataFilters();
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._timelineUnix;
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 useRealtime = userPinnedRealtime || followRealtimeAtLatestAnchor;
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: useRealtime ? undefined : this._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, overlaid on satellite / MRMS / NEXRAD only
292
- * (hidden on model grids). By default `nwsAlertSettings.alertScope` is `'all'`. Use `'user'` with
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 isModelMode = !state.isSatellite && !state.isNexrad && !state.isMRMS;
324
- const enabled = masterEnabled && !isModelMode;
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
- this._nwsOverlay.updateOptions({
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
- weatherLayerId,
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._nwsOverlay.syncWithMode({ enabled, timelineUnix, timelineTimes });
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 need {@link geometryLayoutKey} so frames use canonical azimuth bins (stable mesh).
4
- * Other Level-III radials need ray-boundary-keyed geometry so time scrubbing cannot reuse a mismatched mesh.
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
- /** @typedef {{ geometryLayoutKey?: string; geometryCacheKeysRayBoundaries?: boolean }} MapboxRadarFrameUploadOptions */
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(siteId, radarVariable, tilt) {
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 {object} state - AguaceroCore state (or state:change payload) with NEXRAD fields
91
- * @returns {MapboxRadarFrameUploadOptions | undefined}
102
+ * @param state - AguaceroCore state (or state:change payload) with NEXRAD fields
92
103
  */
93
- export function mapboxFrameUploadOptionsForNexradState(state) {
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 = ["VEL", "SW"];
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 AZ_BLOCK_BYTES = 720 * 4;
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 = 6;
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 parseFileHeader(buffer) {
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 + AZ_BLOCK_BYTES);
3964
- const idxStart = FILE_HDR_BYTES + AZ_BLOCK_BYTES;
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 INDEX_FETCH_BYTES = FILE_HDR_BYTES + AZ_BLOCK_BYTES + MAX_SLOTS * SLOT_INDEX_ENTRY;
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 header = parseFileHeader(indexBuffer);
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 slotRangeEnd = slot.offset + slot.compressedSize - 1;
4142
- const slotResp = await fetch(url, {
4143
- headers: {
4144
- "x-api-key": NEXRAD_ARCHIVE_API_KEY,
4145
- "Range": `bytes=${slot.offset}-${slotRangeEnd}`
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
- if (!slotResp.ok && slotResp.status !== 206) {
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 = ['VEL', 'SW'];
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 = 64;
1432
- const AZ_BLOCK_BYTES = 720 * 4; // 2880
1433
- const SLOT_INDEX_ENTRY = 18;
1434
- const MAX_SLOTS = 6;
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, 720*4
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
- function parseFileHeader(buffer: ArrayBuffer): FileHeader {
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
- // Azimuths: 720 big-endian float32s starting at byte 64
1472
- const azimuthsBuffer = buffer.slice(FILE_HDR_BYTES, FILE_HDR_BYTES + AZ_BLOCK_BYTES);
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
- // Slot index starts at byte 2944
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, false);
1480
- const offsetLow = view.getUint32(base + 4, false);
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 = view.getUint32(base + 8, false);
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 { unixTime, nRays, nGates, elevAngle, firstGateKm, gateWidthKm,
1488
- nSlots, azimuthsBuffer, slots };
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 INDEX_FETCH_BYTES = FILE_HDR_BYTES + AZ_BLOCK_BYTES + MAX_SLOTS * SLOT_INDEX_ENTRY;
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 header = parseFileHeader(indexBuffer);
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 slotRangeEnd = slot.offset + slot.compressedSize - 1;
1692
- const slotResp = await fetch(url, {
1693
- headers: {
1694
- 'x-api-key': NEXRAD_ARCHIVE_API_KEY,
1695
- 'Range': `bytes=${slot.offset}-${slotRangeEnd}`,
1696
- },
1697
- });
1698
- if (!slotResp.ok && slotResp.status !== 206) {
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(