@aguacerowx/mapsgl 0.0.32 → 0.0.42
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/index.js +34 -2
- package/package.json +14 -3
- package/src/GridRenderLayer.js +105 -86
- package/src/MapManager.js +47 -15
- package/src/NexradSitesOverlay.js +148 -0
- package/src/NexradWeatherController.js +491 -0
- package/src/NwsWatchesWarningsOverlay.js +768 -0
- package/src/SatelliteShaderManager.js +999 -0
- package/src/WeatherLayerManager.js +866 -139
- package/src/WorkerPool.js +340 -0
- package/src/nexrad/MapboxRadarLayer.bundled.js +810 -0
- package/src/nexrad/MapboxRadarLayer.ts +784 -0
- package/src/nexrad/PreprocessedSweepParser.ts +226 -0
- package/src/nexrad/buildRadarRayGeometry.ts +97 -0
- package/src/nexrad/level3StormRelative.ts +116 -0
- package/src/nexrad/loadNexradSites.ts +41 -0
- package/src/nexrad/nexradArchiveCache.ts +64 -0
- package/src/nexrad/nexradCrossSectionSampleAtLatLon.bundled.js +91 -0
- package/src/nexrad/nexradCrossSectionSampleAtLatLon.ts +121 -0
- package/src/nexrad/nexradLevel3Products.ts +549 -0
- package/src/nexrad/nexradMapboxFrameOpts.js +106 -0
- package/src/nexrad/radarArchiveCore.bundled.js +4206 -0
- package/src/nexrad/radarArchiveCore.bundled.js.map +7 -0
- package/src/nexrad/radarArchiveCore.ts +1737 -0
- package/src/nexrad/radarDecode.worker.bundled.js +809 -0
- package/src/nexrad/radarDecode.worker.ts +227 -0
- package/src/nexrad/radarFrameGpuMatch.bundled.js +79 -0
- package/src/nexrad/radarFrameGpuMatch.ts +111 -0
- package/src/nwsAlertsSupport.js +860 -0
- package/src/nwsEventColorsDefaults.js +133 -0
- package/src/nwsSdkConstants.js +360 -0
- package/src/nwsWarningCustomizationKey.gen.js +496 -0
- package/src/satelliteDefaultColormaps.js +37 -0
- package/src/satelliteKtxWorker.js +225 -0
- 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 =
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
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;
|
|
80
407
|
}
|
|
408
|
+
this._satelliteRunKey = null;
|
|
409
|
+
this._satellitePreloadedTimelineId = null;
|
|
410
|
+
}
|
|
81
411
|
|
|
412
|
+
_handleSatelliteStateChange(state) {
|
|
413
|
+
this._tearDownNexrad();
|
|
82
414
|
if (this.shaderLayer) {
|
|
83
|
-
this.
|
|
84
|
-
|
|
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,200 @@ 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
|
-
|
|
123
|
-
|
|
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
|
|
131
|
-
|
|
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
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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.
|
|
723
|
+
}
|
|
145
724
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
725
|
+
/**
|
|
726
|
+
* MRMS timestamps or model forecast hours for the active timeline (ordering preserved from source lists).
|
|
727
|
+
* @returns {number[]}
|
|
728
|
+
*/
|
|
729
|
+
_collectNormalizedTimelineSteps(state) {
|
|
730
|
+
let fromCore = [];
|
|
731
|
+
try {
|
|
732
|
+
if (!state.isMRMS && typeof this.core.getAvailableForecastHours === 'function') {
|
|
733
|
+
fromCore = this.core.getAvailableForecastHours();
|
|
149
734
|
}
|
|
735
|
+
} catch (err) {
|
|
736
|
+
// ignore
|
|
737
|
+
}
|
|
150
738
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
//console.log(`[WLM._updateLayerDataWithToken] token=${requestToken} — grid received in ${fetchMs}ms, storing+switching texture for timeKey=${timeKey}`);
|
|
739
|
+
const fromState = state.isMRMS
|
|
740
|
+
? (state.availableTimestamps || [])
|
|
741
|
+
: (state.availableHours || []);
|
|
155
742
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
743
|
+
let timeSteps;
|
|
744
|
+
if (state.isMRMS) {
|
|
745
|
+
timeSteps = fromState.length ? fromState : fromCore;
|
|
746
|
+
} else {
|
|
747
|
+
timeSteps = fromCore.length > 0 ? fromCore : fromState;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
return (timeSteps || [])
|
|
751
|
+
.map(t => Number(t))
|
|
752
|
+
.filter(t => !Number.isNaN(t));
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* @param {object} state
|
|
757
|
+
* @param {number[]} times
|
|
758
|
+
* @param {{ rebuildId: number, mode: 'rebuild' | 'append' }} options
|
|
759
|
+
*/
|
|
760
|
+
_runParallelGridFrameLoads(state, times, options) {
|
|
761
|
+
const { rebuildId, mode } = options;
|
|
762
|
+
if (!times.length || !this.shaderLayer) {
|
|
763
|
+
if (mode === 'rebuild') {
|
|
764
|
+
this._initialGridLoadPending = false;
|
|
174
765
|
}
|
|
766
|
+
return;
|
|
175
767
|
}
|
|
768
|
+
|
|
769
|
+
const currentFrameTime = Number(state.isMRMS ? state.mrmsTimestamp : state.forecastHour);
|
|
770
|
+
/** When the active timestep is unset/NaN, paint the first timeline step (legacy single-fetch behavior). */
|
|
771
|
+
let primaryTimeForRebuild = Number.NaN;
|
|
772
|
+
if (mode === 'rebuild') {
|
|
773
|
+
primaryTimeForRebuild = Number.isFinite(currentFrameTime)
|
|
774
|
+
? currentFrameTime
|
|
775
|
+
: times[0];
|
|
776
|
+
}
|
|
777
|
+
const tsKey = state.isMRMS ? 'mrmsTimestamp' : 'forecastHour';
|
|
778
|
+
const gridModel = state.isMRMS ? 'mrms' : state.model;
|
|
779
|
+
const { gridDef } = this.core._getGridCornersAndDef(gridModel);
|
|
780
|
+
|
|
781
|
+
times.forEach((time) => {
|
|
782
|
+
const stateForTime = { ...state, [tsKey]: time };
|
|
783
|
+
this.core._loadGridData(stateForTime)
|
|
784
|
+
.then((grid) => {
|
|
785
|
+
if (rebuildId !== this.currentRebuildId || !this.shaderLayer) {
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
const isPrimaryFrame =
|
|
790
|
+
mode === 'rebuild' &&
|
|
791
|
+
Number.isFinite(primaryTimeForRebuild) &&
|
|
792
|
+
time === primaryTimeForRebuild;
|
|
793
|
+
|
|
794
|
+
if (isPrimaryFrame) {
|
|
795
|
+
const coreTimeKey = state.isMRMS
|
|
796
|
+
? (this.core.state.mrmsTimestamp == null
|
|
797
|
+
? null
|
|
798
|
+
: Number(this.core.state.mrmsTimestamp))
|
|
799
|
+
: Number(this.core.state.forecastHour);
|
|
800
|
+
if (coreTimeKey !== this._rebuildTargetTimeKey) {
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (!grid?.data) {
|
|
804
|
+
this._initialGridLoadPending = false;
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
this.shaderLayer.updateDataTexture(
|
|
808
|
+
grid.data,
|
|
809
|
+
grid.encoding,
|
|
810
|
+
gridDef.grid_params.nx,
|
|
811
|
+
gridDef.grid_params.ny,
|
|
812
|
+
);
|
|
813
|
+
this.currentLoadedTimeKey = time;
|
|
814
|
+
this.shaderLayer.registerCurrentDataTextureAsPreloaded(time);
|
|
815
|
+
this._initialGridLoadPending = false;
|
|
816
|
+
this.map.triggerRepaint();
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
if (grid?.data) {
|
|
821
|
+
this.shaderLayer.storePreloadedTexture(
|
|
822
|
+
time,
|
|
823
|
+
grid.data,
|
|
824
|
+
grid.encoding,
|
|
825
|
+
gridDef.grid_params.nx,
|
|
826
|
+
gridDef.grid_params.ny,
|
|
827
|
+
);
|
|
828
|
+
const s = this.core.state;
|
|
829
|
+
const activeTime = s.isMRMS
|
|
830
|
+
? (s.mrmsTimestamp == null ? null : Number(s.mrmsTimestamp))
|
|
831
|
+
: Number(s.forecastHour);
|
|
832
|
+
if (time === activeTime && this.shaderLayer.switchToPreloadedTexture(time)) {
|
|
833
|
+
this.currentLoadedTimeKey = time;
|
|
834
|
+
this.map.triggerRepaint();
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
})
|
|
838
|
+
.catch(() => {
|
|
839
|
+
if (
|
|
840
|
+
mode === 'rebuild' &&
|
|
841
|
+
Number.isFinite(primaryTimeForRebuild) &&
|
|
842
|
+
time === primaryTimeForRebuild
|
|
843
|
+
) {
|
|
844
|
+
this._initialGridLoadPending = false;
|
|
845
|
+
}
|
|
846
|
+
});
|
|
847
|
+
});
|
|
176
848
|
}
|
|
177
849
|
|
|
178
|
-
|
|
850
|
+
_rebuildLayerAndPreload(state) {
|
|
851
|
+
if (state.isSatellite) {
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
if (!state.variable) {
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
|
|
179
858
|
const rebuildId = ++this.currentRebuildId;
|
|
180
859
|
|
|
181
860
|
this.core.cancelAllRequests();
|
|
@@ -186,23 +865,18 @@ export class WeatherLayerManager extends EventEmitter {
|
|
|
186
865
|
}
|
|
187
866
|
this.currentLoadedTimeKey = null;
|
|
188
867
|
|
|
189
|
-
if (!state.variable) return;
|
|
190
|
-
|
|
191
868
|
if (
|
|
192
869
|
!state.isMRMS &&
|
|
193
870
|
state.forecastHour === 0 &&
|
|
194
871
|
state.variable === 'ptypeRefl' &&
|
|
195
872
|
state.model === 'hrrr'
|
|
196
873
|
) {
|
|
197
|
-
const availableHours =
|
|
198
|
-
this.core.modelStatus?.[state.model]?.[state.date]?.[state.run] || [];
|
|
874
|
+
const availableHours = this.core.getAvailableForecastHours();
|
|
199
875
|
const firstValidHour = availableHours.find(h => h !== 0);
|
|
200
876
|
if (firstValidHour !== undefined) {
|
|
201
877
|
state = { ...state, forecastHour: firstValidHour };
|
|
202
878
|
this.core.state.forecastHour = firstValidHour;
|
|
203
879
|
} else {
|
|
204
|
-
// Run exists but has no valid hours yet — bail out entirely
|
|
205
|
-
console.warn(`[WLM._rebuildLayerAndPreload] rebuildId=${rebuildId} — ptypeRefl/HRRR: no valid hours in new run yet, aborting rebuild`);
|
|
206
880
|
return;
|
|
207
881
|
}
|
|
208
882
|
}
|
|
@@ -213,79 +887,58 @@ export class WeatherLayerManager extends EventEmitter {
|
|
|
213
887
|
|
|
214
888
|
this.shaderLayer = new GridRenderLayer(this.layerId);
|
|
215
889
|
this.shaderLayer.runKey = runKey;
|
|
216
|
-
this.
|
|
890
|
+
const beforeId = this._weatherInsertBeforeId();
|
|
891
|
+
if (beforeId) {
|
|
892
|
+
this.map.addLayer(this.shaderLayer, beforeId);
|
|
893
|
+
} else {
|
|
894
|
+
this.map.addLayer(this.shaderLayer);
|
|
895
|
+
}
|
|
217
896
|
this._updateLayerStyle(state);
|
|
218
897
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
if (grid && grid.data) {
|
|
224
|
-
const gridModel = state.isMRMS ? 'mrms' : state.model;
|
|
225
|
-
const { gridDef } = this.core._getGridCornersAndDef(gridModel);
|
|
898
|
+
this._rebuildTargetTimeKey = state.isMRMS
|
|
899
|
+
? (state.mrmsTimestamp == null ? null : Number(state.mrmsTimestamp))
|
|
900
|
+
: Number(state.forecastHour);
|
|
901
|
+
this._initialGridLoadPending = true;
|
|
226
902
|
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
903
|
+
const normalized = this._collectNormalizedTimelineSteps(state);
|
|
904
|
+
const currentFrameTime = Number(state.isMRMS ? state.mrmsTimestamp : state.forecastHour);
|
|
905
|
+
const timesSet = new Set(normalized);
|
|
906
|
+
if (!Number.isNaN(currentFrameTime)) {
|
|
907
|
+
timesSet.add(currentFrameTime);
|
|
908
|
+
}
|
|
909
|
+
let timesToLoad = [...timesSet];
|
|
910
|
+
if (timesToLoad.length === 0 && !Number.isNaN(currentFrameTime)) {
|
|
911
|
+
timesToLoad = [currentFrameTime];
|
|
912
|
+
}
|
|
913
|
+
if (timesToLoad.length === 0) {
|
|
914
|
+
this._initialGridLoadPending = false;
|
|
915
|
+
return;
|
|
235
916
|
}
|
|
236
917
|
|
|
237
918
|
if (rebuildId === this.currentRebuildId) {
|
|
238
|
-
this.
|
|
919
|
+
this._runParallelGridFrameLoads(state, timesToLoad, { rebuildId, mode: 'rebuild' });
|
|
239
920
|
}
|
|
240
921
|
}
|
|
241
|
-
|
|
922
|
+
|
|
242
923
|
_preloadAllTimeSteps(state) {
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
924
|
+
const normalized = this._collectNormalizedTimelineSteps(state);
|
|
925
|
+
const currentFrameTime = Number(state.isMRMS ? state.mrmsTimestamp : state.forecastHour);
|
|
926
|
+
const stepsToPreload = normalized.filter(t => t !== currentFrameTime);
|
|
927
|
+
|
|
928
|
+
if (normalized.length === 0) {
|
|
246
929
|
return;
|
|
247
930
|
}
|
|
248
931
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
//console.log(`[WLM._preloadAllTimeSteps] preloading ${stepsToPreload.length} frames (skipping currentFrameTime=${currentFrameTime}), capturedRebuildId=${capturedRebuildId}`);
|
|
253
|
-
|
|
254
|
-
const gridModel = state.isMRMS ? 'mrms' : state.model;
|
|
255
|
-
const { gridDef } = this.core._getGridCornersAndDef(gridModel);
|
|
256
|
-
|
|
257
|
-
let completed = 0, failed = 0;
|
|
258
|
-
stepsToPreload.forEach(time => {
|
|
259
|
-
const stateForTime = {
|
|
260
|
-
...state,
|
|
261
|
-
[state.isMRMS ? 'mrmsTimestamp' : 'forecastHour']: time
|
|
262
|
-
};
|
|
932
|
+
if (stepsToPreload.length === 0) {
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
263
935
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
if (capturedRebuildId !== this.currentRebuildId) return;
|
|
267
|
-
if (grid?.data && this.shaderLayer) {
|
|
268
|
-
this.shaderLayer.storePreloadedTexture(
|
|
269
|
-
time, grid.data, grid.encoding,
|
|
270
|
-
gridDef.grid_params.nx, gridDef.grid_params.ny
|
|
271
|
-
);
|
|
272
|
-
completed++;
|
|
273
|
-
//console.log(`[WLM._preloadAllTimeSteps] stored texture for time=${time} (${completed}/${stepsToPreload.length} done, ${failed} failed)`);
|
|
274
|
-
} else if (!grid?.data) {
|
|
275
|
-
failed++;
|
|
276
|
-
console.warn(`[WLM._preloadAllTimeSteps] null grid for time=${time} — skipping texture store (${completed}/${stepsToPreload.length} done, ${failed} failed)`);
|
|
277
|
-
}
|
|
278
|
-
})
|
|
279
|
-
.catch(e => {
|
|
280
|
-
failed++;
|
|
281
|
-
console.warn(`[WLM._preloadAllTimeSteps] fetch error for time=${time} (${completed}/${stepsToPreload.length} done, ${failed} failed):`, e);
|
|
282
|
-
});
|
|
283
|
-
});
|
|
936
|
+
const capturedRebuildId = this.currentRebuildId;
|
|
937
|
+
this._runParallelGridFrameLoads(state, stepsToPreload, { rebuildId: capturedRebuildId, mode: 'append' });
|
|
284
938
|
}
|
|
285
939
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
return this._updateLayerDataWithToken(state, requestToken);
|
|
940
|
+
_updateLayerData(state) {
|
|
941
|
+
return this._updateLayerDataWithToken(state);
|
|
289
942
|
}
|
|
290
943
|
|
|
291
944
|
_updateLayerStyle(state) {
|
|
@@ -306,11 +959,9 @@ export class WeatherLayerManager extends EventEmitter {
|
|
|
306
959
|
let dataRange;
|
|
307
960
|
if (state.variable === 'ptypeRefl' || state.variable === 'ptypeRate') {
|
|
308
961
|
if (state.isMRMS) {
|
|
309
|
-
//console.log('🔧 Using MRMS ptype data range: 5-380');
|
|
310
962
|
dataRange = [5, 380];
|
|
311
963
|
} else {
|
|
312
|
-
|
|
313
|
-
dataRange = [5, 380]; // Models also use the full colormap range
|
|
964
|
+
dataRange = [5, 380];
|
|
314
965
|
}
|
|
315
966
|
} else {
|
|
316
967
|
dataRange = [finalColormap[0], finalColormap[finalColormap.length - 2]];
|
|
@@ -332,18 +983,35 @@ export class WeatherLayerManager extends EventEmitter {
|
|
|
332
983
|
* @private
|
|
333
984
|
*/
|
|
334
985
|
async _handleMouseMove(e) {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
986
|
+
const { lng, lat } = this._mouseLngLatFromEvent(e);
|
|
987
|
+
const state = this._lastEmittedState;
|
|
988
|
+
if (state?.isNexrad) {
|
|
989
|
+
if (this._nexradController) {
|
|
990
|
+
const payload = this._nexradController.getInspectPayload(lng, lat, state);
|
|
991
|
+
if (payload) {
|
|
992
|
+
this.emit('data:inspect', {
|
|
993
|
+
...payload,
|
|
994
|
+
point: e.point,
|
|
995
|
+
});
|
|
996
|
+
} else {
|
|
997
|
+
this.emit('data:inspect', null);
|
|
998
|
+
}
|
|
999
|
+
} else {
|
|
1000
|
+
this.emit('data:inspect', null);
|
|
1001
|
+
}
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// Model / MRMS: delegate to the core grid sampler.
|
|
1006
|
+
const payload = await this.core.getValueAtLngLat(lng, lat);
|
|
338
1007
|
|
|
339
|
-
// It then re-emits the final payload with the screen point attached.
|
|
340
1008
|
if (payload) {
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
1009
|
+
this.emit('data:inspect', {
|
|
1010
|
+
...payload,
|
|
1011
|
+
point: e.point,
|
|
1012
|
+
});
|
|
345
1013
|
} else {
|
|
346
|
-
|
|
1014
|
+
this.emit('data:inspect', null);
|
|
347
1015
|
}
|
|
348
1016
|
}
|
|
349
1017
|
|
|
@@ -381,8 +1049,6 @@ export class WeatherLayerManager extends EventEmitter {
|
|
|
381
1049
|
* @returns {Promise<void>}
|
|
382
1050
|
*/
|
|
383
1051
|
async refreshData() {
|
|
384
|
-
//console.log('[WeatherLayerManager] Manual data refresh triggered.');
|
|
385
|
-
// This just calls the internal logic we already built.
|
|
386
1052
|
await this._checkForUpdates();
|
|
387
1053
|
}
|
|
388
1054
|
|
|
@@ -392,7 +1058,58 @@ export class WeatherLayerManager extends EventEmitter {
|
|
|
392
1058
|
* @private
|
|
393
1059
|
*/
|
|
394
1060
|
async _checkForUpdates() {
|
|
395
|
-
const
|
|
1061
|
+
const s = this.core.state;
|
|
1062
|
+
const { isMRMS, isSatellite, isNexrad, model: currentModel, variable: currentVariable, date, run } = s;
|
|
1063
|
+
|
|
1064
|
+
if (isSatellite && s.satelliteKey) {
|
|
1065
|
+
const prevTimeline = this.core._computeSatelliteTimeline();
|
|
1066
|
+
const prevTimes = [...(prevTimeline.unixTimes || [])]
|
|
1067
|
+
.map((t) => Number(t))
|
|
1068
|
+
.filter((t) => !Number.isNaN(t))
|
|
1069
|
+
.sort((a, b) => a - b);
|
|
1070
|
+
const prevMax = prevTimes.length ? prevTimes[prevTimes.length - 1] : null;
|
|
1071
|
+
const curSat = s.satelliteTimestamp == null ? null : Number(s.satelliteTimestamp);
|
|
1072
|
+
|
|
1073
|
+
await this.core.fetchSatelliteListing(true);
|
|
1074
|
+
this.core._emitStateChange();
|
|
1075
|
+
|
|
1076
|
+
const nextTimeline = this.core._computeSatelliteTimeline();
|
|
1077
|
+
const nextTimes = [...(nextTimeline.unixTimes || [])]
|
|
1078
|
+
.map((t) => Number(t))
|
|
1079
|
+
.filter((t) => !Number.isNaN(t))
|
|
1080
|
+
.sort((a, b) => a - b);
|
|
1081
|
+
const newMax = nextTimes.length ? nextTimes[nextTimes.length - 1] : null;
|
|
1082
|
+
|
|
1083
|
+
if (prevMax != null && curSat != null && curSat === prevMax && newMax != null && newMax > prevMax) {
|
|
1084
|
+
await this.core.setSatelliteTimestamp(newMax);
|
|
1085
|
+
} else if (curSat != null && newMax != null && nextTimes.length && !nextTimes.includes(curSat)) {
|
|
1086
|
+
await this.core.setSatelliteTimestamp(newMax);
|
|
1087
|
+
}
|
|
1088
|
+
this.emit('data:updated', { type: 'satellite', satelliteKey: s.satelliteKey });
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
if (isNexrad && s.nexradSite) {
|
|
1093
|
+
const nk = this.core._nexradTimesCacheKey();
|
|
1094
|
+
const rawBefore = nk ? this.core.nexradTimesByStation[nk]?.unixTimes : [];
|
|
1095
|
+
const filteredBefore = this.core._getFilteredNexradTimestampsForVariable(rawBefore || []);
|
|
1096
|
+
const prevMax = filteredBefore.length ? filteredBefore[filteredBefore.length - 1] : null;
|
|
1097
|
+
const curNx = s.nexradTimestamp == null ? null : Number(s.nexradTimestamp);
|
|
1098
|
+
|
|
1099
|
+
await this.core.refreshNexradTimes();
|
|
1100
|
+
|
|
1101
|
+
const rawAfter = nk ? this.core.nexradTimesByStation[nk]?.unixTimes : [];
|
|
1102
|
+
const filteredAfter = this.core._getFilteredNexradTimestampsForVariable(rawAfter || []);
|
|
1103
|
+
const newMax = filteredAfter.length ? filteredAfter[filteredAfter.length - 1] : null;
|
|
1104
|
+
|
|
1105
|
+
if (prevMax != null && curNx != null && curNx === prevMax && newMax != null && newMax > prevMax) {
|
|
1106
|
+
await this.core.setNexradTimestamp(newMax);
|
|
1107
|
+
} else if (curNx != null && newMax != null && filteredAfter.length && !filteredAfter.includes(curNx)) {
|
|
1108
|
+
await this.core.setNexradTimestamp(newMax);
|
|
1109
|
+
}
|
|
1110
|
+
this.emit('data:updated', { type: 'nexrad', nexradSite: s.nexradSite });
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
396
1113
|
|
|
397
1114
|
if (isMRMS) {
|
|
398
1115
|
const oldTimestamps = new Set(this.core.mrmsStatus?.[currentVariable] || []);
|
|
@@ -414,7 +1131,7 @@ export class WeatherLayerManager extends EventEmitter {
|
|
|
414
1131
|
newTimestampsToPreload.forEach(timestamp => {
|
|
415
1132
|
const stateForPreload = { ...this.core.state, mrmsTimestamp: timestamp };
|
|
416
1133
|
this.core._loadGridData(stateForPreload)
|
|
417
|
-
.catch(
|
|
1134
|
+
.catch(() => {});
|
|
418
1135
|
});
|
|
419
1136
|
|
|
420
1137
|
// 5. Clean up the cache for any timestamps that are no longer available.
|
|
@@ -459,6 +1176,18 @@ export class WeatherLayerManager extends EventEmitter {
|
|
|
459
1176
|
// 1. Unbind the map's mousemove event listener
|
|
460
1177
|
this.map.off('mousemove', this._handleMouseMove);
|
|
461
1178
|
|
|
1179
|
+
if (this._nwsOverlay) {
|
|
1180
|
+
try {
|
|
1181
|
+
this._nwsOverlay.destroy();
|
|
1182
|
+
} catch {
|
|
1183
|
+
/* ignore */
|
|
1184
|
+
}
|
|
1185
|
+
this._nwsOverlay = null;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
this._tearDownSatellite();
|
|
1189
|
+
this._tearDownNexrad();
|
|
1190
|
+
|
|
462
1191
|
// 2. Explicitly clean up and remove the visual layer
|
|
463
1192
|
if (this.shaderLayer) {
|
|
464
1193
|
// First, check if the layer still exists on the map before trying to remove it
|
|
@@ -479,7 +1208,5 @@ export class WeatherLayerManager extends EventEmitter {
|
|
|
479
1208
|
|
|
480
1209
|
// 4. Clear any remaining internal state
|
|
481
1210
|
this.currentLoadedTimeKey = null;
|
|
482
|
-
|
|
483
|
-
//console.log(`WeatherLayerManager with id "${this.layerId}" has been destroyed.`);
|
|
484
1211
|
}
|
|
485
1212
|
}
|