@aguacerowx/mapsgl 0.0.31 → 0.0.41

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.
Files changed (33) hide show
  1. package/index.js +34 -2
  2. package/package.json +13 -3
  3. package/src/GridRenderLayer.js +105 -86
  4. package/src/MapManager.js +47 -15
  5. package/src/NexradSitesOverlay.js +148 -0
  6. package/src/NexradWeatherController.js +491 -0
  7. package/src/NwsWatchesWarningsOverlay.js +768 -0
  8. package/src/SatelliteShaderManager.js +999 -0
  9. package/src/WeatherLayerManager.js +800 -110
  10. package/src/WorkerPool.js +340 -0
  11. package/src/nexrad/MapboxRadarLayer.bundled.js +810 -0
  12. package/src/nexrad/MapboxRadarLayer.ts +784 -0
  13. package/src/nexrad/PreprocessedSweepParser.ts +226 -0
  14. package/src/nexrad/buildRadarRayGeometry.ts +97 -0
  15. package/src/nexrad/level3StormRelative.ts +116 -0
  16. package/src/nexrad/loadNexradSites.ts +41 -0
  17. package/src/nexrad/nexradArchiveCache.ts +64 -0
  18. package/src/nexrad/nexradCrossSectionSampleAtLatLon.ts +121 -0
  19. package/src/nexrad/nexradLevel3Products.ts +549 -0
  20. package/src/nexrad/nexradMapboxFrameOpts.js +106 -0
  21. package/src/nexrad/radarArchiveCore.bundled.js +4206 -0
  22. package/src/nexrad/radarArchiveCore.bundled.js.map +7 -0
  23. package/src/nexrad/radarArchiveCore.ts +1737 -0
  24. package/src/nexrad/radarDecode.worker.bundled.js +809 -0
  25. package/src/nexrad/radarDecode.worker.ts +227 -0
  26. package/src/nexrad/radarFrameGpuMatch.ts +111 -0
  27. package/src/nwsAlertsSupport.js +860 -0
  28. package/src/nwsEventColorsDefaults.js +133 -0
  29. package/src/nwsSdkConstants.js +360 -0
  30. package/src/nwsWarningCustomizationKey.gen.js +496 -0
  31. package/src/satelliteDefaultColormaps.js +37 -0
  32. package/src/satelliteKtxWorker.js +225 -0
  33. package/src/satelliteShader.js +17 -0
@@ -1,7 +1,12 @@
1
1
  // index.js - The main entry point for the @aguacerowx/web SDK
2
2
 
3
- import { AguaceroCore, EventEmitter, DICTIONARIES } from '@aguacerowx/javascript-sdk';
3
+ import { AguaceroCore, EventEmitter, DICTIONARIES, resolveSatelliteS3FileName } from '@aguacerowx/javascript-sdk';
4
4
  import { GridRenderLayer } from './GridRenderLayer.js'; // <-- IMPORT THE WEB RENDERER!
5
+ import { SatelliteShaderManager } from './SatelliteShaderManager.js';
6
+ import { NexradWeatherController } from './NexradWeatherController.js';
7
+ import { NexradSitesOverlay } from './NexradSitesOverlay.js';
8
+ import { NwsWatchesWarningsOverlay } from './NwsWatchesWarningsOverlay.js';
9
+ import WorkerPool from './WorkerPool.js';
5
10
 
6
11
  function findLatestModelRun(modelsData, modelName) {
7
12
  const model = modelsData?.[modelName];
@@ -20,28 +25,93 @@ function findLatestModelRun(modelsData, modelName) {
20
25
  * The WeatherLayerManager is the main class for the Aguacero Web SDK.
21
26
  * It acts as a "controller" that connects the headless AguaceroCore engine
22
27
  * to a visual Mapbox GL JS map instance.
28
+ *
29
+ * @param {import('mapbox-gl').Map} map
30
+ * @param {object} [options]
31
+ * @param {string} [options.layerId] - Mapbox **custom layer** id for model/MRMS grid (default: random `weather-layer-*`). Alias of `id`.
32
+ * @param {string} [options.id] - Same as `layerId` if `layerId` is omitted.
33
+ * @param {string} [options.belowID] - Style layer id to insert Aguacero weather layers **below** (default `AML_-_terrain` when present). Alias of `weatherBeforeLayerId`.
34
+ * @param {string} [options.weatherBeforeLayerId] - Same as `belowID`.
35
+ * @param {string} [options.nexradLayerId] - Override Mapbox id for the NEXRAD custom layer (default: derived from `layerId`).
23
36
  */
24
37
  export class WeatherLayerManager extends EventEmitter {
25
38
  constructor(map, options = {}) {
26
39
  super();
27
40
  if (!map) throw new Error('A Mapbox GL map instance is required.');
28
41
  this.map = map;
29
- this.layerId = options.id || `weather-layer-${Math.random().toString(36).substr(2, 9)}`;
42
+ this.layerId =
43
+ options.layerId ||
44
+ options.id ||
45
+ `weather-layer-${Math.random().toString(36).substr(2, 9)}`;
46
+ /** Mapbox id for the NEXRAD custom layer (derived from `layerId` unless `nexradLayerId` is set). */
47
+ this._nexradLayerId =
48
+ options.nexradLayerId ||
49
+ (this.layerId.includes('weather-layer')
50
+ ? this.layerId.replace('weather-layer', 'nexrad-layer')
51
+ : `${this.layerId}-nexrad`);
30
52
  this.shaderLayer = null;
31
53
  this.currentLoadedTimeKey = null;
32
- this.latestDataRequestToken = 0;
33
-
34
54
  this.autoRefreshEnabled = options.autoRefresh ?? false;
35
55
  this.autoRefreshIntervalSeconds = options.autoRefreshInterval ?? 60; // Default to 30 seconds
36
56
  this.autoRefreshIntervalId = null;
37
57
  this.currentRunKey = null;
38
58
  this.currentRebuildId = 0;
59
+ /** True while awaiting the first _loadGridData for the current shader layer rebuild */
60
+ this._initialGridLoadPending = false;
61
+ /** Timestep (forecast hour or MRMS ts) that the in-flight rebuild is fetching */
62
+ this._rebuildTargetTimeKey = null;
63
+
64
+ this.satelliteLayerId = options.satelliteLayerId || 'aguacero-satellite-layer';
65
+ this.basisBaseUrl = options.basisBaseUrl || '/basis/';
66
+ this.satelliteLayer = null;
67
+ this.satelliteWorkerPool = null;
68
+ this._satelliteRunKey = null;
69
+ /** Prevents duplicate background fetches for the same satellite timeline (MRMS-style preload). */
70
+ this._satellitePreloadedTimelineId = null;
71
+ /** Tracks MRMS timeline window so expanding hours triggers background preload of new frames. */
72
+ this._prevMrmsDurationValue = undefined;
73
+
74
+ /** Optional satellite colormap overrides (flat `[norm, '#hex', ...]` arrays, norm 0–100). */
75
+ this._satelliteColormap = options.satelliteColormap ?? null;
76
+ this._satelliteColormapIR = options.satelliteColormapIR ?? null;
77
+ this._satelliteColormapWV = options.satelliteColormapWV ?? null;
78
+ this._interpolateSatelliteColormap = options.interpolateSatelliteColormap !== false;
79
+
80
+ this._nexradController = null;
81
+ this._nexradSites = null;
82
+ this._nexradSitesBound = false;
83
+ /** Override URL for the NEXRAD site list (default: `/data/nexrad.json` — serve from app `public/data/`). */
84
+ this._nexradSitesUrl = options.nexradSitesUrl ?? null;
85
+
86
+ /** Latest payload from {@link AguaceroCore} `state:change` (includes `colormap` for NEXRAD readouts). */
87
+ this._lastEmittedState = null;
88
+
89
+ /**
90
+ * Optional NWS watches/warnings overlay (all alert types by default; optional user allowlist).
91
+ * See {@link configureWatchesWarnings}; not a separate “data mode” — overlays satellite / MRMS / NEXRAD only.
92
+ */
93
+ this._watchesWarningsOptions = {
94
+ alertInteractionEnabled: true,
95
+ ...(options.watchesWarnings ?? {}),
96
+ };
97
+ this._nwsOverlay = null;
98
+
99
+ /**
100
+ * Style layer id to insert custom weather layers below when using a non-Aguacero Mapbox style
101
+ * (set via {@link MapManager}, or pass `belowID` / `weatherBeforeLayerId` here).
102
+ */
103
+ this._weatherBeforeLayerId =
104
+ options.belowID ??
105
+ options.weatherBeforeLayerId ??
106
+ (map && map.__aguaceroMapsgl && map.__aguaceroMapsgl.weatherBeforeLayerId) ??
107
+ null;
39
108
 
40
109
  // 1. CREATE an instance of the core engine
41
110
  this.core = new AguaceroCore(options);
42
111
 
43
112
  // 2. LISTEN for events from the core engine
44
113
  this.core.on('state:change', (newState) => {
114
+ this._lastEmittedState = newState;
45
115
  // When the core's state changes, this controller's job
46
116
  // is to update the visual map layers accordingly.
47
117
  this._handleStateChange(newState);
@@ -58,6 +128,45 @@ export class WeatherLayerManager extends EventEmitter {
58
128
  this.map.on('mousemove', this._handleMouseMove);
59
129
  }
60
130
 
131
+ /**
132
+ * Resolves which style layer id to pass as Mapbox `addLayer(..., beforeId)` for satellite / grid / NEXRAD.
133
+ * Prefers an explicit id (custom styles), then the default Aguacero `AML_-_terrain` anchor when present.
134
+ * @returns {string | undefined}
135
+ */
136
+ _weatherInsertBeforeId() {
137
+ const tryId = (id) => {
138
+ if (!id || typeof id !== 'string') {
139
+ return undefined;
140
+ }
141
+ try {
142
+ return this.map.getLayer(id) ? id : undefined;
143
+ } catch {
144
+ return undefined;
145
+ }
146
+ };
147
+ return tryId(this._weatherBeforeLayerId) || tryId('AML_-_terrain') || undefined;
148
+ }
149
+
150
+ /**
151
+ * Lng/lat under the cursor — prefer `map.unproject(e.point)` (aguacero-frontend parity) so readouts
152
+ * align with custom layers / Web Mercator.
153
+ * @param {import('mapbox-gl').MapMouseEvent | { lngLat: { lng: number; lat: number }; point: import('mapbox-gl').Point }} e
154
+ * @returns {{ lng: number; lat: number }}
155
+ */
156
+ _mouseLngLatFromEvent(e) {
157
+ try {
158
+ if (this.map && typeof this.map.unproject === 'function') {
159
+ const u = this.map.unproject(e.point);
160
+ if (Number.isFinite(u.lng) && Number.isFinite(u.lat)) {
161
+ return { lng: u.lng, lat: u.lat };
162
+ }
163
+ }
164
+ } catch {
165
+ /* style or map not ready */
166
+ }
167
+ return { lng: e.lngLat.lng, lat: e.lngLat.lat };
168
+ }
169
+
61
170
  /**
62
171
  * The main visual controller. It receives the new state from the core
63
172
  * and decides what visual update is needed.
@@ -65,23 +174,461 @@ export class WeatherLayerManager extends EventEmitter {
65
174
  * @private
66
175
  */
67
176
  _handleStateChange(state) {
68
- const timeKey = state.isMRMS ? state.mrmsTimestamp : state.forecastHour;
69
- const runKey = `${state.model}-${state.date}-${state.run}-${state.variable}`;
70
- const existingRunKey = this.shaderLayer?.runKey ?? 'none';
71
-
72
- if (!this.shaderLayer || this.shaderLayer.runKey !== runKey) {
73
- //console.log(`[WLM._handleStateChange] → dispatching _rebuildLayerAndPreload (runKey changed: "${existingRunKey}" → "${runKey}")`);
74
- this._rebuildLayerAndPreload(state);
75
- } else if (this.currentLoadedTimeKey !== timeKey) {
76
- //console.log(`[WLM._handleStateChange] → dispatching _updateLayerData (timeKey changed: ${this.currentLoadedTimeKey} → ${timeKey})`);
77
- this._updateLayerData(state);
78
- } else {
79
- //console.log(`[WLM._handleStateChange] → style/opacity update only`);
177
+ /** 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. */
178
+ let deferNwsSyncUntilNexradReady = false;
179
+ try {
180
+ if (state.isSatellite) {
181
+ this._handleSatelliteStateChange(state);
182
+ return;
183
+ }
184
+
185
+ this._tearDownSatellite();
186
+
187
+ if (state.isNexrad) {
188
+ this._tearDownShaderGrid();
189
+ deferNwsSyncUntilNexradReady = true;
190
+ void this._handleNexradStateChange(state).finally(() => {
191
+ const st = this._lastEmittedState ?? state;
192
+ this._syncNwsOverlay(st);
193
+ });
194
+ return;
195
+ }
196
+
197
+ this._tearDownNexrad();
198
+
199
+ const prevMrmsDur = this._prevMrmsDurationValue;
200
+ this._prevMrmsDurationValue = state.mrmsDurationValue;
201
+ const mrmsDurationChanged =
202
+ state.isMRMS && prevMrmsDur !== undefined && prevMrmsDur !== state.mrmsDurationValue;
203
+
204
+ const timeKey = state.isMRMS
205
+ ? (state.mrmsTimestamp == null ? null : Number(state.mrmsTimestamp))
206
+ : Number(state.forecastHour);
207
+ const runKey = `${state.model}-${state.date}-${state.run}-${state.variable}`;
208
+
209
+ if (!this.shaderLayer || this.shaderLayer.runKey !== runKey) {
210
+ this._rebuildLayerAndPreload(state);
211
+ } else if (this.currentLoadedTimeKey !== timeKey) {
212
+ const duplicateBeforeFirstPaint =
213
+ this.currentLoadedTimeKey === null &&
214
+ this._initialGridLoadPending &&
215
+ timeKey === this._rebuildTargetTimeKey;
216
+ if (!duplicateBeforeFirstPaint) {
217
+ this._updateLayerData(state);
218
+ }
219
+ }
220
+
221
+ if (this.shaderLayer) {
222
+ this.shaderLayer.updateStyle({ opacity: state.opacity });
223
+ this.shaderLayer.setSmoothing(!state.shaderSmoothingEnabled);
224
+ this.map.triggerRepaint();
225
+ }
226
+
227
+ if (state.isMRMS && this.shaderLayer && mrmsDurationChanged) {
228
+ this._preloadAllTimeSteps(state);
229
+ }
230
+ } finally {
231
+ if (!deferNwsSyncUntilNexradReady) {
232
+ this._syncNwsOverlay(state);
233
+ }
80
234
  }
235
+ }
81
236
 
237
+ /**
238
+ * NWS watches/warnings: same NWWS feed as aguacero-frontend, overlaid on satellite / MRMS / NEXRAD only
239
+ * (hidden on model grids). By default `nwsAlertSettings.alertScope` is `'all'`. Use `'user'` with
240
+ * `includedAlerts` (exact NWS `event_name` strings) to show only selected products.
241
+ *
242
+ * @param {object} partial
243
+ * @param {boolean} [partial.enabled] - Master switch; default false.
244
+ * @param {string} [partial.alertsBaseUrl] - API root (default `https://api.aguacerowx.com`).
245
+ * @param {boolean} [partial.alertInteractionEnabled=true] - When true, map fires `nws:alert:click` with alert details.
246
+ * @param {boolean} [partial.activeOnlyRealtime=false] - Match frontend “active only (realtime)” time filter.
247
+ * @param {object} [partial.nwsAlertSettings]
248
+ * @param {Record<string, string>} [partial.nwsAlertSettings.colors] - Fill color (`#rrggbb`) per `event_name` (same names as NWS / API).
249
+ * @param {Record<string, boolean>} [partial.nwsAlertSettings.fillHidden] - `true` = no fill for that product.
250
+ * @param {Record<string, boolean>} [partial.nwsAlertSettings.lineHidden] - `true` = no outline for that product.
251
+ * @param {Record<string, number>} [partial.nwsAlertSettings.fillOpacity] - 0–1 fill opacity multiplier per product (when fill is shown).
252
+ * @param {Record<string, object>} [partial.nwsAlertSettings.lineStyles] - Per-product line overrides (`innerColor`, `outerColor`, widths, `lineDash`, …).
253
+ * @param {string} [partial.nwsAlertSettings.lineDash] - Default dash style when a product has no `lineStyles` entry.
254
+ * @param {'all'|'user'} [partial.nwsAlertSettings.alertScope] - `all` (default): every alert with a resolved name. `user`: only `includedAlerts`.
255
+ * @param {string[]} [partial.nwsAlertSettings.includedAlerts] - When `alertScope` is `'user'`, list of exact NWS `event_name` values to render (optional colors per name in `colors`).
256
+ * @param {string} [partial.lineBeforeLayerId] - Mapbox style layer id to insert NWS **lines** below (default `AML_-_states`). Override if your style uses different ids.
257
+ * @param {string | null} [partial.fillBeforeLayerId] - Optional manual override: layer id NWS **fill** is inserted under. When omitted, placement uses the active weather layer and `AML_-_states` so fill stays under whichever is lower in the style.
258
+ */
259
+ configureWatchesWarnings(partial) {
260
+ this._watchesWarningsOptions = { ...this._watchesWarningsOptions, ...partial };
261
+ const st = this._lastEmittedState ?? this.core?.state;
262
+ if (st) {
263
+ this._syncNwsOverlay(st);
264
+ }
265
+ }
266
+
267
+ _syncNwsOverlay(state) {
268
+ const wopt = this._watchesWarningsOptions ?? {};
269
+ const masterEnabled = wopt.enabled === true;
270
+ const isModelMode = !state.isSatellite && !state.isNexrad && !state.isMRMS;
271
+ const enabled = masterEnabled && !isModelMode;
272
+
273
+ let timelineUnix = null;
274
+ if (state.isSatellite) {
275
+ timelineUnix = state.satelliteTimestamp == null ? null : Number(state.satelliteTimestamp);
276
+ } else if (state.isNexrad) {
277
+ timelineUnix = state.nexradTimestamp == null ? null : Number(state.nexradTimestamp);
278
+ } else if (state.isMRMS) {
279
+ timelineUnix = state.mrmsTimestamp == null ? null : Number(state.mrmsTimestamp);
280
+ }
281
+
282
+ if (!this._nwsOverlay) {
283
+ this._nwsOverlay = new NwsWatchesWarningsOverlay(this.map, {
284
+ ...wopt,
285
+ enabled: false,
286
+ });
287
+ }
288
+
289
+ const weatherLayerId = this._resolveNwsFillBeforeLayerId(state);
290
+ const fillBeforeLayerId = Object.prototype.hasOwnProperty.call(wopt, 'fillBeforeLayerId')
291
+ ? wopt.fillBeforeLayerId ?? null
292
+ : null;
293
+
294
+ this._nwsOverlay.updateOptions({
295
+ alertsBaseUrl: wopt.alertsBaseUrl,
296
+ alertInteractionEnabled: wopt.alertInteractionEnabled,
297
+ deltaDebounceMs: wopt.deltaDebounceMs,
298
+ fillOpacity: wopt.fillOpacity,
299
+ lineOpacity: wopt.lineOpacity,
300
+ lineWidth: wopt.lineWidth,
301
+ weatherLayerId,
302
+ fillBeforeLayerId,
303
+ lineBeforeLayerId: wopt.lineBeforeLayerId,
304
+ nwsAlertSettings: {
305
+ ...(wopt.nwsAlertSettings ?? {}),
306
+ ...(wopt.activeOnlyRealtime !== undefined ? { activeOnlyRealtime: wopt.activeOnlyRealtime } : {}),
307
+ },
308
+ });
309
+ this._nwsOverlay.syncWithMode({ enabled, timelineUnix });
310
+ }
311
+
312
+ /**
313
+ * Layer id for the active satellite / MRMS / NEXRAD custom layer (used with `AML_-_states` for NWS fill stacking).
314
+ * @param {object} state
315
+ * @returns {string | null}
316
+ */
317
+ _resolveNwsFillBeforeLayerId(state) {
318
+ try {
319
+ if (state.isSatellite && this.satelliteLayer?.id && this.map.getLayer(this.satelliteLayer.id)) {
320
+ return this.satelliteLayer.id;
321
+ }
322
+ if (state.isMRMS && this.shaderLayer?.id && this.map.getLayer(this.shaderLayer.id)) {
323
+ return this.shaderLayer.id;
324
+ }
325
+ if (state.isNexrad && this._nexradController?.mapboxLayer?.id && this.map.getLayer(this._nexradController.mapboxLayer.id)) {
326
+ return this._nexradController.mapboxLayer.id;
327
+ }
328
+ } catch {
329
+ /* map not ready */
330
+ }
331
+ return null;
332
+ }
333
+
334
+ _tearDownShaderGrid() {
335
+ if (!this.shaderLayer) return;
336
+ if (this.map.getLayer(this.shaderLayer.id)) {
337
+ this.map.removeLayer(this.shaderLayer.id);
338
+ }
339
+ try {
340
+ this.shaderLayer.onRemove?.();
341
+ } catch {
342
+ /* ignore */
343
+ }
344
+ this.shaderLayer = null;
345
+ this.currentLoadedTimeKey = null;
346
+ }
347
+
348
+ _tearDownNexrad() {
349
+ if (this._nexradController) {
350
+ this._nexradController.destroy();
351
+ this._nexradController = null;
352
+ }
353
+ if (this._nexradSites) {
354
+ this._nexradSites.destroy();
355
+ this._nexradSites = null;
356
+ }
357
+ this._nexradSitesBound = false;
358
+ }
359
+
360
+ async _handleNexradStateChange(state) {
361
+ if (!this._nexradController) {
362
+ this._nexradController = new NexradWeatherController(this.map, this.core, {
363
+ nexradLayerId: this._nexradLayerId,
364
+ interpolateNexradColormap: this.core.state?.shaderSmoothingEnabled !== false,
365
+ getInsertBeforeLayerId: () => this._weatherInsertBeforeId(),
366
+ });
367
+ }
368
+
369
+ const showSiteMarkers = state.nexradShowSitesPicker !== false;
370
+ if (showSiteMarkers) {
371
+ if (!this._nexradSites) {
372
+ this._nexradSites = new NexradSitesOverlay(this.map, {
373
+ nexradSitesUrl: this._nexradSitesUrl || undefined,
374
+ });
375
+ }
376
+ await this._nexradSites.show();
377
+ if (!this._nexradSitesBound) {
378
+ this._nexradSites.bindClick((siteId) => {
379
+ void this.core.setNexradSite(siteId);
380
+ });
381
+ this._nexradSitesBound = true;
382
+ }
383
+ } else if (this._nexradSites) {
384
+ this._nexradSites.destroy();
385
+ this._nexradSites = null;
386
+ this._nexradSitesBound = false;
387
+ }
388
+
389
+ this._nexradController.preloadAllAvailable(state);
390
+ await this._nexradController.sync(state);
391
+ }
392
+
393
+ _tearDownSatellite() {
394
+ if (!this.satelliteLayer) return;
395
+ if (this.map.getLayer(this.satelliteLayer.id)) {
396
+ this.map.removeLayer(this.satelliteLayer.id);
397
+ }
398
+ try {
399
+ this.satelliteLayer.onRemove?.();
400
+ } catch {
401
+ /* ignore */
402
+ }
403
+ this.satelliteLayer = null;
404
+ if (this.satelliteWorkerPool) {
405
+ this.satelliteWorkerPool.terminate();
406
+ this.satelliteWorkerPool = null;
407
+ }
408
+ this._satelliteRunKey = null;
409
+ this._satellitePreloadedTimelineId = null;
410
+ }
411
+
412
+ _handleSatelliteStateChange(state) {
413
+ this._tearDownNexrad();
82
414
  if (this.shaderLayer) {
83
- this.shaderLayer.updateStyle({ opacity: state.opacity });
84
- this.shaderLayer.setSmoothing(!state.shaderSmoothingEnabled);
415
+ if (this.map.getLayer(this.shaderLayer.id)) {
416
+ this.map.removeLayer(this.shaderLayer.id);
417
+ }
418
+ try {
419
+ this.shaderLayer.onRemove?.();
420
+ } catch {
421
+ /* ignore */
422
+ }
423
+ this.shaderLayer = null;
424
+ this.currentLoadedTimeKey = null;
425
+ }
426
+
427
+ if (!state.satelliteKey || state.satelliteTimestamp == null) {
428
+ this._tearDownSatellite();
429
+ this.map.triggerRepaint();
430
+ return;
431
+ }
432
+
433
+ const satRunKey = `${state.satelliteKey}|${state.variable || ''}`;
434
+ if (!this.satelliteLayer || this._satelliteRunKey !== satRunKey) {
435
+ this._rebuildSatelliteLayer(state, satRunKey);
436
+ }
437
+
438
+ // Preload the full timeline before syncing the visible frame. Slider / timestamp changes must never
439
+ // trigger fetches — only this preload pass (and listing refresh) may load KTX2 data.
440
+ if (this.satelliteLayer && state.satelliteTimeToFileMap) {
441
+ this._preloadAllSatelliteFrames(state);
442
+ }
443
+ this._syncSatelliteFrame(state);
444
+
445
+ if (this.satelliteLayer) {
446
+ this.satelliteLayer.updateLayerSettings(this._buildSatelliteLayerSettings(state));
447
+ this.map.triggerRepaint();
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Merge core satellite state with optional colormap overrides for `SatelliteShaderManager`.
453
+ * @param {object} state - Core state snapshot
454
+ */
455
+ _buildSatelliteLayerSettings(state) {
456
+ return {
457
+ satelliteOpacity: state.opacity ?? 1,
458
+ satelliteVisibility: state.visible !== false,
459
+ fillSmoothing: state.shaderSmoothingEnabled ? 1 : 0,
460
+ interpolateColormap: this._interpolateSatelliteColormap,
461
+ satelliteColormap: this._satelliteColormap ?? undefined,
462
+ satelliteColormapIR: this._satelliteColormapIR ?? undefined,
463
+ satelliteColormapWV: this._satelliteColormapWV ?? undefined,
464
+ };
465
+ }
466
+
467
+ /**
468
+ * Set custom IR / WV / legacy colormap stops for the satellite layer. Pass `null` to clear an override.
469
+ * Stops are alternating `[norm0to100, '#rrggbb', ...]` (same encoding as aguacero-frontend satellite colormaps).
470
+ *
471
+ * @param {object} opts
472
+ * @param {number[]|string[]|null} [opts.colormap] - Single list; interpreted using the active frame (IR vs WV).
473
+ * @param {number[]|string[]|null} [opts.colormapIR] - Longwave IR bands (C13–C16).
474
+ * @param {number[]|string[]|null} [opts.colormapWV] - Water vapor bands (C08–C10).
475
+ * @param {boolean} [opts.interpolate=true] - Interpolate between stops when building the 1D LUT texture.
476
+ */
477
+ setSatelliteColormapOverrides(opts = {}) {
478
+ if (opts.colormap !== undefined) {
479
+ this._satelliteColormap = opts.colormap;
480
+ }
481
+ if (opts.colormapIR !== undefined) {
482
+ this._satelliteColormapIR = opts.colormapIR;
483
+ }
484
+ if (opts.colormapWV !== undefined) {
485
+ this._satelliteColormapWV = opts.colormapWV;
486
+ }
487
+ if (opts.interpolate !== undefined) {
488
+ this._interpolateSatelliteColormap = opts.interpolate;
489
+ }
490
+
491
+ if (this.satelliteLayer && this.core?.state?.isSatellite) {
492
+ this.satelliteLayer.updateLayerSettings(this._buildSatelliteLayerSettings(this.core.state));
493
+ this.map.triggerRepaint();
494
+ }
495
+ }
496
+
497
+ _rebuildSatelliteLayer(state, satRunKey) {
498
+ this._tearDownSatellite();
499
+
500
+ // Vite only rewrites workers when it sees `new Worker(new URL(..., import.meta.url))` here — not
501
+ // `super(url)` inside a Worker subclass (that leaves a file: URL and triggers fs allow-list errors).
502
+ this.satelliteWorkerPool = new WorkerPool(
503
+ () => new Worker(new URL('./satelliteKtxWorker.js', import.meta.url)),
504
+ Math.min(4, Math.max(2, Math.floor((typeof navigator !== 'undefined' && navigator.hardwareConcurrency) ? navigator.hardwareConcurrency * 0.5 : 2)))
505
+ );
506
+
507
+ this.satelliteLayer = new SatelliteShaderManager(
508
+ this.satelliteWorkerPool,
509
+ () => {},
510
+ { basisBaseUrl: this.basisBaseUrl }
511
+ );
512
+ this.satelliteLayer.id = this.satelliteLayerId;
513
+ this._satelliteRunKey = satRunKey;
514
+
515
+ const beforeId = this._weatherInsertBeforeId();
516
+ if (beforeId) {
517
+ this.map.addLayer(this.satelliteLayer, beforeId);
518
+ } else {
519
+ this.map.addLayer(this.satelliteLayer);
520
+ }
521
+ }
522
+
523
+ _satelliteTimelineId(state) {
524
+ const keys = Object.keys(state.satelliteTimeToFileMap || {}).sort((a, b) => Number(a) - Number(b));
525
+ return `${state.satelliteKey}|${state.variable || ''}|${keys.join(',')}`;
526
+ }
527
+
528
+ _getSatelliteFetchParts(state, satelliteTimestamp) {
529
+ const fileName = resolveSatelliteS3FileName(
530
+ state.satelliteKey,
531
+ state.satelliteTimeToFileMap,
532
+ satelliteTimestamp
533
+ );
534
+ if (!fileName) {
535
+ return null;
536
+ }
537
+ const parts = state.satelliteKey.split('.');
538
+ const channelName = parts[2] || state.variable;
539
+ const channelNameUpper = String(channelName).toUpperCase();
540
+ let s3FileName = fileName.replace('MULTI', channelNameUpper);
541
+ if (!s3FileName.endsWith('.ktx2')) {
542
+ s3FileName += '.ktx2';
543
+ }
544
+ let shaderFileName = s3FileName;
545
+ if (!channelNameUpper.startsWith('C') || channelNameUpper.length > 3) {
546
+ const token = channelNameUpper.toLowerCase().replace(/_/g, '');
547
+ shaderFileName = `${s3FileName}_${token}_`;
548
+ }
549
+ const apiKey = this.core.apiKey;
550
+ if (!apiKey) {
551
+ return null;
552
+ }
553
+ const uid = this.core.userId || 'sdk-user';
554
+ const url = `https://d3dc62msmxkrd7.cloudfront.net/satellite/${s3FileName}?userId=${encodeURIComponent(uid)}&apiKey=${encodeURIComponent(apiKey)}`;
555
+ return { url, shaderFileName, frameKey: Number(satelliteTimestamp), apiKey };
556
+ }
557
+
558
+ _preloadAllSatelliteFrames(state) {
559
+ if (!this.satelliteLayer || !state.satelliteTimeToFileMap) {
560
+ return;
561
+ }
562
+ const timelineId = this._satelliteTimelineId(state);
563
+ if (this._satellitePreloadedTimelineId === timelineId) {
564
+ return;
565
+ }
566
+ this._satellitePreloadedTimelineId = timelineId;
567
+
568
+ const runKey = this._satelliteRunKey;
569
+ const timestamps = Object.keys(state.satelliteTimeToFileMap)
570
+ .map((k) => Number(k))
571
+ .filter((t) => !Number.isNaN(t));
572
+
573
+ timestamps.forEach((ts) => {
574
+ if (!this.satelliteLayer || this._satelliteRunKey !== runKey) {
575
+ return;
576
+ }
577
+ if (this.satelliteLayer.frames.has(ts)) {
578
+ return;
579
+ }
580
+ void this._fetchSatelliteFrameAt(state, ts, runKey);
581
+ });
582
+ }
583
+
584
+ async _fetchSatelliteFrameAt(state, satelliteTimestamp, runKey) {
585
+ const parts = this._getSatelliteFetchParts(state, satelliteTimestamp);
586
+ if (!parts || !this.satelliteLayer) {
587
+ return;
588
+ }
589
+ const { url, shaderFileName, frameKey, apiKey } = parts;
590
+
591
+ if (this.satelliteLayer.frames.has(frameKey)) {
592
+ return;
593
+ }
594
+
595
+ try {
596
+ const response = await fetch(url, { headers: { 'x-api-key': apiKey } });
597
+ if (!response.ok) {
598
+ throw new Error(`${response.status} ${response.statusText}`);
599
+ }
600
+ const buffer = await response.arrayBuffer();
601
+ if (!this.satelliteLayer || this._satelliteRunKey !== runKey) {
602
+ return;
603
+ }
604
+ this.satelliteLayer.addFrame(new Uint8Array(buffer), frameKey, shaderFileName);
605
+ this.map.triggerRepaint();
606
+ } catch (err) {
607
+ console.warn('[WeatherLayerManager] Satellite frame fetch failed:', err);
608
+ }
609
+ }
610
+
611
+ /**
612
+ * Selects which decoded frame to show. Never performs network I/O — preloading owns all fetches.
613
+ * If the frame is not decoded yet, SatelliteShaderManager keeps targetFrameKey until addFrame runs.
614
+ */
615
+ _syncSatelliteFrame(state) {
616
+ if (!this.satelliteLayer) {
617
+ return;
618
+ }
619
+ if (!this.core.apiKey) {
620
+ console.warn('[WeatherLayerManager] apiKey is required to load satellite KTX2 tiles.');
621
+ return;
622
+ }
623
+
624
+ const parts = this._getSatelliteFetchParts(state, state.satelliteTimestamp);
625
+ if (!parts) {
626
+ return;
627
+ }
628
+ const { frameKey } = parts;
629
+
630
+ this.satelliteLayer.setActiveFrame(frameKey);
631
+ if (this.satelliteLayer.frames.has(frameKey)) {
85
632
  this.map.triggerRepaint();
86
633
  }
87
634
  }
@@ -114,68 +661,75 @@ export class WeatherLayerManager extends EventEmitter {
114
661
  async setMRMSTimestamp(timestamp) {
115
662
  return this.core.setMRMSTimestamp(timestamp);
116
663
  }
664
+ async setSatelliteTimestamp(timestamp) {
665
+ return this.core.setSatelliteTimestamp(timestamp);
666
+ }
667
+ async setSatelliteDurationValue(value) {
668
+ return this.core.setSatelliteDurationValue(value);
669
+ }
670
+ async setMRMSDurationValue(value) {
671
+ return this.core.setMRMSDurationValue(value);
672
+ }
673
+
674
+ /** Refreshes NEXRAD object-key listings for the current site / product / tilt (NEXRAD mode only). */
675
+ async refreshNexradTimes() {
676
+ return this.core.refreshNexradTimes();
677
+ }
678
+ /** @param {string | null} siteId - ICAO id, or null to clear the selected site */
679
+ async setNexradSite(siteId) {
680
+ return this.core.setNexradSite(siteId);
681
+ }
682
+ async setNexradProduct(product) {
683
+ return this.core.setNexradProduct(product);
684
+ }
685
+ /** @param {'level2'|'level3'} dataSource @param {string} product */
686
+ async setNexradProductMode(dataSource, product) {
687
+ return this.core.setNexradProductMode(dataSource, product);
688
+ }
689
+ /** @param {'level2'|'level3'} source */
690
+ async setNexradDataSource(source) {
691
+ return this.core.setNexradDataSource(source);
692
+ }
693
+ async setNexradTilt(tilt) {
694
+ return this.core.setNexradTilt(tilt);
695
+ }
696
+ async setNexradTimestamp(timestamp) {
697
+ return this.core.setNexradTimestamp(timestamp);
698
+ }
117
699
 
118
700
  // --- VISUAL RENDERING LOGIC ---
119
701
  // These methods were removed from the core and now live exclusively
120
702
  // in this web-specific presentation layer.
121
703
 
122
- // New internal method accepts an externally-supplied token so the
123
- // rebuild can guarantee its own fetch is never discarded.
124
- async _updateLayerDataWithToken(state, requestToken) {
704
+ /** Swaps to a timestep that was already uploaded by rebuild or `_preloadAllTimeSteps`. */
705
+ _updateLayerDataWithToken(state) {
125
706
  if (!this.shaderLayer || !state.variable) {
126
- console.warn(`[WLM._updateLayerDataWithToken] token=${requestToken} — skipping, shaderLayer=${!!this.shaderLayer} variable=${state.variable}`);
127
707
  return;
128
708
  }
129
709
 
130
- const timeKey = state.isMRMS ? state.mrmsTimestamp : state.forecastHour;
131
- //console.log(`[WLM._updateLayerDataWithToken] token=${requestToken} timeKey=${timeKey}`);
132
-
710
+ const timeKey = state.isMRMS
711
+ ? (state.mrmsTimestamp == null ? null : Number(state.mrmsTimestamp))
712
+ : Number(state.forecastHour);
133
713
  if (this.shaderLayer.switchToPreloadedTexture(timeKey)) {
134
714
  this.currentLoadedTimeKey = timeKey;
135
- //console.log(`[WLM._updateLayerDataWithToken] token=${requestToken} — FAST PATH: preloaded texture found for timeKey=${timeKey}, swapped instantly`);
136
715
  this.map.triggerRepaint();
137
716
  return;
138
717
  }
139
718
 
140
- //console.log(`[WLM._updateLayerDataWithToken] token=${requestToken} SLOW PATH: no preloaded texture for timeKey=${timeKey}, fetching...`);
141
- const fetchStart = performance.now();
142
- try {
143
- const grid = await this.core._loadGridData(state);
144
- const fetchMs = (performance.now() - fetchStart).toFixed(1);
145
-
146
- if (requestToken !== this.latestDataRequestToken) {
147
- //console.log(`[WLM._updateLayerDataWithToken] token=${requestToken} — STALE after fetch (latestToken=${this.latestDataRequestToken}), discarding (${fetchMs}ms)`);
148
- return;
149
- }
150
-
151
- if (grid && grid.data) {
152
- const gridModel = state.isMRMS ? 'mrms' : state.model;
153
- const { gridDef } = this.core._getGridCornersAndDef(gridModel);
154
- //console.log(`[WLM._updateLayerDataWithToken] token=${requestToken} — grid received in ${fetchMs}ms, storing+switching texture for timeKey=${timeKey}`);
155
-
156
- this.shaderLayer.storePreloadedTexture(
157
- timeKey, grid.data, grid.encoding,
158
- gridDef.grid_params.nx, gridDef.grid_params.ny
159
- );
160
- this.shaderLayer.switchToPreloadedTexture(timeKey);
161
- this.currentLoadedTimeKey = timeKey;
162
- this.latestDataRequestToken = this.currentRebuildId;
163
- this.map.triggerRepaint();
164
- } else {
165
- console.error(
166
- `[WLM._updateLayerDataWithToken] token=${requestToken} — grid fetch returned null/empty after ${fetchMs}ms for timeKey=${timeKey}.`,
167
- { model: state.model, variable: state.variable, forecastHour: state.forecastHour, isMRMS: state.isMRMS },
168
- '← THIS IS WHY THE MAP IS BLANK'
169
- );
170
- }
171
- } catch (error) {
172
- if (requestToken === this.latestDataRequestToken) {
173
- console.error(`[WLM._updateLayerDataWithToken] token=${requestToken} — fetch threw for timeKey=${timeKey}:`, error);
174
- }
175
- }
719
+ // No GPU cache for this timestep (preload still in flight, or it failed e.g. 502). Never fetch
720
+ // here scrubbing must not trigger `_loadGridData`; only rebuild + `_preloadAllTimeSteps` may.
721
+ // When a background preload finishes, `_preloadAllTimeSteps` applies the frame if it matches
722
+ // the active time.
176
723
  }
177
724
 
178
725
  async _rebuildLayerAndPreload(state) {
726
+ if (state.isSatellite) {
727
+ return;
728
+ }
729
+ if (!state.variable) {
730
+ return;
731
+ }
732
+
179
733
  const rebuildId = ++this.currentRebuildId;
180
734
 
181
735
  this.core.cancelAllRequests();
@@ -186,20 +740,19 @@ export class WeatherLayerManager extends EventEmitter {
186
740
  }
187
741
  this.currentLoadedTimeKey = null;
188
742
 
189
- if (!state.variable) return;
190
-
191
743
  if (
192
744
  !state.isMRMS &&
193
745
  state.forecastHour === 0 &&
194
746
  state.variable === 'ptypeRefl' &&
195
747
  state.model === 'hrrr'
196
748
  ) {
197
- const availableHours =
198
- this.core.modelStatus?.[state.model]?.[state.date]?.[state.run] || [];
749
+ const availableHours = this.core.getAvailableForecastHours();
199
750
  const firstValidHour = availableHours.find(h => h !== 0);
200
751
  if (firstValidHour !== undefined) {
201
752
  state = { ...state, forecastHour: firstValidHour };
202
753
  this.core.state.forecastHour = firstValidHour;
754
+ } else {
755
+ return;
203
756
  }
204
757
  }
205
758
 
@@ -209,12 +762,39 @@ export class WeatherLayerManager extends EventEmitter {
209
762
 
210
763
  this.shaderLayer = new GridRenderLayer(this.layerId);
211
764
  this.shaderLayer.runKey = runKey;
212
- this.map.addLayer(this.shaderLayer, 'AML_-_terrain');
765
+ const beforeId = this._weatherInsertBeforeId();
766
+ if (beforeId) {
767
+ this.map.addLayer(this.shaderLayer, beforeId);
768
+ } else {
769
+ this.map.addLayer(this.shaderLayer);
770
+ }
213
771
  this._updateLayerStyle(state);
214
772
 
215
- const grid = await this.core._loadGridData(state);
773
+ this._rebuildTargetTimeKey = state.isMRMS
774
+ ? (state.mrmsTimestamp == null ? null : Number(state.mrmsTimestamp))
775
+ : Number(state.forecastHour);
776
+ this._initialGridLoadPending = true;
216
777
 
217
- if (rebuildId !== this.currentRebuildId) return;
778
+ let grid;
779
+ try {
780
+ grid = await this.core._loadGridData(state);
781
+ } catch (e) {
782
+ this._initialGridLoadPending = false;
783
+ throw e;
784
+ }
785
+
786
+ if (rebuildId !== this.currentRebuildId) {
787
+ this._initialGridLoadPending = false;
788
+ return;
789
+ }
790
+
791
+ const coreTimeKey = state.isMRMS
792
+ ? (this.core.state.mrmsTimestamp == null ? null : Number(this.core.state.mrmsTimestamp))
793
+ : Number(this.core.state.forecastHour);
794
+ if (coreTimeKey !== this._rebuildTargetTimeKey) {
795
+ this._initialGridLoadPending = false;
796
+ return;
797
+ }
218
798
 
219
799
  if (grid && grid.data) {
220
800
  const gridModel = state.isMRMS ? 'mrms' : state.model;
@@ -225,32 +805,61 @@ export class WeatherLayerManager extends EventEmitter {
225
805
  gridDef.grid_params.nx, gridDef.grid_params.ny
226
806
  );
227
807
 
228
- this.currentLoadedTimeKey = state.isMRMS ? state.mrmsTimestamp : state.forecastHour;
229
- this.latestDataRequestToken = this.currentRebuildId;
808
+ this.currentLoadedTimeKey = state.isMRMS
809
+ ? (state.mrmsTimestamp == null ? null : Number(state.mrmsTimestamp))
810
+ : Number(state.forecastHour);
811
+ this.shaderLayer.registerCurrentDataTextureAsPreloaded(this.currentLoadedTimeKey);
230
812
  this.map.triggerRepaint();
231
813
  }
232
814
 
815
+ this._initialGridLoadPending = false;
816
+
233
817
  if (rebuildId === this.currentRebuildId) {
234
818
  this._preloadAllTimeSteps(state);
235
819
  }
236
820
  }
237
821
 
238
822
  _preloadAllTimeSteps(state) {
239
- const timeSteps = state.isMRMS ? state.availableTimestamps : state.availableHours;
240
- if (!timeSteps || timeSteps.length <= 1) {
241
- //console.log(`[WLM._preloadAllTimeSteps] skipping only ${timeSteps?.length ?? 0} time steps available`);
823
+ let fromCore = [];
824
+ try {
825
+ if (!state.isMRMS && typeof this.core.getAvailableForecastHours === 'function') {
826
+ fromCore = this.core.getAvailableForecastHours();
827
+ }
828
+ } catch (err) {
829
+ // ignore
830
+ }
831
+
832
+ const fromState = state.isMRMS
833
+ ? (state.availableTimestamps || [])
834
+ : (state.availableHours || []);
835
+
836
+ let timeSteps;
837
+ if (state.isMRMS) {
838
+ timeSteps = fromState.length ? fromState : fromCore;
839
+ } else {
840
+ timeSteps = fromCore.length > 0 ? fromCore : fromState;
841
+ }
842
+
843
+ const normalized = (timeSteps || [])
844
+ .map(t => Number(t))
845
+ .filter(t => !Number.isNaN(t));
846
+
847
+ const currentFrameTime = Number(state.isMRMS ? state.mrmsTimestamp : state.forecastHour);
848
+ const stepsToPreload = normalized.filter(t => t !== currentFrameTime);
849
+
850
+ if (normalized.length === 0) {
851
+ return;
852
+ }
853
+
854
+ if (stepsToPreload.length === 0) {
242
855
  return;
243
856
  }
244
857
 
245
- const currentFrameTime = state.isMRMS ? state.mrmsTimestamp : state.forecastHour;
246
- const stepsToPreload = timeSteps.filter(t => t !== currentFrameTime);
247
858
  const capturedRebuildId = this.currentRebuildId;
248
- //console.log(`[WLM._preloadAllTimeSteps] preloading ${stepsToPreload.length} frames (skipping currentFrameTime=${currentFrameTime}), capturedRebuildId=${capturedRebuildId}`);
249
859
 
250
860
  const gridModel = state.isMRMS ? 'mrms' : state.model;
251
861
  const { gridDef } = this.core._getGridCornersAndDef(gridModel);
252
862
 
253
- let completed = 0, failed = 0;
254
863
  stepsToPreload.forEach(time => {
255
864
  const stateForTime = {
256
865
  ...state,
@@ -259,29 +868,30 @@ export class WeatherLayerManager extends EventEmitter {
259
868
 
260
869
  this.core._loadGridData(stateForTime)
261
870
  .then(grid => {
262
- if (capturedRebuildId !== this.currentRebuildId) return;
871
+ if (capturedRebuildId !== this.currentRebuildId) {
872
+ return;
873
+ }
263
874
  if (grid?.data && this.shaderLayer) {
264
875
  this.shaderLayer.storePreloadedTexture(
265
876
  time, grid.data, grid.encoding,
266
877
  gridDef.grid_params.nx, gridDef.grid_params.ny
267
878
  );
268
- completed++;
269
- //console.log(`[WLM._preloadAllTimeSteps] stored texture for time=${time} (${completed}/${stepsToPreload.length} done, ${failed} failed)`);
270
- } else if (!grid?.data) {
271
- failed++;
272
- console.warn(`[WLM._preloadAllTimeSteps] null grid for time=${time} skipping texture store (${completed}/${stepsToPreload.length} done, ${failed} failed)`);
879
+ const s = this.core.state;
880
+ const activeTime = s.isMRMS
881
+ ? (s.mrmsTimestamp == null ? null : Number(s.mrmsTimestamp))
882
+ : Number(s.forecastHour);
883
+ if (time === activeTime && this.shaderLayer.switchToPreloadedTexture(time)) {
884
+ this.currentLoadedTimeKey = time;
885
+ this.map.triggerRepaint();
886
+ }
273
887
  }
274
888
  })
275
- .catch(e => {
276
- failed++;
277
- console.warn(`[WLM._preloadAllTimeSteps] fetch error for time=${time} (${completed}/${stepsToPreload.length} done, ${failed} failed):`, e);
278
- });
889
+ .catch(() => {});
279
890
  });
280
891
  }
281
892
 
282
- async _updateLayerData(state) {
283
- const requestToken = ++this.latestDataRequestToken;
284
- return this._updateLayerDataWithToken(state, requestToken);
893
+ _updateLayerData(state) {
894
+ return this._updateLayerDataWithToken(state);
285
895
  }
286
896
 
287
897
  _updateLayerStyle(state) {
@@ -302,11 +912,9 @@ export class WeatherLayerManager extends EventEmitter {
302
912
  let dataRange;
303
913
  if (state.variable === 'ptypeRefl' || state.variable === 'ptypeRate') {
304
914
  if (state.isMRMS) {
305
- //console.log('🔧 Using MRMS ptype data range: 5-380');
306
915
  dataRange = [5, 380];
307
916
  } else {
308
- //console.log('🔧 Using Model ptype data range: 5-380');
309
- dataRange = [5, 380]; // Models also use the full colormap range
917
+ dataRange = [5, 380];
310
918
  }
311
919
  } else {
312
920
  dataRange = [finalColormap[0], finalColormap[finalColormap.length - 2]];
@@ -328,18 +936,35 @@ export class WeatherLayerManager extends EventEmitter {
328
936
  * @private
329
937
  */
330
938
  async _handleMouseMove(e) {
331
- // This function's only job is to get the coordinates and call the core's
332
- // powerful utility method to do the actual work.
333
- const payload = await this.core.getValueAtLngLat(e.lngLat.lng, e.lngLat.lat);
939
+ const { lng, lat } = this._mouseLngLatFromEvent(e);
940
+ const state = this._lastEmittedState;
941
+ if (state?.isNexrad) {
942
+ if (this._nexradController) {
943
+ const payload = this._nexradController.getInspectPayload(lng, lat, state);
944
+ if (payload) {
945
+ this.emit('data:inspect', {
946
+ ...payload,
947
+ point: e.point,
948
+ });
949
+ } else {
950
+ this.emit('data:inspect', null);
951
+ }
952
+ } else {
953
+ this.emit('data:inspect', null);
954
+ }
955
+ return;
956
+ }
957
+
958
+ // Model / MRMS: delegate to the core grid sampler.
959
+ const payload = await this.core.getValueAtLngLat(lng, lat);
334
960
 
335
- // It then re-emits the final payload with the screen point attached.
336
961
  if (payload) {
337
- this.emit('data:inspect', {
338
- ...payload,
339
- point: e.point, // Add the screen coordinate for the UI
340
- });
962
+ this.emit('data:inspect', {
963
+ ...payload,
964
+ point: e.point,
965
+ });
341
966
  } else {
342
- this.emit('data:inspect', null);
967
+ this.emit('data:inspect', null);
343
968
  }
344
969
  }
345
970
 
@@ -377,8 +1002,6 @@ export class WeatherLayerManager extends EventEmitter {
377
1002
  * @returns {Promise<void>}
378
1003
  */
379
1004
  async refreshData() {
380
- //console.log('[WeatherLayerManager] Manual data refresh triggered.');
381
- // This just calls the internal logic we already built.
382
1005
  await this._checkForUpdates();
383
1006
  }
384
1007
 
@@ -388,7 +1011,58 @@ export class WeatherLayerManager extends EventEmitter {
388
1011
  * @private
389
1012
  */
390
1013
  async _checkForUpdates() {
391
- const { isMRMS, model: currentModel, variable: currentVariable, date, run } = this.core.state;
1014
+ const s = this.core.state;
1015
+ const { isMRMS, isSatellite, isNexrad, model: currentModel, variable: currentVariable, date, run } = s;
1016
+
1017
+ if (isSatellite && s.satelliteKey) {
1018
+ const prevTimeline = this.core._computeSatelliteTimeline();
1019
+ const prevTimes = [...(prevTimeline.unixTimes || [])]
1020
+ .map((t) => Number(t))
1021
+ .filter((t) => !Number.isNaN(t))
1022
+ .sort((a, b) => a - b);
1023
+ const prevMax = prevTimes.length ? prevTimes[prevTimes.length - 1] : null;
1024
+ const curSat = s.satelliteTimestamp == null ? null : Number(s.satelliteTimestamp);
1025
+
1026
+ await this.core.fetchSatelliteListing(true);
1027
+ this.core._emitStateChange();
1028
+
1029
+ const nextTimeline = this.core._computeSatelliteTimeline();
1030
+ const nextTimes = [...(nextTimeline.unixTimes || [])]
1031
+ .map((t) => Number(t))
1032
+ .filter((t) => !Number.isNaN(t))
1033
+ .sort((a, b) => a - b);
1034
+ const newMax = nextTimes.length ? nextTimes[nextTimes.length - 1] : null;
1035
+
1036
+ if (prevMax != null && curSat != null && curSat === prevMax && newMax != null && newMax > prevMax) {
1037
+ await this.core.setSatelliteTimestamp(newMax);
1038
+ } else if (curSat != null && newMax != null && nextTimes.length && !nextTimes.includes(curSat)) {
1039
+ await this.core.setSatelliteTimestamp(newMax);
1040
+ }
1041
+ this.emit('data:updated', { type: 'satellite', satelliteKey: s.satelliteKey });
1042
+ return;
1043
+ }
1044
+
1045
+ if (isNexrad && s.nexradSite) {
1046
+ const nk = this.core._nexradTimesCacheKey();
1047
+ const rawBefore = nk ? this.core.nexradTimesByStation[nk]?.unixTimes : [];
1048
+ const filteredBefore = this.core._getFilteredNexradTimestampsForVariable(rawBefore || []);
1049
+ const prevMax = filteredBefore.length ? filteredBefore[filteredBefore.length - 1] : null;
1050
+ const curNx = s.nexradTimestamp == null ? null : Number(s.nexradTimestamp);
1051
+
1052
+ await this.core.refreshNexradTimes();
1053
+
1054
+ const rawAfter = nk ? this.core.nexradTimesByStation[nk]?.unixTimes : [];
1055
+ const filteredAfter = this.core._getFilteredNexradTimestampsForVariable(rawAfter || []);
1056
+ const newMax = filteredAfter.length ? filteredAfter[filteredAfter.length - 1] : null;
1057
+
1058
+ if (prevMax != null && curNx != null && curNx === prevMax && newMax != null && newMax > prevMax) {
1059
+ await this.core.setNexradTimestamp(newMax);
1060
+ } else if (curNx != null && newMax != null && filteredAfter.length && !filteredAfter.includes(curNx)) {
1061
+ await this.core.setNexradTimestamp(newMax);
1062
+ }
1063
+ this.emit('data:updated', { type: 'nexrad', nexradSite: s.nexradSite });
1064
+ return;
1065
+ }
392
1066
 
393
1067
  if (isMRMS) {
394
1068
  const oldTimestamps = new Set(this.core.mrmsStatus?.[currentVariable] || []);
@@ -410,7 +1084,7 @@ export class WeatherLayerManager extends EventEmitter {
410
1084
  newTimestampsToPreload.forEach(timestamp => {
411
1085
  const stateForPreload = { ...this.core.state, mrmsTimestamp: timestamp };
412
1086
  this.core._loadGridData(stateForPreload)
413
- .catch(e => console.warn(`[Auto-Refresh] Failed to preload MRMS frame for time ${timestamp}`, e));
1087
+ .catch(() => {});
414
1088
  });
415
1089
 
416
1090
  // 5. Clean up the cache for any timestamps that are no longer available.
@@ -425,8 +1099,14 @@ export class WeatherLayerManager extends EventEmitter {
425
1099
  this.emit('data:updated', { type: 'mrms', variable: currentVariable, newTimestamps: newTimestampsToPreload });
426
1100
  }
427
1101
  } else {
1102
+ const previousStatus = this.core.modelStatus;
428
1103
  const modelStatus = await this.core.fetchModelStatus(true);
429
- this.core._emitStateChange();
1104
+
1105
+ // Only emit if the status actually changed
1106
+ const statusChanged = JSON.stringify(previousStatus) !== JSON.stringify(modelStatus);
1107
+ if (statusChanged) {
1108
+ this.core._emitStateChange();
1109
+ }
430
1110
 
431
1111
  const latestRun = findLatestModelRun(modelStatus, currentModel);
432
1112
  if (!latestRun) return;
@@ -449,6 +1129,18 @@ export class WeatherLayerManager extends EventEmitter {
449
1129
  // 1. Unbind the map's mousemove event listener
450
1130
  this.map.off('mousemove', this._handleMouseMove);
451
1131
 
1132
+ if (this._nwsOverlay) {
1133
+ try {
1134
+ this._nwsOverlay.destroy();
1135
+ } catch {
1136
+ /* ignore */
1137
+ }
1138
+ this._nwsOverlay = null;
1139
+ }
1140
+
1141
+ this._tearDownSatellite();
1142
+ this._tearDownNexrad();
1143
+
452
1144
  // 2. Explicitly clean up and remove the visual layer
453
1145
  if (this.shaderLayer) {
454
1146
  // First, check if the layer still exists on the map before trying to remove it
@@ -469,7 +1161,5 @@ export class WeatherLayerManager extends EventEmitter {
469
1161
 
470
1162
  // 4. Clear any remaining internal state
471
1163
  this.currentLoadedTimeKey = null;
472
-
473
- //console.log(`WeatherLayerManager with id "${this.layerId}" has been destroyed.`);
474
1164
  }
475
1165
  }