@aguacerowx/mapsgl 0.0.43 → 0.0.45

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.43",
3
+ "version": "0.0.45",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -308,6 +308,23 @@ export class NwsWatchesWarningsOverlay {
308
308
  : timeMode.timelineUnix;
309
309
  const useOnsetWindow = this._nwsAlertSettings.activeOnlyRealtime === true;
310
310
 
311
+ /**
312
+ * Weather-timeline alignment (MRMS / satellite / NEXRAD scrubber): without a valid reference
313
+ * time we must not show every alert at full opacity — previously `isActive` defaulted to
314
+ * true for all features when `referenceUnix` was still null (race before first timestamp).
315
+ */
316
+ if (!timeMode.useRealtime && (referenceUnix == null || !Number.isFinite(referenceUnix))) {
317
+ for (const f of this._workingFc.features ?? []) {
318
+ if (f.id == null) continue;
319
+ try {
320
+ m.setFeatureState({ source: this._sourceId, id: f.id }, { active: false });
321
+ } catch {
322
+ /* ignore */
323
+ }
324
+ }
325
+ return;
326
+ }
327
+
311
328
  for (const f of this._workingFc.features ?? []) {
312
329
  if (f.id == null) continue;
313
330
  let isActive = true;
@@ -1007,7 +1007,11 @@ export class WeatherLayerManager extends EventEmitter {
1007
1007
  return;
1008
1008
  }
1009
1009
 
1010
- const currentFrameTime = Number(state.isMRMS ? state.mrmsTimestamp : state.forecastHour);
1010
+ // Number(null) === 0, which is falsy and causes _loadGridData to return null immediately
1011
+ // (the core guards with `if (!mrmsTimestamp) return null`). Treat null as NaN so that
1012
+ // primaryTimeForRebuild falls through to times[0] — the first real MRMS timestamp.
1013
+ const _rawFrameTime = state.isMRMS ? state.mrmsTimestamp : state.forecastHour;
1014
+ const currentFrameTime = _rawFrameTime == null ? NaN : Number(_rawFrameTime);
1011
1015
  /** When the active timestep is unset/NaN, paint the first timeline step (legacy single-fetch behavior). */
1012
1016
  let primaryTimeForRebuild = Number.NaN;
1013
1017
  if (mode === 'rebuild') {
@@ -1180,7 +1184,10 @@ export class WeatherLayerManager extends EventEmitter {
1180
1184
  this._initialGridLoadPending = true;
1181
1185
 
1182
1186
  const normalized = this._collectNormalizedTimelineSteps(state);
1183
- const currentFrameTime = Number(state.isMRMS ? state.mrmsTimestamp : state.forecastHour);
1187
+ // Same null-guard as in _runParallelGridFrameLoads: Number(null) === 0, which is a finite
1188
+ // value that would inject Unix epoch into timesToLoad and mis-identify the primary frame.
1189
+ const _rawCft = state.isMRMS ? state.mrmsTimestamp : state.forecastHour;
1190
+ const currentFrameTime = _rawCft == null ? NaN : Number(_rawCft);
1184
1191
  const timesSet = new Set(normalized);
1185
1192
  if (!Number.isNaN(currentFrameTime)) {
1186
1193
  timesSet.add(currentFrameTime);
@@ -1219,7 +1226,8 @@ export class WeatherLayerManager extends EventEmitter {
1219
1226
 
1220
1227
  _preloadAllTimeSteps(state) {
1221
1228
  const normalized = this._collectNormalizedTimelineSteps(state);
1222
- const currentFrameTime = Number(state.isMRMS ? state.mrmsTimestamp : state.forecastHour);
1229
+ const _rawPft = state.isMRMS ? state.mrmsTimestamp : state.forecastHour;
1230
+ const currentFrameTime = _rawPft == null ? NaN : Number(_rawPft);
1223
1231
  const stepsToPreload = normalized.filter(t => t !== currentFrameTime);
1224
1232
 
1225
1233
  this._debugLog('_preloadAllTimeSteps', {
@@ -323,8 +323,43 @@ export function isNwwsTrackerStatusTerminal(status) {
323
323
  return status === 'cancelled' || status === 'expired';
324
324
  }
325
325
 
326
- function isNwwsTrackerStatusUpgrade(status) {
327
- return status === 'upgraded';
326
+ /**
327
+ * CAP references / explicit replace fields for the previous bulletin(s) in the same event chain.
328
+ * Used so extensions/upgrades/continuations remove polygons keyed by superseded ids, not only by
329
+ * the new feature's `base_alert_id`.
330
+ */
331
+ function collectNwwsReplacementReferenceIds(properties) {
332
+ if (!properties || typeof properties !== 'object') return [];
333
+ const out = [];
334
+ const pushRaw = (v) => {
335
+ if (v == null) return;
336
+ if (Array.isArray(v)) {
337
+ for (const x of v) pushRaw(x);
338
+ return;
339
+ }
340
+ const n = normalizeNwwsAlertId(v);
341
+ if (n) out.push(n);
342
+ };
343
+ pushRaw(properties._upgradedFrom ?? properties.upgradedFrom);
344
+ pushRaw(properties._replaces ?? properties.replaces ?? properties.replaced_alert_id ?? properties.replacedAlertId);
345
+ let refs = properties.references;
346
+ if (typeof refs === 'string') {
347
+ try {
348
+ refs = JSON.parse(refs);
349
+ } catch {
350
+ refs = null;
351
+ }
352
+ }
353
+ if (Array.isArray(refs)) {
354
+ for (const r of refs) {
355
+ if (typeof r === 'string') pushRaw(r);
356
+ else if (r && typeof r === 'object') {
357
+ const ro = r;
358
+ pushRaw(ro.identifier ?? ro.id ?? ro.alert_id ?? ro.alertId ?? ro.base_alert_id);
359
+ }
360
+ }
361
+ }
362
+ return out;
328
363
  }
329
364
 
330
365
  export function getNwsBaseAlertIdFromFeature(feature) {
@@ -369,20 +404,60 @@ export function applyNwwsDeltaToCollection(data, delta) {
369
404
  const expireSet = new Set(delta.expireIds);
370
405
  const removeIds = new Set([...expireSet, ...cancelSet]);
371
406
 
407
+ for (const feat of delta.upserts) {
408
+ if (!feat || feat.type !== 'Feature') continue;
409
+ const norm = normalizeNwwsHttpAlertFeature(
410
+ {
411
+ type: 'Feature',
412
+ id: feat.id,
413
+ geometry: feat.geometry,
414
+ properties: { ...(feat.properties ?? {}) },
415
+ },
416
+ true
417
+ );
418
+ for (const refId of collectNwwsReplacementReferenceIds(norm.properties)) {
419
+ removeIds.add(refId);
420
+ }
421
+ }
422
+
372
423
  const replacedBaseIds = new Set();
424
+ // Secondary: VTEC-derived stable IDs from upserts. Used as a fallback when base_alert_id
425
+ // differs between the stored feature and the update (e.g. when the content hash changed but
426
+ // the VTEC event number did not). This handles sessions where old hash-keyed features already
427
+ // exist in the working collection when an update with a VTEC-keyed ID arrives.
428
+ const replacedVtecIds = new Set();
373
429
  for (const feat of delta.upserts) {
374
430
  if (!feat || feat.type !== 'Feature') continue;
375
- const baseId = getNwsBaseAlertIdFromFeature(feat);
431
+ const norm = normalizeNwwsHttpAlertFeature(
432
+ {
433
+ type: 'Feature',
434
+ id: feat.id,
435
+ geometry: feat.geometry,
436
+ properties: { ...(feat.properties ?? {}) },
437
+ },
438
+ true
439
+ );
440
+ const baseId = getNwsBaseAlertIdFromFeature(norm);
376
441
  if (baseId) replacedBaseIds.add(baseId);
442
+ const vtecId = extractNwsVtecStableId(norm.properties);
443
+ if (vtecId) replacedVtecIds.add(vtecId);
377
444
  }
378
445
 
379
446
  const clearedGeometryByInstance = new Map();
380
447
  const clearedMapboxIdByInstance = new Map();
381
448
 
382
- if (replacedBaseIds.size > 0) {
449
+ if (replacedBaseIds.size > 0 || replacedVtecIds.size > 0) {
383
450
  features = features.filter((f) => {
384
451
  const baseId = getNwsBaseAlertIdFromFeature(f);
385
- if (!baseId || !replacedBaseIds.has(baseId)) return true;
452
+ const matchesPrimary = baseId != null && replacedBaseIds.has(baseId);
453
+ const matchesVtec =
454
+ !matchesPrimary &&
455
+ replacedVtecIds.size > 0 &&
456
+ (() => {
457
+ const vtecId = extractNwsVtecStableId(f.properties);
458
+ return vtecId != null && replacedVtecIds.has(vtecId);
459
+ })();
460
+ if (!matchesPrimary && !matchesVtec) return true;
386
461
  const iid = getNwsAlertInstanceIdFromFeature(f) ?? baseId;
387
462
  if (f.geometry) clearedGeometryByInstance.set(iid, f.geometry);
388
463
  if (f.id != null) clearedMapboxIdByInstance.set(iid, f.id);
@@ -392,7 +467,16 @@ export function applyNwwsDeltaToCollection(data, delta) {
392
467
 
393
468
  for (const feat of delta.upserts) {
394
469
  if (!feat || feat.type !== 'Feature') continue;
395
- const enriched = enrichWarningsWithColors({ type: 'FeatureCollection', features: [feat] }).features[0];
470
+ const normalizedFeat = normalizeNwwsHttpAlertFeature(
471
+ {
472
+ type: 'Feature',
473
+ id: feat.id,
474
+ geometry: feat.geometry,
475
+ properties: { ...(feat.properties ?? {}) },
476
+ },
477
+ true
478
+ );
479
+ const enriched = enrichWarningsWithColors({ type: 'FeatureCollection', features: [normalizedFeat] }).features[0];
396
480
  const trackerStatus = getNwwsTrackerStatusFromProperties(enriched?.properties);
397
481
  if (isNwwsTrackerStatusTerminal(trackerStatus)) {
398
482
  const terminalId = getNwsBaseAlertIdFromFeature(enriched);
@@ -456,37 +540,19 @@ export function parseNwwsWsDelta(raw) {
456
540
  const cancelIds = [];
457
541
  const expireIds = [];
458
542
 
459
- const pushUpgradeReplacedIdsFromFeature = (feature) => {
543
+ const pushSupersededIdsFromFeature = (feature) => {
460
544
  const status = getNwwsTrackerStatusFromProperties(feature?.properties);
461
- if (!isNwwsTrackerStatusUpgrade(status)) return;
545
+ if (status === 'cancelled' || status === 'expired') return;
546
+
462
547
  const p = feature?.properties ?? {};
463
- const direct =
464
- p._upgradedFrom ??
465
- p.upgradedFrom ??
466
- p._replaces ??
467
- p.replaces ??
468
- p.replaced_alert_id ??
469
- p.replacedAlertId;
470
- const directNorm = normalizeNwwsAlertId(direct);
471
- if (directNorm) cancelIds.push(directNorm);
472
-
473
- const refs = p.references ?? feature?.references;
474
- if (Array.isArray(refs)) {
475
- for (const r of refs) {
476
- if (r == null) continue;
477
- if (typeof r === 'string') {
478
- const id = normalizeNwwsAlertId(r);
479
- if (id) cancelIds.push(id);
480
- continue;
481
- }
482
- if (typeof r === 'object') {
483
- const ro = r;
484
- const id = normalizeNwwsAlertId(
485
- ro.identifier ?? ro.id ?? ro.alert_id ?? ro.alertId ?? ro.base_alert_id
486
- );
487
- if (id) cancelIds.push(id);
488
- }
489
- }
548
+ const self = new Set();
549
+ const b = getNwsBaseAlertIdFromFeature(feature);
550
+ const inst = getNwsAlertInstanceIdFromFeature(feature);
551
+ if (b) self.add(b);
552
+ if (inst) self.add(inst);
553
+
554
+ for (const id of collectNwwsReplacementReferenceIds(p)) {
555
+ if (!self.has(id)) cancelIds.push(id);
490
556
  }
491
557
  };
492
558
 
@@ -508,7 +574,7 @@ export function parseNwwsWsDelta(raw) {
508
574
  expireIds.push(baseId);
509
575
  return;
510
576
  }
511
- pushUpgradeReplacedIdsFromFeature(obj);
577
+ pushSupersededIdsFromFeature(obj);
512
578
  upserts.push(obj);
513
579
  return;
514
580
  }
@@ -693,6 +759,68 @@ export function filterToSdkAllowedEvents(fc) {
693
759
  return filterToConvectiveSdkEvents(fc);
694
760
  }
695
761
 
762
+ function extractRawNwwsVtecString(properties) {
763
+ if (!properties) return null;
764
+ if (properties.vtec != null) return String(properties.vtec);
765
+ if (properties.VTEC != null) return String(properties.VTEC);
766
+ const details = properties.details;
767
+ if (details?.pvtec != null) return String(details.pvtec);
768
+ const params = properties.parameters;
769
+ if (params != null && typeof params === 'object' && !Array.isArray(params)) {
770
+ const direct = params.VTEC ?? params.vtec;
771
+ if (direct != null) {
772
+ return Array.isArray(direct) ? String(direct[0] ?? '') : String(direct);
773
+ }
774
+ for (const k of Object.keys(params)) {
775
+ if (String(k).toUpperCase() === 'VTEC') {
776
+ const x = params[k];
777
+ if (x != null) return Array.isArray(x) ? String(x[0] ?? '') : String(x);
778
+ }
779
+ }
780
+ }
781
+ return null;
782
+ }
783
+
784
+ function normalizeNwsChainTimeComponent(v) {
785
+ if (v == null || v === '') return null;
786
+ if (typeof v === 'number' && Number.isFinite(v)) {
787
+ return new Date(v > 1e12 ? v : v * 1000).toISOString();
788
+ }
789
+ const s = String(v).trim();
790
+ const ms = Date.parse(s);
791
+ if (!Number.isNaN(ms)) return new Date(ms).toISOString();
792
+ return s;
793
+ }
794
+
795
+ function getNwsOfficeEventIssuedChainId(properties) {
796
+ if (!properties) return null;
797
+ const office = String(properties.office ?? properties.sender_icao ?? properties.sender ?? '').trim();
798
+ const ev = String(properties.event ?? properties.event_name ?? '').trim();
799
+ const rawIssued = getNwsTimeProp(properties, ['issued', 'issued_at']);
800
+ if (!office || !ev || rawIssued == null || rawIssued === '') return null;
801
+ const issuedNorm = normalizeNwsChainTimeComponent(rawIssued);
802
+ if (!issuedNorm) return null;
803
+ return `${office}|${ev}|${issuedNorm}`;
804
+ }
805
+
806
+ /**
807
+ * Extracts a stable VTEC-derived alert ID (e.g. "KWNS.SV.A.0108") from raw feature properties.
808
+ * The VTEC event number is stable across CON/EXT/CAN actions for the same watch/warning,
809
+ * unlike content-based hashes which change on every update.
810
+ */
811
+ function extractNwsVtecStableId(properties) {
812
+ const vtec = extractRawNwwsVtecString(properties);
813
+ if (vtec == null || vtec === '') return null;
814
+ const parts = String(vtec).split('.');
815
+ if (parts.length < 6) return null;
816
+ const office = parts[2]?.trim();
817
+ const ph = parts[3]?.trim();
818
+ const sig = parts[4]?.trim();
819
+ const seq = parts[5]?.trim();
820
+ if (!office || !ph || !sig || !seq) return null;
821
+ return `${office}.${ph}.${sig}.${seq}`;
822
+ }
823
+
696
824
  function getNwsHttpAlertStableId(properties) {
697
825
  if (!properties) return null;
698
826
  const details = properties.details;
@@ -700,6 +828,20 @@ function getNwsHttpAlertStableId(properties) {
700
828
  const tr = details.tracking;
701
829
  if (tr != null && String(tr).trim() !== '') return String(tr).trim();
702
830
  }
831
+ // Same DB key as backend — use if trim drops details/hash (see API trim payloads).
832
+ for (const k of ['nws_stable_id', 'stable_id', 'alert_chain_id']) {
833
+ const v = properties[k];
834
+ if (v != null && String(v).trim() !== '') return String(v).trim();
835
+ }
836
+ if (properties.tracking != null && typeof properties.tracking === 'string' && String(properties.tracking).trim() !== '') {
837
+ return String(properties.tracking).trim();
838
+ }
839
+ // Prefer VTEC-derived ID over hash: VTEC event number is stable across all CON/EXT/UPG
840
+ // actions while the content hash changes on every update, causing phantom duplicates on the map.
841
+ const vtecId = extractNwsVtecStableId(properties);
842
+ if (vtecId) return vtecId;
843
+ const chainId = getNwsOfficeEventIssuedChainId(properties);
844
+ if (chainId) return chainId;
703
845
  const h = properties.hash;
704
846
  if (h != null && String(h).trim() !== '') return String(h).trim();
705
847
  return null;
@@ -733,6 +875,12 @@ export function normalizeNwwsHttpAlertFeature(raw, forLiveStream) {
733
875
  if (fallback) {
734
876
  p.base_alert_id = fallback;
735
877
  p.alert_id = fallback;
878
+ } else {
879
+ const chain = getNwsOfficeEventIssuedChainId(p);
880
+ if (chain) {
881
+ p.base_alert_id = chain;
882
+ p.alert_id = chain;
883
+ }
736
884
  }
737
885
  }
738
886
  const tracker = mapHttpActionToTrackerStatus(p, forLiveStream);