@aguacerowx/mapsgl 0.0.50 → 0.0.52

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.
@@ -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
  /**
@@ -745,17 +765,12 @@ export class WeatherLayerManager extends EventEmitter {
745
765
  async setNexradProduct(product) {
746
766
  return this.core.setNexradProduct(product);
747
767
  }
748
- /** @param {'level2'|'level3'} dataSource @param {string} product */
749
- async setNexradProductMode(dataSource, product) {
750
- return this.core.setNexradProductMode(dataSource, product);
751
- }
752
- /** @param {'level2'|'level3'} source */
753
- async setNexradDataSource(source) {
754
- return this.core.setNexradDataSource(source);
755
- }
756
768
  async setNexradTilt(tilt) {
757
769
  return this.core.setNexradTilt(tilt);
758
770
  }
771
+ async setNexradStormRelative(enabled) {
772
+ return this.core.setNexradStormRelative(enabled);
773
+ }
759
774
  async setNexradTimestamp(timestamp) {
760
775
  return this.core.setNexradTimestamp(timestamp);
761
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
  }
@@ -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(