@aguacerowx/mapsgl 0.0.49 → 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.49",
3
+ "version": "0.0.50",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -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,30 +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
- nexradDurationValue: this.core.state?.nexradDurationValue,
181
- satelliteDurationValue: this.core.state?.satelliteDurationValue,
182
- },
183
- });
184
- }
185
-
186
120
  // 2. LISTEN for events from the core engine
187
121
  this.core.on('state:change', (newState) => {
188
122
  this._lastEmittedState = newState;
@@ -203,17 +137,44 @@ export class WeatherLayerManager extends EventEmitter {
203
137
  }
204
138
 
205
139
  /**
206
- * Structured debug log (no-op unless `options.debug === true` on construction).
207
- * @param {string} scope
208
- * @param {object} [data]
140
+ * Logs MRMS timeline facts when `options.mrmsTimelineLog` is enabled (once per distinct snapshot).
141
+ * @param {object} state
209
142
  */
210
- _debugLog(scope, data) {
211
- if (!this._debug) return;
212
- if (data !== undefined) {
213
- console.log(DEBUG_NS, scope, data);
214
- } else {
215
- console.log(DEBUG_NS, scope);
216
- }
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
+ });
217
178
  }
218
179
 
219
180
  /**
@@ -262,38 +223,11 @@ export class WeatherLayerManager extends EventEmitter {
262
223
  * @private
263
224
  */
264
225
  _handleStateChange(state) {
265
- const seq = this._debug ? ++this._debugStateSeq : 0;
266
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. */
267
227
  let deferNwsSyncUntilNexradReady = false;
268
228
  try {
269
- if (this._debug) {
270
- const tsSummary = state.isMRMS
271
- ? _debugSummarizeNumericSeries(state.availableTimestamps || [])
272
- : null;
273
- this._debugLog('state:change', {
274
- seq,
275
- mode: state.isSatellite
276
- ? 'satellite'
277
- : state.isNexrad
278
- ? 'nexrad'
279
- : state.isMRMS
280
- ? 'mrms'
281
- : 'model',
282
- model: state.model,
283
- variable: state.variable,
284
- runKey: `${state.model}-${state.date}-${state.run}-${state.variable}`,
285
- timeKey: state.isMRMS
286
- ? (state.mrmsTimestamp == null ? null : Number(state.mrmsTimestamp))
287
- : Number(state.forecastHour),
288
- mrmsTimestamp: state.mrmsTimestamp,
289
- mrmsDurationValue: state.mrmsDurationValue,
290
- availableTimestampsSummary: tsSummary,
291
- availableHoursLen: Array.isArray(state.availableHours) ? state.availableHours.length : 0,
292
- shaderLayerRunKey: this.shaderLayer?.runKey ?? null,
293
- currentLoadedTimeKey: this.currentLoadedTimeKey,
294
- rebuildId: this.currentRebuildId,
295
- initialGridLoadPending: this._initialGridLoadPending,
296
- });
229
+ if (state.isMRMS) {
230
+ this._logMrmsTimelineIfEnabled(state);
297
231
  }
298
232
 
299
233
  if (state.isSatellite) {
@@ -325,21 +259,6 @@ export class WeatherLayerManager extends EventEmitter {
325
259
  : Number(state.forecastHour);
326
260
  const runKey = `${state.model}-${state.date}-${state.run}-${state.variable}`;
327
261
 
328
- if (this._debug && state.isMRMS) {
329
- this._debugLog('grid path (mrms/model)', {
330
- seq,
331
- prevMrmsDurationValue: prevMrmsDur,
332
- mrmsDurationChanged,
333
- willRebuild: !this.shaderLayer || this.shaderLayer.runKey !== runKey,
334
- willUpdateData:
335
- this.shaderLayer &&
336
- this.shaderLayer.runKey === runKey &&
337
- this.currentLoadedTimeKey !== timeKey,
338
- shaderRunKey: this.shaderLayer?.runKey,
339
- targetRunKey: runKey,
340
- });
341
- }
342
-
343
262
  if (!this.shaderLayer || this.shaderLayer.runKey !== runKey) {
344
263
  this._rebuildLayerAndPreload(state);
345
264
  } else if (this.currentLoadedTimeKey !== timeKey) {
@@ -349,12 +268,6 @@ export class WeatherLayerManager extends EventEmitter {
349
268
  timeKey === this._rebuildTargetTimeKey;
350
269
  if (!duplicateBeforeFirstPaint) {
351
270
  this._updateLayerData(state);
352
- } else if (this._debug) {
353
- this._debugLog('skip _updateLayerData (duplicate before first paint)', {
354
- seq,
355
- timeKey,
356
- _rebuildTargetTimeKey: this._rebuildTargetTimeKey,
357
- });
358
271
  }
359
272
  }
360
273
 
@@ -365,13 +278,6 @@ export class WeatherLayerManager extends EventEmitter {
365
278
  }
366
279
 
367
280
  if (state.isMRMS && this.shaderLayer && mrmsDurationChanged) {
368
- if (this._debug) {
369
- this._debugLog('_preloadAllTimeSteps (mrms duration changed)', {
370
- seq,
371
- from: prevMrmsDur,
372
- to: state.mrmsDurationValue,
373
- });
374
- }
375
281
  this._preloadAllTimeSteps(state);
376
282
  }
377
283
  } finally {
@@ -418,12 +324,17 @@ export class WeatherLayerManager extends EventEmitter {
418
324
  const enabled = masterEnabled && !isModelMode;
419
325
 
420
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;
421
329
  if (state.isSatellite) {
422
330
  timelineUnix = state.satelliteTimestamp == null ? null : Number(state.satelliteTimestamp);
331
+ timelineTimes = state.availableSatelliteTimestamps;
423
332
  } else if (state.isNexrad) {
424
333
  timelineUnix = state.nexradTimestamp == null ? null : Number(state.nexradTimestamp);
334
+ timelineTimes = state.availableNexradTimestamps;
425
335
  } else if (state.isMRMS) {
426
336
  timelineUnix = state.mrmsTimestamp == null ? null : Number(state.mrmsTimestamp);
337
+ timelineTimes = state.availableTimestamps;
427
338
  }
428
339
 
429
340
  if (!this._nwsOverlay) {
@@ -453,7 +364,7 @@ export class WeatherLayerManager extends EventEmitter {
453
364
  ...(wopt.activeOnlyRealtime !== undefined ? { activeOnlyRealtime: wopt.activeOnlyRealtime } : {}),
454
365
  },
455
366
  });
456
- this._nwsOverlay.syncWithMode({ enabled, timelineUnix });
367
+ this._nwsOverlay.syncWithMode({ enabled, timelineUnix, timelineTimes });
457
368
  }
458
369
 
459
370
  /**
@@ -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(); }
@@ -983,17 +836,6 @@ export class WeatherLayerManager extends EventEmitter {
983
836
  const out = (timeSteps || [])
984
837
  .map(t => Number(t))
985
838
  .filter(t => !Number.isNaN(t));
986
- if (this._debug && state.isMRMS) {
987
- this._debugLog('_collectNormalizedTimelineSteps', {
988
- variable: state.variable,
989
- mrmsDurationValue: state.mrmsDurationValue,
990
- fromStateLen: fromState.length,
991
- fromCoreLen: fromCore.length,
992
- chosenSource: mrmsTimelineSource || (state.isMRMS ? 'n/a' : 'model'),
993
- normalizedLen: out.length,
994
- summary: _debugSummarizeNumericSeries(out),
995
- });
996
- }
997
839
  return out;
998
840
  }
999
841
 
@@ -1008,50 +850,27 @@ export class WeatherLayerManager extends EventEmitter {
1008
850
  if (mode === 'rebuild') {
1009
851
  this._initialGridLoadPending = false;
1010
852
  }
1011
- this._debugLog('_runParallelGridFrameLoads:skip', {
1012
- reason: !times.length ? 'no times' : 'no shaderLayer',
1013
- mode,
1014
- rebuildId,
1015
- timesLen: times.length,
1016
- });
1017
853
  return;
1018
854
  }
1019
855
 
1020
- // Number(null) === 0, which is falsy and causes _loadGridData to return null immediately
1021
- // (the core guards with `if (!mrmsTimestamp) return null`). Treat null as NaN so that
1022
- // 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.
1023
859
  const _rawFrameTime = state.isMRMS ? state.mrmsTimestamp : state.forecastHour;
1024
860
  const currentFrameTime = _rawFrameTime == null ? NaN : Number(_rawFrameTime);
1025
- /** When the active timestep is unset/NaN, paint the first timeline step (legacy single-fetch behavior). */
1026
861
  let primaryTimeForRebuild = Number.NaN;
1027
862
  if (mode === 'rebuild') {
1028
- primaryTimeForRebuild = Number.isFinite(currentFrameTime)
1029
- ? currentFrameTime
1030
- : 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
+ }
1031
869
  }
1032
870
  const tsKey = state.isMRMS ? 'mrmsTimestamp' : 'forecastHour';
1033
871
  const gridModel = state.isMRMS ? 'mrms' : state.model;
1034
872
  const { gridDef } = this.core._getGridCornersAndDef(gridModel);
1035
873
 
1036
- this._debugLog('_runParallelGridFrameLoads', {
1037
- mode,
1038
- rebuildId,
1039
- gridModel,
1040
- timesLen: times.length,
1041
- timesSummary: _debugSummarizeNumericSeries(times),
1042
- currentFrameTime,
1043
- primaryTimeForRebuild:
1044
- mode === 'rebuild'
1045
- ? Number.isFinite(currentFrameTime)
1046
- ? currentFrameTime
1047
- : times[0]
1048
- : undefined,
1049
- tsKey,
1050
- gridNxNy: gridDef?.grid_params
1051
- ? { nx: gridDef.grid_params.nx, ny: gridDef.grid_params.ny }
1052
- : null,
1053
- });
1054
-
1055
874
  times.forEach((time) => {
1056
875
  const stateForTime = { ...state, [tsKey]: time };
1057
876
  this.core._loadGridData(stateForTime)
@@ -1080,21 +899,10 @@ export class WeatherLayerManager extends EventEmitter {
1080
899
  : Number(this.core.state.mrmsTimestamp))
1081
900
  : Number(this.core.state.forecastHour);
1082
901
  if (coreTimeKey !== this._rebuildTargetTimeKey) {
1083
- this._debugLog('_loadGridData:skip primary (core time drifted vs rebuild target)', {
1084
- time,
1085
- coreTimeKey,
1086
- _rebuildTargetTimeKey: this._rebuildTargetTimeKey,
1087
- rebuildId,
1088
- });
1089
902
  return;
1090
903
  }
1091
904
  }
1092
905
  if (!grid?.data) {
1093
- this._debugLog('_loadGridData:primary frame missing grid.data', {
1094
- time,
1095
- rebuildId,
1096
- mode,
1097
- });
1098
906
  this._initialGridLoadPending = false;
1099
907
  return;
1100
908
  }
@@ -1127,17 +935,9 @@ export class WeatherLayerManager extends EventEmitter {
1127
935
  this.currentLoadedTimeKey = time;
1128
936
  this.map.triggerRepaint();
1129
937
  }
1130
- } else if (this._debug && mode === 'append') {
1131
- this._debugLog('_loadGridData:empty grid (append)', { time, mode, rebuildId });
1132
938
  }
1133
939
  })
1134
- .catch((err) => {
1135
- this._debugLog('_loadGridData:error', {
1136
- time,
1137
- mode,
1138
- rebuildId,
1139
- message: err?.message || String(err),
1140
- });
940
+ .catch(() => {
1141
941
  if (
1142
942
  mode === 'rebuild' &&
1143
943
  Number.isFinite(primaryTimeForRebuild) &&
@@ -1215,26 +1015,8 @@ export class WeatherLayerManager extends EventEmitter {
1215
1015
  if (timesToLoad.length === 0 && !Number.isNaN(currentFrameTime)) {
1216
1016
  timesToLoad = [currentFrameTime];
1217
1017
  }
1218
- this._debugLog('_rebuildLayerAndPreload:timeline', {
1219
- rebuildId,
1220
- isMRMS: state.isMRMS,
1221
- variable: state.variable,
1222
- normalizedLen: normalized.length,
1223
- normalizedSummary: _debugSummarizeNumericSeries(normalized),
1224
- currentFrameTime,
1225
- timesToLoadLen: timesToLoad.length,
1226
- timesToLoadSummary: _debugSummarizeNumericSeries(timesToLoad),
1227
- insertBeforeId: beforeId ?? '(stack top)',
1228
- });
1229
1018
  if (timesToLoad.length === 0) {
1230
1019
  this._initialGridLoadPending = false;
1231
- this._debugLog('_rebuildLayerAndPreload:no times to load — check availableTimestamps / duration window', {
1232
- rebuildId,
1233
- availableTimestampsLen: Array.isArray(state.availableTimestamps)
1234
- ? state.availableTimestamps.length
1235
- : 0,
1236
- mrmsDurationValue: state.mrmsDurationValue,
1237
- });
1238
1020
  return;
1239
1021
  }
1240
1022
 
@@ -1249,21 +1031,11 @@ export class WeatherLayerManager extends EventEmitter {
1249
1031
  const currentFrameTime = _rawPft == null ? NaN : Number(_rawPft);
1250
1032
  const stepsToPreload = normalized.filter(t => t !== currentFrameTime);
1251
1033
 
1252
- this._debugLog('_preloadAllTimeSteps', {
1253
- normalizedLen: normalized.length,
1254
- currentFrameTime,
1255
- stepsToPreloadLen: stepsToPreload.length,
1256
- stepsSummary: _debugSummarizeNumericSeries(stepsToPreload),
1257
- });
1258
-
1259
1034
  if (normalized.length === 0) {
1260
1035
  return;
1261
1036
  }
1262
1037
 
1263
1038
  if (stepsToPreload.length === 0) {
1264
- this._debugLog('_preloadAllTimeSteps:skip (nothing to preload besides current)', {
1265
- currentFrameTime,
1266
- });
1267
1039
  return;
1268
1040
  }
1269
1041
 
@@ -1510,7 +1282,6 @@ export class WeatherLayerManager extends EventEmitter {
1510
1282
  * Cleans up all resources.
1511
1283
  */
1512
1284
  destroy() {
1513
- this._debugLog('destroy');
1514
1285
  this.setAutoRefresh(false);
1515
1286
 
1516
1287
  // 1. Unbind the map's mousemove event listener
@@ -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;