@aguacerowx/mapsgl 0.0.48 → 0.0.50

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.48",
3
+ "version": "0.0.50",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -21,7 +21,7 @@
21
21
  "esbuild": "^0.21.5"
22
22
  },
23
23
  "dependencies": {
24
- "@aguacerowx/javascript-sdk": "^0.0.20",
24
+ "@aguacerowx/javascript-sdk": "^0.0.21",
25
25
  "buffer": "^6.0.3",
26
26
  "fzstd": "^0.1.1",
27
27
  "mapbox-gl": "^3.4.0",
@@ -19,9 +19,12 @@ import {
19
19
  enrichWarningsWithColors,
20
20
  filterNwwsTerminalStatusFeatures,
21
21
  filterNwsAlertsByScope,
22
+ getNwwsProductKeyFromFeature,
22
23
  normalizeFeatureCollectionForMapboxGl,
23
24
  normalizeNwwsHttpAlertFeature,
25
+ nwsRevisionVisibilityActive,
24
26
  parseNwwsWsDelta,
27
+ pickBestNwsRevisionAmongConflictingFeatures,
25
28
  unwrapNwwsFeatureCollectionRoot,
26
29
  } from './nwsAlertsSupport.js';
27
30
 
@@ -131,6 +134,8 @@ export class NwsWatchesWarningsOverlay {
131
134
 
132
135
  /** @type {number | null | undefined} */
133
136
  this._timelineUnix = undefined;
137
+ /** Timeline unix list for the active layer (satellite / MRMS / NEXRAD) — used to match frontend “live edge” behavior. */
138
+ this._timelineTimes = null;
134
139
  }
135
140
 
136
141
  getAlertsUrl() {
@@ -290,9 +295,29 @@ export class NwsWatchesWarningsOverlay {
290
295
  return this._nwsAlertSettings.flashStyles ?? {};
291
296
  }
292
297
 
298
+ _isFollowingLatestTimelineAnchor() {
299
+ const current = this._timelineUnix;
300
+ if (current == null || !Number.isFinite(current)) return false;
301
+ const times = this._timelineTimes;
302
+ if (!Array.isArray(times) || times.length === 0) return false;
303
+ let latest = -Infinity;
304
+ for (const t of times) {
305
+ if (typeof t === 'number' && Number.isFinite(t) && t > latest) {
306
+ latest = t;
307
+ }
308
+ }
309
+ if (!Number.isFinite(latest)) return false;
310
+ return Math.abs(current - latest) <= 1;
311
+ }
312
+
313
+ /**
314
+ * Matches aguacero-frontend {@link WatchesWarningsLayer.resolveNwsDisplayTimeMode}: at the last slider
315
+ * step, use wall clock so SSE/stream updates appear even when the timeline tick lags.
316
+ */
293
317
  _resolveDisplayTimeMode() {
294
318
  const userPinnedRealtime = this._nwsAlertSettings.activeOnlyRealtime === true;
295
- const useRealtime = userPinnedRealtime;
319
+ const followRealtimeAtLatestAnchor = !userPinnedRealtime && this._isFollowingLatestTimelineAnchor();
320
+ const useRealtime = userPinnedRealtime || followRealtimeAtLatestAnchor;
296
321
  return {
297
322
  useRealtime,
298
323
  timelineUnix: useRealtime ? undefined : this._timelineUnix,
@@ -309,35 +334,66 @@ export class NwsWatchesWarningsOverlay {
309
334
  : timeMode.timelineUnix;
310
335
  const useOnsetWindow = this._nwsAlertSettings.activeOnlyRealtime === true;
311
336
 
312
- /**
313
- * Weather-timeline alignment (MRMS / satellite / NEXRAD scrubber): without a valid reference
314
- * time we must not show every alert at full opacity — previously `isActive` defaulted to
315
- * true for all features when `referenceUnix` was still null (race before first timestamp).
316
- */
317
- if (!timeMode.useRealtime && (referenceUnix == null || !Number.isFinite(referenceUnix))) {
318
- for (const f of this._workingFc.features ?? []) {
319
- if (f.id == null) continue;
320
- try {
321
- m.setFeatureState({ source: this._sourceId, id: f.id }, { active: false });
322
- } catch {
323
- /* ignore */
324
- }
325
- }
326
- return;
327
- }
337
+ /** @type {Map<number, boolean>} */
338
+ const tentativeActive = new Map();
328
339
 
329
340
  for (const f of this._workingFc.features ?? []) {
330
341
  if (f.id == null) continue;
342
+ const fid = typeof f.id === 'number' ? f.id : Number(f.id);
343
+ if (!Number.isFinite(fid)) continue;
344
+
331
345
  let isActive = true;
332
346
  if (referenceUnix != null && Number.isFinite(referenceUnix)) {
333
347
  const p = f.properties ?? {};
334
- const startWindow = useOnsetWindow ? (p.active_start_unix ?? p.start_unix) : p.start_unix;
335
- if (startWindow != null && referenceUnix < startWindow) {
336
- isActive = false;
337
- } else if (p.end_unix != null && referenceUnix > p.end_unix) {
348
+ const rev = nwsRevisionVisibilityActive(p, referenceUnix);
349
+ if (rev === false) {
338
350
  isActive = false;
351
+ } else {
352
+ const startWindow = useOnsetWindow ? (p.active_start_unix ?? p.start_unix) : p.start_unix;
353
+ if (startWindow != null && referenceUnix < startWindow) {
354
+ isActive = false;
355
+ } else if (p.end_unix != null && referenceUnix > p.end_unix) {
356
+ isActive = false;
357
+ }
358
+ }
359
+ }
360
+
361
+ tentativeActive.set(fid, isActive);
362
+ }
363
+
364
+ if (referenceUnix != null && Number.isFinite(referenceUnix)) {
365
+ const keyToFeatures = new globalThis.Map();
366
+ for (const f of this._workingFc.features ?? []) {
367
+ if (f.id == null) continue;
368
+ const fid = typeof f.id === 'number' ? f.id : Number(f.id);
369
+ if (!Number.isFinite(fid) || !tentativeActive.get(fid)) continue;
370
+ const pk = getNwwsProductKeyFromFeature(f);
371
+ if (!pk) continue;
372
+ let bucket = keyToFeatures.get(pk);
373
+ if (!bucket) {
374
+ bucket = [];
375
+ keyToFeatures.set(pk, bucket);
339
376
  }
377
+ bucket.push(f);
340
378
  }
379
+ for (const [, group] of keyToFeatures) {
380
+ if (group.length <= 1) continue;
381
+ const winner = pickBestNwsRevisionAmongConflictingFeatures(group, referenceUnix);
382
+ const winId = typeof winner?.id === 'number' ? winner.id : Number(winner?.id);
383
+ for (const f of group) {
384
+ const gfid = typeof f.id === 'number' ? f.id : Number(f.id);
385
+ if (Number.isFinite(gfid) && Number.isFinite(winId) && gfid !== winId) {
386
+ tentativeActive.set(gfid, false);
387
+ }
388
+ }
389
+ }
390
+ }
391
+
392
+ for (const f of this._workingFc.features ?? []) {
393
+ if (f.id == null) continue;
394
+ const fid = typeof f.id === 'number' ? f.id : Number(f.id);
395
+ if (!Number.isFinite(fid)) continue;
396
+ const isActive = tentativeActive.get(fid) ?? true;
341
397
  try {
342
398
  m.setFeatureState({ source: this._sourceId, id: f.id }, { active: isActive });
343
399
  } catch {
@@ -759,10 +815,17 @@ export class NwsWatchesWarningsOverlay {
759
815
 
760
816
  /**
761
817
  * Called by WeatherLayerManager when radar/satellite/MRMS mode is active.
818
+ * @param {object} opts
819
+ * @param {boolean} opts.enabled
820
+ * @param {number | null | undefined} opts.timelineUnix - Active scrubber unix for satellite / MRMS / NEXRAD.
821
+ * @param {number[] | null | undefined} [opts.timelineTimes] - Full timeline for the current layer (for “live edge” = wall clock).
762
822
  */
763
- syncWithMode({ enabled, timelineUnix }) {
823
+ syncWithMode({ enabled, timelineUnix, timelineTimes }) {
764
824
  if (this._destroyed) return;
765
825
  this.options.enabled = !!enabled;
826
+ if (timelineTimes !== undefined) {
827
+ this._timelineTimes = Array.isArray(timelineTimes) ? timelineTimes : null;
828
+ }
766
829
  this.setTimelineUnix(timelineUnix);
767
830
  if (!this.options.enabled) {
768
831
  this._started = false;
@@ -10,48 +10,6 @@ import WorkerPool from './WorkerPool.js';
10
10
 
11
11
  import { DEFAULT_BASIS_BASE_URL } from './defaultBasisBaseUrl.js';
12
12
 
13
- const DEBUG_NS = '[WeatherLayerManager:debug]';
14
-
15
- /**
16
- * Redact secrets for console output.
17
- * @param {object} options
18
- */
19
- function _debugSanitizeOptions(options) {
20
- if (!options || typeof options !== 'object') return options;
21
- const o = { ...options };
22
- if (typeof o.apiKey === 'string' && o.apiKey.length > 0) {
23
- o.apiKey = `${o.apiKey.slice(0, 4)}…(${o.apiKey.length} chars)`;
24
- }
25
- return o;
26
- }
27
-
28
- /**
29
- * Compact summary of timestamp / hour arrays (MRMS timeline debugging).
30
- * @param {unknown[]} arr
31
- */
32
- function _debugSummarizeNumericSeries(arr) {
33
- if (!Array.isArray(arr) || arr.length === 0) {
34
- return { length: 0, min: null, max: null, spanSec: null, head: [], tail: undefined };
35
- }
36
- const nums = arr.map((x) => Number(x)).filter((n) => !Number.isNaN(n));
37
- if (nums.length === 0) {
38
- return { length: 0, min: null, max: null, spanSec: null, head: [], tail: undefined };
39
- }
40
- const sorted = [...nums].sort((a, b) => a - b);
41
- const min = sorted[0];
42
- const max = sorted[sorted.length - 1];
43
- const head = sorted.slice(0, Math.min(8, sorted.length));
44
- const tail = sorted.length > 16 ? sorted.slice(-8) : [];
45
- return {
46
- length: sorted.length,
47
- min,
48
- max,
49
- spanSec: max != null && min != null ? max - min : null,
50
- head,
51
- tail: tail.length ? tail : undefined,
52
- };
53
- }
54
-
55
13
  function findLatestModelRun(modelsData, modelName) {
56
14
  const model = modelsData?.[modelName];
57
15
  if (!model) return null;
@@ -77,7 +35,7 @@ function findLatestModelRun(modelsData, modelName) {
77
35
  * @param {string} [options.belowID] - Style layer id to insert Aguacero weather layers **below** (default `AML_-_terrain` when present). Alias of `weatherBeforeLayerId`.
78
36
  * @param {string} [options.weatherBeforeLayerId] - Same as `belowID`.
79
37
  * @param {string} [options.nexradLayerId] - Override Mapbox id for the NEXRAD custom layer (default: derived from `layerId`).
80
- * @param {boolean} [options.debug] - When `true`, logs detailed diagnostics to the console (prefix `[WeatherLayerManager:debug]`). Off by default.
38
+ * @param {boolean} [options.mrmsTimelineLog] - When `true`, logs MRMS duration / raw vs filtered timeline counts (prefix `[WeatherLayerManager MRMS]`). Off by default.
81
39
  * @param {string} [options.basisBaseUrl] - URL prefix for satellite KTX2 Basis transcoder assets (`basis_transcoder.js`, `basis_transcoder.wasm`). Defaults to jsDelivr for the published package version; override (e.g. `/basis/`) for strict CSP or offline.
82
40
  */
83
41
  export class WeatherLayerManager extends EventEmitter {
@@ -85,10 +43,10 @@ export class WeatherLayerManager extends EventEmitter {
85
43
  super();
86
44
  if (!map) throw new Error('A Mapbox GL map instance is required.');
87
45
  this.map = map;
88
- /** @private When true, emit verbose `[WeatherLayerManager:debug]` logs. */
89
- this._debug = options.debug === true;
90
- /** @private Monotonic counter for correlating state:change logs. */
91
- this._debugStateSeq = 0;
46
+ /** @private MRMS-only timeline diagnostics (duration, raw vs filtered counts). */
47
+ this._mrmsTimelineLog = options.mrmsTimelineLog === true;
48
+ /** @private Dedupes repeated MRMS timeline log lines. */
49
+ this._mrmsLogKey = '';
92
50
  this.layerId =
93
51
  options.layerId ||
94
52
  options.id ||
@@ -159,29 +117,6 @@ export class WeatherLayerManager extends EventEmitter {
159
117
  // 1. CREATE an instance of the core engine
160
118
  this.core = new AguaceroCore(options);
161
119
 
162
- if (this._debug) {
163
- const layerOpts = options.layerOptions || {};
164
- console.log(DEBUG_NS, 'constructor', {
165
- layerId: this.layerId,
166
- nexradLayerId: this._nexradLayerId,
167
- mapLoaded: typeof this.map?.loaded === 'function' ? this.map.loaded() : undefined,
168
- styleLoaded: this.map?.isStyleLoaded?.() ?? undefined,
169
- weatherBeforeLayerId: this._weatherBeforeLayerId,
170
- options: _debugSanitizeOptions(options),
171
- layerOptions: layerOpts,
172
- coreStateSnapshot: {
173
- isMRMS: this.core.state?.isMRMS,
174
- isSatellite: this.core.state?.isSatellite,
175
- isNexrad: this.core.state?.isNexrad,
176
- model: this.core.state?.model,
177
- variable: this.core.state?.variable,
178
- mrmsDurationValue: this.core.state?.mrmsDurationValue,
179
- mrmsTimestamp: this.core.state?.mrmsTimestamp,
180
- satelliteDurationValue: this.core.state?.satelliteDurationValue,
181
- },
182
- });
183
- }
184
-
185
120
  // 2. LISTEN for events from the core engine
186
121
  this.core.on('state:change', (newState) => {
187
122
  this._lastEmittedState = newState;
@@ -202,17 +137,44 @@ export class WeatherLayerManager extends EventEmitter {
202
137
  }
203
138
 
204
139
  /**
205
- * Structured debug log (no-op unless `options.debug === true` on construction).
206
- * @param {string} scope
207
- * @param {object} [data]
140
+ * Logs MRMS timeline facts when `options.mrmsTimelineLog` is enabled (once per distinct snapshot).
141
+ * @param {object} state
208
142
  */
209
- _debugLog(scope, data) {
210
- if (!this._debug) return;
211
- if (data !== undefined) {
212
- console.log(DEBUG_NS, scope, data);
213
- } else {
214
- console.log(DEBUG_NS, scope);
215
- }
143
+ _logMrmsTimelineIfEnabled(state) {
144
+ if (!this._mrmsTimelineLog || !state.isMRMS || !state.variable) return;
145
+ const raw = this.core.mrmsStatus?.[state.variable];
146
+ const rawLen = Array.isArray(raw) ? raw.length : 0;
147
+ const avail = state.availableTimestamps || [];
148
+ const filteredLen = avail.length;
149
+ let spanH = null;
150
+ let minT = null;
151
+ let maxT = null;
152
+ if (filteredLen) {
153
+ const nums = avail.map(Number).filter(Number.isFinite).sort((a, b) => a - b);
154
+ minT = nums[0];
155
+ maxT = nums[nums.length - 1];
156
+ spanH = (maxT - minT) / 3600;
157
+ }
158
+ const key = `${state.variable}|${state.mrmsDurationValue}|${state.mrmsTimestamp}|${rawLen}|${filteredLen}|${minT}|${maxT}`;
159
+ if (key === this._mrmsLogKey) return;
160
+ this._mrmsLogKey = key;
161
+ const rawSpanH =
162
+ rawLen > 1 && Array.isArray(raw)
163
+ ? (Math.max(...raw.map(Number)) - Math.min(...raw.map(Number))) / 3600
164
+ : null;
165
+ console.log('[WeatherLayerManager MRMS]', {
166
+ variable: state.variable,
167
+ durationSettingHours: state.mrmsDurationValue,
168
+ selectedUnix: state.mrmsTimestamp,
169
+ rawStatusScanCount: rawLen,
170
+ rawWallClockSpanHoursApprox: rawSpanH != null ? Number(rawSpanH.toFixed(2)) : null,
171
+ filteredTimelineCount: filteredLen,
172
+ filteredSpanHoursApprox: spanH != null ? Number(spanH.toFixed(2)) : null,
173
+ filteredWindow:
174
+ minT != null && maxT != null ? { minUnix: minT, maxUnix: maxT } : null,
175
+ note:
176
+ 'Timeline cannot extend beyond scans returned by the MRMS status feed; widening "hours" only includes more of that list when older scans exist.',
177
+ });
216
178
  }
217
179
 
218
180
  /**
@@ -261,38 +223,11 @@ export class WeatherLayerManager extends EventEmitter {
261
223
  * @private
262
224
  */
263
225
  _handleStateChange(state) {
264
- const seq = this._debug ? ++this._debugStateSeq : 0;
265
226
  /** NEXRAD setup is async (`sites.show`, frame fetch). `finally` would run before the radar layer exists, so NWS fill stacks above terrain instead of under the custom layer — defer sync until the handler settles. */
266
227
  let deferNwsSyncUntilNexradReady = false;
267
228
  try {
268
- if (this._debug) {
269
- const tsSummary = state.isMRMS
270
- ? _debugSummarizeNumericSeries(state.availableTimestamps || [])
271
- : null;
272
- this._debugLog('state:change', {
273
- seq,
274
- mode: state.isSatellite
275
- ? 'satellite'
276
- : state.isNexrad
277
- ? 'nexrad'
278
- : state.isMRMS
279
- ? 'mrms'
280
- : 'model',
281
- model: state.model,
282
- variable: state.variable,
283
- runKey: `${state.model}-${state.date}-${state.run}-${state.variable}`,
284
- timeKey: state.isMRMS
285
- ? (state.mrmsTimestamp == null ? null : Number(state.mrmsTimestamp))
286
- : Number(state.forecastHour),
287
- mrmsTimestamp: state.mrmsTimestamp,
288
- mrmsDurationValue: state.mrmsDurationValue,
289
- availableTimestampsSummary: tsSummary,
290
- availableHoursLen: Array.isArray(state.availableHours) ? state.availableHours.length : 0,
291
- shaderLayerRunKey: this.shaderLayer?.runKey ?? null,
292
- currentLoadedTimeKey: this.currentLoadedTimeKey,
293
- rebuildId: this.currentRebuildId,
294
- initialGridLoadPending: this._initialGridLoadPending,
295
- });
229
+ if (state.isMRMS) {
230
+ this._logMrmsTimelineIfEnabled(state);
296
231
  }
297
232
 
298
233
  if (state.isSatellite) {
@@ -324,21 +259,6 @@ export class WeatherLayerManager extends EventEmitter {
324
259
  : Number(state.forecastHour);
325
260
  const runKey = `${state.model}-${state.date}-${state.run}-${state.variable}`;
326
261
 
327
- if (this._debug && state.isMRMS) {
328
- this._debugLog('grid path (mrms/model)', {
329
- seq,
330
- prevMrmsDurationValue: prevMrmsDur,
331
- mrmsDurationChanged,
332
- willRebuild: !this.shaderLayer || this.shaderLayer.runKey !== runKey,
333
- willUpdateData:
334
- this.shaderLayer &&
335
- this.shaderLayer.runKey === runKey &&
336
- this.currentLoadedTimeKey !== timeKey,
337
- shaderRunKey: this.shaderLayer?.runKey,
338
- targetRunKey: runKey,
339
- });
340
- }
341
-
342
262
  if (!this.shaderLayer || this.shaderLayer.runKey !== runKey) {
343
263
  this._rebuildLayerAndPreload(state);
344
264
  } else if (this.currentLoadedTimeKey !== timeKey) {
@@ -348,12 +268,6 @@ export class WeatherLayerManager extends EventEmitter {
348
268
  timeKey === this._rebuildTargetTimeKey;
349
269
  if (!duplicateBeforeFirstPaint) {
350
270
  this._updateLayerData(state);
351
- } else if (this._debug) {
352
- this._debugLog('skip _updateLayerData (duplicate before first paint)', {
353
- seq,
354
- timeKey,
355
- _rebuildTargetTimeKey: this._rebuildTargetTimeKey,
356
- });
357
271
  }
358
272
  }
359
273
 
@@ -364,13 +278,6 @@ export class WeatherLayerManager extends EventEmitter {
364
278
  }
365
279
 
366
280
  if (state.isMRMS && this.shaderLayer && mrmsDurationChanged) {
367
- if (this._debug) {
368
- this._debugLog('_preloadAllTimeSteps (mrms duration changed)', {
369
- seq,
370
- from: prevMrmsDur,
371
- to: state.mrmsDurationValue,
372
- });
373
- }
374
281
  this._preloadAllTimeSteps(state);
375
282
  }
376
283
  } finally {
@@ -417,12 +324,17 @@ export class WeatherLayerManager extends EventEmitter {
417
324
  const enabled = masterEnabled && !isModelMode;
418
325
 
419
326
  let timelineUnix = null;
327
+ /** Same list the core uses for the time slider — enables NWS “live edge” (wall clock at latest step) like aguacero-frontend. */
328
+ let timelineTimes = null;
420
329
  if (state.isSatellite) {
421
330
  timelineUnix = state.satelliteTimestamp == null ? null : Number(state.satelliteTimestamp);
331
+ timelineTimes = state.availableSatelliteTimestamps;
422
332
  } else if (state.isNexrad) {
423
333
  timelineUnix = state.nexradTimestamp == null ? null : Number(state.nexradTimestamp);
334
+ timelineTimes = state.availableNexradTimestamps;
424
335
  } else if (state.isMRMS) {
425
336
  timelineUnix = state.mrmsTimestamp == null ? null : Number(state.mrmsTimestamp);
337
+ timelineTimes = state.availableTimestamps;
426
338
  }
427
339
 
428
340
  if (!this._nwsOverlay) {
@@ -452,7 +364,7 @@ export class WeatherLayerManager extends EventEmitter {
452
364
  ...(wopt.activeOnlyRealtime !== undefined ? { activeOnlyRealtime: wopt.activeOnlyRealtime } : {}),
453
365
  },
454
366
  });
455
- this._nwsOverlay.syncWithMode({ enabled, timelineUnix });
367
+ this._nwsOverlay.syncWithMode({ enabled, timelineUnix, timelineTimes });
456
368
  }
457
369
 
458
370
  /**
@@ -570,13 +482,13 @@ export class WeatherLayerManager extends EventEmitter {
570
482
  this.currentLoadedTimeKey = null;
571
483
  }
572
484
 
573
- if (!state.satelliteKey || state.satelliteTimestamp == null) {
485
+ if (!state.satelliteInstrumentId || state.satelliteTimestamp == null) {
574
486
  this._tearDownSatellite();
575
487
  this.map.triggerRepaint();
576
488
  return;
577
489
  }
578
490
 
579
- const satRunKey = `${state.satelliteKey}|${state.variable || ''}`;
491
+ const satRunKey = `${state.satelliteInstrumentId}|${state.satelliteSectorLabel}|${state.satelliteChannel}|${state.variable || ''}`;
580
492
  if (!this.satelliteLayer || this._satelliteRunKey !== satRunKey) {
581
493
  this._rebuildSatelliteLayer(state, satRunKey);
582
494
  }
@@ -668,20 +580,19 @@ export class WeatherLayerManager extends EventEmitter {
668
580
 
669
581
  _satelliteTimelineId(state) {
670
582
  const keys = Object.keys(state.satelliteTimeToFileMap || {}).sort((a, b) => Number(a) - Number(b));
671
- return `${state.satelliteKey}|${state.variable || ''}|${keys.join(',')}`;
583
+ return `${state.satelliteInstrumentId}|${state.satelliteSectorLabel}|${state.satelliteChannel}|${state.variable || ''}|${keys.join(',')}`;
672
584
  }
673
585
 
674
586
  _getSatelliteFetchParts(state, satelliteTimestamp) {
675
587
  const fileName = resolveSatelliteS3FileName(
676
- state.satelliteKey,
588
+ state.satelliteChannel,
677
589
  state.satelliteTimeToFileMap,
678
590
  satelliteTimestamp
679
591
  );
680
592
  if (!fileName) {
681
593
  return null;
682
594
  }
683
- const parts = state.satelliteKey.split('.');
684
- const channelName = parts[2] || state.variable;
595
+ const channelName = state.satelliteChannel || state.variable;
685
596
  const channelNameUpper = String(channelName).toUpperCase();
686
597
  let s3FileName = fileName.replace('MULTI', channelNameUpper);
687
598
  if (!s3FileName.endsWith('.ktx2')) {
@@ -784,68 +695,10 @@ export class WeatherLayerManager extends EventEmitter {
784
695
  // to the core engine. This keeps the API consistent for your users.
785
696
 
786
697
  async initialize(options) {
787
- const t0 =
788
- typeof performance !== 'undefined' && typeof performance.now === 'function'
789
- ? performance.now()
790
- : Date.now();
791
- this._debugLog('initialize:start', {
792
- autoRefreshEnabled: this.autoRefreshEnabled,
793
- autoRefreshIntervalSeconds: this.autoRefreshIntervalSeconds,
794
- passedOptions: options && typeof options === 'object' ? { ...options } : options,
795
- coreStateBefore: {
796
- isMRMS: this.core.state?.isMRMS,
797
- variable: this.core.state?.variable,
798
- mrmsTimestamp: this.core.state?.mrmsTimestamp,
799
- mrmsDurationValue: this.core.state?.mrmsDurationValue,
800
- },
801
- mrmsStatusVariableLen: this.core.state?.variable
802
- ? (this.core.mrmsStatus?.[this.core.state.variable]?.length ?? 'n/a')
803
- : 'n/a',
804
- });
805
698
  if (this.autoRefreshEnabled) {
806
699
  this.setAutoRefresh(true, this.autoRefreshIntervalSeconds);
807
700
  }
808
- try {
809
- const result = await this.core.initialize({ ...options, autoRefresh: false });
810
- const t1 =
811
- typeof performance !== 'undefined' && typeof performance.now === 'function'
812
- ? performance.now()
813
- : Date.now();
814
- this._debugLog('initialize:done', {
815
- elapsedMs: Math.round(t1 - t0),
816
- coreStateAfter: {
817
- isMRMS: this.core.state?.isMRMS,
818
- variable: this.core.state?.variable,
819
- model: this.core.state?.model,
820
- date: this.core.state?.date,
821
- run: this.core.state?.run,
822
- forecastHour: this.core.state?.forecastHour,
823
- mrmsTimestamp: this.core.state?.mrmsTimestamp,
824
- mrmsDurationValue: this.core.state?.mrmsDurationValue,
825
- },
826
- availableTimestampsSummary: _debugSummarizeNumericSeries(
827
- this._lastEmittedState?.availableTimestamps || [],
828
- ),
829
- availableHoursLen: Array.isArray(this._lastEmittedState?.availableHours)
830
- ? this._lastEmittedState.availableHours.length
831
- : 0,
832
- modelStatusLoaded: this.core.modelStatus != null,
833
- mrmsStatusKeys:
834
- this.core.mrmsStatus && typeof this.core.mrmsStatus === 'object'
835
- ? Object.keys(this.core.mrmsStatus).length
836
- : 0,
837
- mrmsStatusVariableLen: this.core.state?.variable
838
- ? (this.core.mrmsStatus?.[this.core.state.variable]?.length ?? 0)
839
- : 0,
840
- });
841
- return result;
842
- } catch (err) {
843
- this._debugLog('initialize:error', {
844
- message: err?.message || String(err),
845
- stack: err?.stack,
846
- });
847
- throw err;
848
- }
701
+ return this.core.initialize({ ...options, autoRefresh: false });
849
702
  }
850
703
  async setState(newState) { return this.core.setState(newState); }
851
704
  play() { this.core.play(); }
@@ -871,9 +724,15 @@ export class WeatherLayerManager extends EventEmitter {
871
724
  async setSatelliteDurationValue(value) {
872
725
  return this.core.setSatelliteDurationValue(value);
873
726
  }
727
+ async setSatelliteSelection(opts) {
728
+ return this.core.setSatelliteSelection(opts);
729
+ }
874
730
  async setMRMSDurationValue(value) {
875
731
  return this.core.setMRMSDurationValue(value);
876
732
  }
733
+ async setNexradDurationValue(value) {
734
+ return this.core.setNexradDurationValue(value);
735
+ }
877
736
 
878
737
  /** Refreshes NEXRAD object-key listings for the current site / product / tilt (NEXRAD mode only). */
879
738
  async refreshNexradTimes() {
@@ -977,17 +836,6 @@ export class WeatherLayerManager extends EventEmitter {
977
836
  const out = (timeSteps || [])
978
837
  .map(t => Number(t))
979
838
  .filter(t => !Number.isNaN(t));
980
- if (this._debug && state.isMRMS) {
981
- this._debugLog('_collectNormalizedTimelineSteps', {
982
- variable: state.variable,
983
- mrmsDurationValue: state.mrmsDurationValue,
984
- fromStateLen: fromState.length,
985
- fromCoreLen: fromCore.length,
986
- chosenSource: mrmsTimelineSource || (state.isMRMS ? 'n/a' : 'model'),
987
- normalizedLen: out.length,
988
- summary: _debugSummarizeNumericSeries(out),
989
- });
990
- }
991
839
  return out;
992
840
  }
993
841
 
@@ -1002,50 +850,27 @@ export class WeatherLayerManager extends EventEmitter {
1002
850
  if (mode === 'rebuild') {
1003
851
  this._initialGridLoadPending = false;
1004
852
  }
1005
- this._debugLog('_runParallelGridFrameLoads:skip', {
1006
- reason: !times.length ? 'no times' : 'no shaderLayer',
1007
- mode,
1008
- rebuildId,
1009
- timesLen: times.length,
1010
- });
1011
853
  return;
1012
854
  }
1013
855
 
1014
- // Number(null) === 0, which is falsy and causes _loadGridData to return null immediately
1015
- // (the core guards with `if (!mrmsTimestamp) return null`). Treat null as NaN so that
1016
- // primaryTimeForRebuild falls through to times[0] the first real MRMS timestamp.
856
+ // Number(null) === 0 treat null as NaN. For MRMS, when the active timestep is unset,
857
+ // prefer the **newest** scan in the timeline (matches AguaceroCore default); model grids
858
+ // keep using the first forecast hour in the list.
1017
859
  const _rawFrameTime = state.isMRMS ? state.mrmsTimestamp : state.forecastHour;
1018
860
  const currentFrameTime = _rawFrameTime == null ? NaN : Number(_rawFrameTime);
1019
- /** When the active timestep is unset/NaN, paint the first timeline step (legacy single-fetch behavior). */
1020
861
  let primaryTimeForRebuild = Number.NaN;
1021
862
  if (mode === 'rebuild') {
1022
- primaryTimeForRebuild = Number.isFinite(currentFrameTime)
1023
- ? currentFrameTime
1024
- : times[0];
863
+ if (Number.isFinite(currentFrameTime)) {
864
+ primaryTimeForRebuild = currentFrameTime;
865
+ } else if (times.length > 0) {
866
+ const finite = times.map(Number).filter(Number.isFinite);
867
+ primaryTimeForRebuild = state.isMRMS ? Math.max(...finite) : Number(times[0]);
868
+ }
1025
869
  }
1026
870
  const tsKey = state.isMRMS ? 'mrmsTimestamp' : 'forecastHour';
1027
871
  const gridModel = state.isMRMS ? 'mrms' : state.model;
1028
872
  const { gridDef } = this.core._getGridCornersAndDef(gridModel);
1029
873
 
1030
- this._debugLog('_runParallelGridFrameLoads', {
1031
- mode,
1032
- rebuildId,
1033
- gridModel,
1034
- timesLen: times.length,
1035
- timesSummary: _debugSummarizeNumericSeries(times),
1036
- currentFrameTime,
1037
- primaryTimeForRebuild:
1038
- mode === 'rebuild'
1039
- ? Number.isFinite(currentFrameTime)
1040
- ? currentFrameTime
1041
- : times[0]
1042
- : undefined,
1043
- tsKey,
1044
- gridNxNy: gridDef?.grid_params
1045
- ? { nx: gridDef.grid_params.nx, ny: gridDef.grid_params.ny }
1046
- : null,
1047
- });
1048
-
1049
874
  times.forEach((time) => {
1050
875
  const stateForTime = { ...state, [tsKey]: time };
1051
876
  this.core._loadGridData(stateForTime)
@@ -1074,21 +899,10 @@ export class WeatherLayerManager extends EventEmitter {
1074
899
  : Number(this.core.state.mrmsTimestamp))
1075
900
  : Number(this.core.state.forecastHour);
1076
901
  if (coreTimeKey !== this._rebuildTargetTimeKey) {
1077
- this._debugLog('_loadGridData:skip primary (core time drifted vs rebuild target)', {
1078
- time,
1079
- coreTimeKey,
1080
- _rebuildTargetTimeKey: this._rebuildTargetTimeKey,
1081
- rebuildId,
1082
- });
1083
902
  return;
1084
903
  }
1085
904
  }
1086
905
  if (!grid?.data) {
1087
- this._debugLog('_loadGridData:primary frame missing grid.data', {
1088
- time,
1089
- rebuildId,
1090
- mode,
1091
- });
1092
906
  this._initialGridLoadPending = false;
1093
907
  return;
1094
908
  }
@@ -1121,17 +935,9 @@ export class WeatherLayerManager extends EventEmitter {
1121
935
  this.currentLoadedTimeKey = time;
1122
936
  this.map.triggerRepaint();
1123
937
  }
1124
- } else if (this._debug && mode === 'append') {
1125
- this._debugLog('_loadGridData:empty grid (append)', { time, mode, rebuildId });
1126
938
  }
1127
939
  })
1128
- .catch((err) => {
1129
- this._debugLog('_loadGridData:error', {
1130
- time,
1131
- mode,
1132
- rebuildId,
1133
- message: err?.message || String(err),
1134
- });
940
+ .catch(() => {
1135
941
  if (
1136
942
  mode === 'rebuild' &&
1137
943
  Number.isFinite(primaryTimeForRebuild) &&
@@ -1209,26 +1015,8 @@ export class WeatherLayerManager extends EventEmitter {
1209
1015
  if (timesToLoad.length === 0 && !Number.isNaN(currentFrameTime)) {
1210
1016
  timesToLoad = [currentFrameTime];
1211
1017
  }
1212
- this._debugLog('_rebuildLayerAndPreload:timeline', {
1213
- rebuildId,
1214
- isMRMS: state.isMRMS,
1215
- variable: state.variable,
1216
- normalizedLen: normalized.length,
1217
- normalizedSummary: _debugSummarizeNumericSeries(normalized),
1218
- currentFrameTime,
1219
- timesToLoadLen: timesToLoad.length,
1220
- timesToLoadSummary: _debugSummarizeNumericSeries(timesToLoad),
1221
- insertBeforeId: beforeId ?? '(stack top)',
1222
- });
1223
1018
  if (timesToLoad.length === 0) {
1224
1019
  this._initialGridLoadPending = false;
1225
- this._debugLog('_rebuildLayerAndPreload:no times to load — check availableTimestamps / duration window', {
1226
- rebuildId,
1227
- availableTimestampsLen: Array.isArray(state.availableTimestamps)
1228
- ? state.availableTimestamps.length
1229
- : 0,
1230
- mrmsDurationValue: state.mrmsDurationValue,
1231
- });
1232
1020
  return;
1233
1021
  }
1234
1022
 
@@ -1243,21 +1031,11 @@ export class WeatherLayerManager extends EventEmitter {
1243
1031
  const currentFrameTime = _rawPft == null ? NaN : Number(_rawPft);
1244
1032
  const stepsToPreload = normalized.filter(t => t !== currentFrameTime);
1245
1033
 
1246
- this._debugLog('_preloadAllTimeSteps', {
1247
- normalizedLen: normalized.length,
1248
- currentFrameTime,
1249
- stepsToPreloadLen: stepsToPreload.length,
1250
- stepsSummary: _debugSummarizeNumericSeries(stepsToPreload),
1251
- });
1252
-
1253
1034
  if (normalized.length === 0) {
1254
1035
  return;
1255
1036
  }
1256
1037
 
1257
1038
  if (stepsToPreload.length === 0) {
1258
- this._debugLog('_preloadAllTimeSteps:skip (nothing to preload besides current)', {
1259
- currentFrameTime,
1260
- });
1261
1039
  return;
1262
1040
  }
1263
1041
 
@@ -1389,7 +1167,7 @@ export class WeatherLayerManager extends EventEmitter {
1389
1167
  const s = this.core.state;
1390
1168
  const { isMRMS, isSatellite, isNexrad, model: currentModel, variable: currentVariable, date, run } = s;
1391
1169
 
1392
- if (isSatellite && s.satelliteKey) {
1170
+ if (isSatellite && s.satelliteInstrumentId) {
1393
1171
  const prevTimeline = this.core._computeSatelliteTimeline();
1394
1172
  const prevTimes = [...(prevTimeline.unixTimes || [])]
1395
1173
  .map((t) => Number(t))
@@ -1413,7 +1191,12 @@ export class WeatherLayerManager extends EventEmitter {
1413
1191
  } else if (curSat != null && newMax != null && nextTimes.length && !nextTimes.includes(curSat)) {
1414
1192
  await this.core.setSatelliteTimestamp(newMax);
1415
1193
  }
1416
- this.emit('data:updated', { type: 'satellite', satelliteKey: s.satelliteKey });
1194
+ this.emit('data:updated', {
1195
+ type: 'satellite',
1196
+ satelliteInstrumentId: s.satelliteInstrumentId,
1197
+ satelliteSectorLabel: s.satelliteSectorLabel,
1198
+ satelliteChannel: s.satelliteChannel,
1199
+ });
1417
1200
  return;
1418
1201
  }
1419
1202
 
@@ -1499,7 +1282,6 @@ export class WeatherLayerManager extends EventEmitter {
1499
1282
  * Cleans up all resources.
1500
1283
  */
1501
1284
  destroy() {
1502
- this._debugLog('destroy');
1503
1285
  this.setAutoRefresh(false);
1504
1286
 
1505
1287
  // 1. Unbind the map's mousemove event listener
@@ -1,40 +1,11 @@
1
- import pkg from '../package.json' with { type: 'json' };
2
-
3
1
  /**
4
2
  * jsDelivr serves the `basis/` folder from the published npm package (no `public/basis/` copy).
5
3
  * Use when you consume `@aguacerowx/mapsgl` from the registry and do not host transcoder assets yourself.
6
4
  */
7
- export const JSDELIVR_BASIS_BASE_URL = `https://cdn.jsdelivr.net/npm/@aguacerowx/mapsgl@${pkg.version}/basis/`;
5
+ export const JSDELIVR_BASIS_BASE_URL = `https://cdn.jsdelivr.net/npm/@aguacerowx/mapsgl/basis/`;
8
6
 
9
7
  /**
10
- * Default: resolve `mapsgl/basis/` inside this package.
11
- *
12
- * We cannot use `new URL('..', new URL('.', import.meta.url))`: Vite aliases `@aguacerowx/mapsgl` to
13
- * `mapsgl/index.js`, so the "directory" of this module is often `.../mapsgl/` and `..` becomes the
14
- * monorepo `packages/` folder — wrong (`packages/basis/` instead of `packages/mapsgl/basis/`).
15
- * Webpack also rejects `new URL('../basis/', import.meta.url)` as an unresolved module, so we locate
16
- * the `mapsgl` path segment and append `basis/`.
17
- *
18
- * In Vite production builds the folder URL becomes your app origin `/basis/`, so place copies under
19
- * `public/basis/` (or sync from `node_modules/@aguacerowx/mapsgl/basis`).
20
- * For CDN-only, pass {@link JSDELIVR_BASIS_BASE_URL} as `WeatherLayerManager({ basisBaseUrl: ... })`.
8
+ * Default to the jsDelivr CDN so users don't have to host the files themselves.
9
+ * You can override this by passing `basisBaseUrl` to `WeatherLayerManager`.
21
10
  */
22
- const _mapsglRootHref = (() => {
23
- const u = import.meta.url;
24
- const slashIdx = u.indexOf('/mapsgl/');
25
- if (slashIdx >= 0) {
26
- return u.slice(0, slashIdx + '/mapsgl/'.length);
27
- }
28
- // e.g. cdn.jsdelivr.net/.../mapsgl@0.0.47/dist/... (no `/mapsgl/` substring)
29
- const atMatch = u.match(/\/mapsgl@[^/]+\//);
30
- if (atMatch && atMatch.index !== undefined) {
31
- return u.slice(0, atMatch.index + atMatch[0].length);
32
- }
33
- const srcMarker = '/src/';
34
- const j = u.lastIndexOf(srcMarker);
35
- if (j >= 0) {
36
- return `${u.slice(0, j)}/`;
37
- }
38
- return new URL('../', new URL('.', u)).href;
39
- })();
40
- export const DEFAULT_BASIS_BASE_URL = new URL('basis/', _mapsglRootHref).href;
11
+ export const DEFAULT_BASIS_BASE_URL = JSDELIVR_BASIS_BASE_URL;
@@ -442,6 +442,98 @@ export function getNwsAlertInstanceIdFromFeature(feature) {
442
442
  );
443
443
  }
444
444
 
445
+ /**
446
+ * SVR revision visibility: superseded geometry only before cutover unix; updated geometry only at/after cutover.
447
+ * Returns null when this feature does not use revision split (fall back to start_unix / end_unix).
448
+ * Matches aguacero-frontend {@link nwsRevisionVisibilityActive}.
449
+ */
450
+ export function nwsRevisionVisibilityActive(properties, referenceUnix) {
451
+ if (!properties) return null;
452
+ const role = properties._nws_revision_role;
453
+ const cut = properties._nws_revision_cutover_unix;
454
+ if (cut == null || typeof cut !== 'number' || !role) return null;
455
+ if (role === 'superseded') {
456
+ return referenceUnix < cut;
457
+ }
458
+ if (role === 'update') {
459
+ return referenceUnix >= cut;
460
+ }
461
+ if (role === 'current') {
462
+ return null;
463
+ }
464
+ return null;
465
+ }
466
+
467
+ /** nwws-server stores ids as `${vtecBaseId}.${issuedTs}`; strip the issued suffix for revision grouping. */
468
+ function stripNwwsIssuedMillisSuffixFromId(id) {
469
+ const m = String(id).match(/^(.+)\.(\d{10,})$/);
470
+ return m ? m[1] : id;
471
+ }
472
+
473
+ /**
474
+ * One key per logical warning product (VTEC sequence / base id). Matches aguacero-frontend
475
+ * {@link getNwwsLogicalProductKey} for deduping superseded vs update rows on the time slider.
476
+ */
477
+ export function getNwwsLogicalProductKey(feature) {
478
+ const p = feature?.properties ?? {};
479
+ const params = p.parameters;
480
+ const vtecRaw = params != null && typeof params === 'object' && !Array.isArray(params) ? params.VTEC : undefined;
481
+ const vtec = Array.isArray(vtecRaw)
482
+ ? vtecRaw[0]
483
+ : p.vtec ?? (p.details && typeof p.details === 'object' ? p.details.pvtec : undefined);
484
+ if (typeof vtec === 'string' && vtec.trim()) {
485
+ const parts = vtec.split('.');
486
+ if (parts.length >= 6) {
487
+ return `${parts[2]}.${parts[3]}.${parts[4]}.${parts[5]}`;
488
+ }
489
+ }
490
+ const base = getNwsBaseAlertIdFromFeature(feature);
491
+ if (base) return stripNwwsIssuedMillisSuffixFromId(base);
492
+ if (feature?.id != null && feature.id !== '') return stripNwwsIssuedMillisSuffixFromId(String(feature.id));
493
+ return null;
494
+ }
495
+
496
+ export function getNwwsProductKeyFromFeature(feature) {
497
+ const pk = feature?.properties?.nws_product_key;
498
+ if (typeof pk === 'string' && pk.trim() !== '') return pk.trim();
499
+ return getNwwsLogicalProductKey(feature);
500
+ }
501
+
502
+ /**
503
+ * When superseded + update rows both pass time checks, pick one per reference instant (same product key).
504
+ * Matches aguacero-frontend {@link pickBestNwsRevisionAmongConflictingFeatures}.
505
+ */
506
+ export function pickBestNwsRevisionAmongConflictingFeatures(features, referenceUnix) {
507
+ if (!Array.isArray(features) || features.length === 0) return null;
508
+ if (features.length === 1) return features[0];
509
+ const scored = features.map((f) => {
510
+ const p = f?.properties ?? {};
511
+ const role = String(p._nws_revision_role ?? '');
512
+ const cut =
513
+ typeof p._nws_revision_cutover_unix === 'number' && Number.isFinite(p._nws_revision_cutover_unix)
514
+ ? p._nws_revision_cutover_unix
515
+ : null;
516
+ let slotScore = 0;
517
+ if (role === 'superseded' && cut != null) {
518
+ slotScore = referenceUnix < cut ? 2 : 0;
519
+ } else if (role === 'update' && cut != null) {
520
+ slotScore = referenceUnix >= cut ? 2 : 0;
521
+ } else if (role === 'current') {
522
+ slotScore = 1;
523
+ } else {
524
+ slotScore = 1;
525
+ }
526
+ const t =
527
+ parseNwsTimeToUnix(getNwsTimeProp(p, ['sent', 'updated_at', 'issued_at', 'issued'])) ?? 0;
528
+ return { f, slotScore, t };
529
+ });
530
+ scored.sort((a, b) => {
531
+ if (b.slotScore !== a.slotScore) return b.slotScore - a.slotScore;
532
+ return b.t - a.t;
533
+ });
534
+ return scored[0].f;
535
+ }
536
+
445
537
  export function isUsableNwwsAlertGeometry(geometry) {
446
538
  if (geometry == null || typeof geometry !== 'object') return false;
447
539
  const g = geometry;