@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.
@@ -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(
@@ -1190,6 +1190,56 @@ export function cloneNwwsFeatureCollectionStrippingVolatile(data) {
1190
1190
  };
1191
1191
  }
1192
1192
 
1193
+ /**
1194
+ * Parse `properties.parameters` (JSON string or object from NWWS / CAP-style extensions).
1195
+ * @param {unknown} raw
1196
+ * @returns {Record<string, unknown>|null}
1197
+ */
1198
+ function parseNwsParametersJson(raw) {
1199
+ if (raw == null) return null;
1200
+ if (typeof raw === 'object' && !Array.isArray(raw)) return /** @type {Record<string, unknown>} */ (raw);
1201
+ if (typeof raw === 'string') {
1202
+ try {
1203
+ const o = JSON.parse(raw);
1204
+ return o && typeof o === 'object' && !Array.isArray(o) ? o : null;
1205
+ } catch {
1206
+ return null;
1207
+ }
1208
+ }
1209
+ return null;
1210
+ }
1211
+
1212
+ /**
1213
+ * Human-oriented subset of `parameters` for severe wx / common warning types (hail, wind, radar source).
1214
+ * @param {Record<string, unknown>|null} pObj
1215
+ * @returns {Record<string, string> | null}
1216
+ */
1217
+ function buildHazardDetailsFromParameters(pObj) {
1218
+ if (!pObj || typeof pObj !== 'object') return null;
1219
+ /** @type {Record<string, string>} */
1220
+ const out = {};
1221
+ const set = (key, from) => {
1222
+ const v = pObj[from];
1223
+ if (v != null && String(v).trim() !== '') out[key] = String(v);
1224
+ };
1225
+ set('source', 'source');
1226
+ set('maxHailSize', 'max_hail_size');
1227
+ set('maxWindGust', 'max_wind_gust');
1228
+ set('damageThreat', 'damage_threat');
1229
+ set('wmo', 'wmo');
1230
+ if (pObj.tornado_detection != null && String(pObj.tornado_detection).trim() !== '')
1231
+ out.tornadoDetection = String(pObj.tornado_detection);
1232
+ if (pObj.flood_detection != null && String(pObj.flood_detection).trim() !== '')
1233
+ out.floodDetection = String(pObj.flood_detection);
1234
+ return Object.keys(out).length ? out : null;
1235
+ }
1236
+
1237
+ /**
1238
+ * Normalized payload for `nws:alert:click`. Top-level fields are derived from `feature.properties`
1239
+ * so you rarely need the duplicate `properties` object (kept for advanced / forward-compatible use).
1240
+ *
1241
+ * @param {GeoJSON.Feature|{ properties?: Record<string, unknown> }} feature
1242
+ */
1193
1243
  export function buildAlertClickPayload(feature) {
1194
1244
  const properties = feature?.properties ? { ...feature.properties } : {};
1195
1245
  const eventName = getNwsAlertEventLabelFromProperties(properties);
@@ -1204,9 +1254,10 @@ export function buildAlertClickPayload(feature) {
1204
1254
  if (!Array.isArray(tags)) {
1205
1255
  tags = tags != null ? [String(tags)] : [];
1206
1256
  }
1207
- const headline = properties.headline != null ? String(properties.headline) : '';
1208
1257
  const name =
1209
- headline ||
1258
+ (properties.headline != null && String(properties.headline).trim() !== ''
1259
+ ? String(properties.headline)
1260
+ : '') ||
1210
1261
  eventName ||
1211
1262
  (properties.event != null ? String(properties.event) : '') ||
1212
1263
  (properties.event_name != null ? String(properties.event_name) : '');
@@ -1216,8 +1267,6 @@ export function buildAlertClickPayload(feature) {
1216
1267
  : properties.raw_text != null
1217
1268
  ? String(properties.raw_text)
1218
1269
  : '';
1219
- const summary = properties.summary != null ? String(properties.summary) : '';
1220
- const instruction = properties.instruction != null ? String(properties.instruction) : '';
1221
1270
 
1222
1271
  const startUnix =
1223
1272
  typeof properties.start_unix === 'number'
@@ -1232,17 +1281,29 @@ export function buildAlertClickPayload(feature) {
1232
1281
  ? properties.active_start_unix
1233
1282
  : getNwsActiveStartUnix(properties);
1234
1283
 
1284
+ const parametersParsed = parseNwsParametersJson(properties.parameters);
1285
+ const hazardDetails = buildHazardDetailsFromParameters(parametersParsed);
1286
+
1235
1287
  return {
1236
1288
  name,
1237
1289
  eventName,
1238
- headline,
1239
- summary,
1240
1290
  description,
1241
- instruction,
1242
1291
  tags,
1243
1292
  startUnix,
1244
1293
  endUnix,
1245
1294
  activeStartUnix,
1295
+ /** ISO-ish strings from the feed (see also unix fields above). */
1296
+ issued: properties.issued != null ? String(properties.issued) : '',
1297
+ updatedAt: properties.updated_at != null ? String(properties.updated_at) : '',
1298
+ expiresAt: properties.expires != null ? String(properties.expires) : '',
1299
+ alertId: properties.alert_id != null ? String(properties.alert_id) : '',
1300
+ office: properties.office != null ? String(properties.office) : '',
1301
+ nwsProductKey: properties.nws_product_key != null ? String(properties.nws_product_key) : '',
1302
+ /** Parsed `properties.parameters` JSON when present (hail/wind/source keys vary by product). */
1303
+ parametersParsed,
1304
+ /** Short labels for SV / similar products: source, maxHailSize, maxWindGust, damageThreat, … */
1305
+ hazardDetails,
1306
+ /** Same as `feature.properties` — prefer top-level fields above when possible. */
1246
1307
  properties,
1247
1308
  };
1248
1309
  }