@aguacerowx/mapsgl 0.0.53 → 0.0.54
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +31 -31
- package/src/NexradWeatherController.js +510 -510
- package/src/NwsWatchesWarningsOverlay.js +54 -3
- package/src/WeatherLayerManager.js +14 -1
- package/src/nexrad/nexradArchiveCache.ts +286 -66
- package/src/nexrad/nexradLevel3Products.ts +581 -581
- package/src/nexrad/radarArchiveCore.bundled.js +83 -5
- package/src/nwsAlertsFetchSpec.js +114 -0
- package/src/nwsAlertsSupport.js +28 -0
|
@@ -1,510 +1,510 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Connects AguaceroCore NEXRAD state to MapboxRadarLayer + archive fetch (aguacero-frontend parity).
|
|
3
|
-
* Preloads all timestamps on the active timeline (MRMS/satellite-style) so scrubbing does not trigger loads.
|
|
4
|
-
*/
|
|
5
|
-
import { getUnitConversionFunction, getDefaultRadarTilt } from '@aguacerowx/javascript-sdk';
|
|
6
|
-
import { fetchAndParseArchive, objectKeyToUrl, setNexradArchiveApiKey } from './nexrad/radarArchiveCore.bundled.js';
|
|
7
|
-
import { MapboxRadarLayer } from './nexrad/MapboxRadarLayer.bundled.js';
|
|
8
|
-
import { nexradBinGroupIdForKey, variableToNexradGroup } from '@aguacerowx/javascript-sdk';
|
|
9
|
-
import { sampleNexradFrameAtLatLon } from './nexrad/nexradCrossSectionSampleAtLatLon.bundled.js';
|
|
10
|
-
import { prepareRadarFrameForGpuReadout } from './nexrad/radarFrameGpuMatch.bundled.js';
|
|
11
|
-
import { mapboxFrameUploadOptionsForNexradState } from './nexrad/nexradMapboxFrameOpts.bundled.js';
|
|
12
|
-
|
|
13
|
-
function pickNearestLevel3ObjectKey(unixTime, timeToKeyMap, maxDeltaSec = 600) {
|
|
14
|
-
const direct = timeToKeyMap[String(unixTime)];
|
|
15
|
-
if (direct) return direct;
|
|
16
|
-
const entries = Object.entries(timeToKeyMap || {});
|
|
17
|
-
if (entries.length === 0) return null;
|
|
18
|
-
let bestKey = null;
|
|
19
|
-
let bestDelta = Infinity;
|
|
20
|
-
for (const [tStr, key] of entries) {
|
|
21
|
-
const t = Number(tStr);
|
|
22
|
-
if (!Number.isFinite(t)) continue;
|
|
23
|
-
const d = Math.abs(t - unixTime);
|
|
24
|
-
if (d < bestDelta) {
|
|
25
|
-
bestDelta = d;
|
|
26
|
-
bestKey = key;
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
if (bestKey == null) return null;
|
|
30
|
-
if (bestDelta > maxDeltaSec && Number.isFinite(maxDeltaSec)) return null;
|
|
31
|
-
return bestKey;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function isVelocityStyleRadarVar(radarVariable) {
|
|
35
|
-
return radarVariable === 'VEL' || radarVariable === 'SW' || radarVariable === 'N0G' || radarVariable === 'N0W';
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function velocityRangeToMs(rangeMin, rangeMax, displayUnit) {
|
|
39
|
-
if (rangeMin == null || rangeMax == null || !displayUnit || displayUnit.toLowerCase() === 'm/s' || displayUnit.toLowerCase() === 'ms') {
|
|
40
|
-
return [rangeMin, rangeMax];
|
|
41
|
-
}
|
|
42
|
-
const toMs = getUnitConversionFunction(displayUnit, 'm/s', 'nexrad_vel');
|
|
43
|
-
if (!toMs) return [rangeMin, rangeMax];
|
|
44
|
-
return [toMs(rangeMin), toMs(rangeMax)];
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function colormapToMs(colormap, radarVariable, displayUnit) {
|
|
48
|
-
if (!colormap || !Array.isArray(colormap) || colormap.length < 2) return colormap;
|
|
49
|
-
if (!isVelocityStyleRadarVar(radarVariable)) return colormap;
|
|
50
|
-
if (!displayUnit || displayUnit.toLowerCase() === 'm/s' || displayUnit.toLowerCase() === 'ms') return colormap;
|
|
51
|
-
const toMs = getUnitConversionFunction(
|
|
52
|
-
displayUnit,
|
|
53
|
-
'm/s',
|
|
54
|
-
radarVariable === 'SW' || radarVariable === 'N0W' ? 'nexrad_sw' : 'nexrad_vel',
|
|
55
|
-
);
|
|
56
|
-
if (!toMs) return colormap;
|
|
57
|
-
const out = [...colormap];
|
|
58
|
-
for (let i = 0; i < out.length; i += 2) {
|
|
59
|
-
const v = out[i];
|
|
60
|
-
if (typeof v === 'number' && Number.isFinite(v)) out[i] = toMs(v);
|
|
61
|
-
}
|
|
62
|
-
return out;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function radarShaderValueRange(rangeMin, rangeMax, radarVariable, units, nexradDataSource) {
|
|
66
|
-
const v = (radarVariable || '').toUpperCase();
|
|
67
|
-
if (isVelocityStyleRadarVar(v)) {
|
|
68
|
-
const [vmin, vmax] = velocityRangeToMs(rangeMin, rangeMax, units);
|
|
69
|
-
return [vmin ?? 0, vmax ?? 80];
|
|
70
|
-
}
|
|
71
|
-
if (nexradDataSource === 'level3' && (v === 'N0H' || v === 'HHC')) {
|
|
72
|
-
const lo = rangeMin ?? 0;
|
|
73
|
-
let hi = rangeMax ?? 11;
|
|
74
|
-
if (hi > 11) hi = 11;
|
|
75
|
-
if (hi < lo) hi = lo;
|
|
76
|
-
return [lo, hi];
|
|
77
|
-
}
|
|
78
|
-
if (v === 'KDP') {
|
|
79
|
-
const lo = rangeMin ?? -2;
|
|
80
|
-
let hi = rangeMax ?? 8;
|
|
81
|
-
if (hi < lo) hi = lo;
|
|
82
|
-
return [lo, hi];
|
|
83
|
-
}
|
|
84
|
-
return [rangeMin ?? 0, rangeMax ?? 80];
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/** Level-III digital / hybrid hydrometeor classification (aguacero-frontend `nexradLevel3IsHydrometeorClassification`). */
|
|
88
|
-
function nexradLevel3IsHydrometeorClassification(radarVariable) {
|
|
89
|
-
const v = (radarVariable || '').toUpperCase();
|
|
90
|
-
return v === 'N0H' || v === 'HHC';
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** NWS class labels for digital HC (indices 0–11). */
|
|
94
|
-
const NEXRAD_HYDROMETEOR_CLASS_LABELS = [
|
|
95
|
-
'Biological',
|
|
96
|
-
'Ground clutter',
|
|
97
|
-
'Ice crystals',
|
|
98
|
-
'Dry snow',
|
|
99
|
-
'Wet snow',
|
|
100
|
-
'Light rain',
|
|
101
|
-
'Heavy rain',
|
|
102
|
-
'Big drops',
|
|
103
|
-
'Graupel',
|
|
104
|
-
'Hail/rain',
|
|
105
|
-
'Large hail',
|
|
106
|
-
'Giant hail',
|
|
107
|
-
];
|
|
108
|
-
|
|
109
|
-
function nexradHydrometeorLabelForClassIndex(value) {
|
|
110
|
-
const idx = Math.round(Number(value));
|
|
111
|
-
if (idx >= 12) return null;
|
|
112
|
-
if (idx >= 0 && idx < NEXRAD_HYDROMETEOR_CLASS_LABELS.length) {
|
|
113
|
-
return NEXRAD_HYDROMETEOR_CLASS_LABELS[idx];
|
|
114
|
-
}
|
|
115
|
-
return `Class ${idx}`;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
function velocityMsToDisplay(valueMs, displayUnit) {
|
|
119
|
-
if (!displayUnit || displayUnit.toLowerCase() === 'm/s' || displayUnit.toLowerCase() === 'ms') {
|
|
120
|
-
return valueMs;
|
|
121
|
-
}
|
|
122
|
-
const fromMs = getUnitConversionFunction('m/s', displayUnit, 'nexrad_vel');
|
|
123
|
-
return fromMs ? fromMs(valueMs) : valueMs;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** Standard 4/3-earth beam height (km AGL at antenna) from slant range and elevation — matches frontend `beamHeightKm`. */
|
|
127
|
-
function beamHeightKm(slantRangeKm, elevDeg) {
|
|
128
|
-
const el = (elevDeg * Math.PI) / 180;
|
|
129
|
-
const ReKm = (6371000 * 4) / (3 * 1000);
|
|
130
|
-
const cosEl = Math.max(Math.cos(el), 0.05);
|
|
131
|
-
const s = slantRangeKm / cosEl;
|
|
132
|
-
return Math.sqrt(s * s + ReKm * ReKm + 2 * s * ReKm * Math.sin(el)) - ReKm;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
function formatNexradInspectNumeric(raw, radarVariable) {
|
|
136
|
-
const vu = (radarVariable || '').toUpperCase();
|
|
137
|
-
const vl = radarVariable || '';
|
|
138
|
-
if (vu.includes('RATE') || vl.toLowerCase().includes('rate')) {
|
|
139
|
-
return Number(raw.toFixed(3).replace(/\.?0+$/, ''));
|
|
140
|
-
}
|
|
141
|
-
if (vu === 'RHO' || vu === 'ZDR' || vl.includes('RhoHV') || vl.includes('Zdr')) {
|
|
142
|
-
return Number(raw.toFixed(2));
|
|
143
|
-
}
|
|
144
|
-
if (Math.abs(raw) < 10) return Number(raw.toFixed(1));
|
|
145
|
-
return Math.round(raw);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const PRELOAD_CONCURRENCY = 4;
|
|
149
|
-
|
|
150
|
-
export class NexradWeatherController {
|
|
151
|
-
constructor(map, core, options = {}) {
|
|
152
|
-
this.map = map;
|
|
153
|
-
this.core = core;
|
|
154
|
-
this.layerId = options.nexradLayerId || 'aguacero-nexrad-layer';
|
|
155
|
-
this.mapboxLayer = null;
|
|
156
|
-
/** Includes tilt/timestamp so elevation changes always re-upload (see {@link _syncIdentity}). */
|
|
157
|
-
this._lastSyncKey = null;
|
|
158
|
-
this._abort = null;
|
|
159
|
-
this._interpolateColormap = options.interpolateNexradColormap !== false;
|
|
160
|
-
this._gateSmoothing = options.nexradGateSmoothing === true;
|
|
161
|
-
/** Decoded radar frames keyed by fetchKey from _buildFetchParamsForUnix. */
|
|
162
|
-
this._frameCache = new Map();
|
|
163
|
-
this._preloadAbort = null;
|
|
164
|
-
this._preloadTimelineSig = null;
|
|
165
|
-
/** @type {(() => string | undefined) | null} */
|
|
166
|
-
this._getInsertBeforeLayerId = options.getInsertBeforeLayerId || null;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
destroy() {
|
|
170
|
-
this._abort?.abort();
|
|
171
|
-
this._abort = null;
|
|
172
|
-
this._preloadAbort?.abort();
|
|
173
|
-
this._preloadAbort = null;
|
|
174
|
-
this._frameCache.clear();
|
|
175
|
-
this._preloadTimelineSig = null;
|
|
176
|
-
if (this.mapboxLayer && this.map.getLayer(this.mapboxLayer.id)) {
|
|
177
|
-
this.map.removeLayer(this.mapboxLayer.id);
|
|
178
|
-
}
|
|
179
|
-
this.mapboxLayer = null;
|
|
180
|
-
this._lastSyncKey = null;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
_resolveListingContext(state) {
|
|
184
|
-
const nk = this.core._nexradTimesCacheKey?.();
|
|
185
|
-
const ent = nk ? this.core.nexradTimesByStation?.[nk] : null;
|
|
186
|
-
const timeToKeyMap = state.nexradTimeToKeyMap || ent?.timeToKeyMap || {};
|
|
187
|
-
const motionMap = state.nexradLevel3MotionTimeToKeyMap || ent?.level3MotionTimeToKeyMap || {};
|
|
188
|
-
return { nk, ent, timeToKeyMap, motionMap };
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
/**
|
|
192
|
-
* @param {object} state
|
|
193
|
-
* @param {number} unix
|
|
194
|
-
* @returns {{ objectKey: string, url: string, fetchKey: string, motionObjectKey: string | null, radarVar: string, radarSource: string, groupId: number } | null}
|
|
195
|
-
*/
|
|
196
|
-
_buildFetchParamsForUnix(state, unix) {
|
|
197
|
-
const radarVar = (state.nexradProduct || 'REF').toUpperCase();
|
|
198
|
-
const radarSource = state.nexradDataSource === 'level3' ? 'level3' : 'level2';
|
|
199
|
-
const groupId = nexradBinGroupIdForKey(variableToNexradGroup(radarVar));
|
|
200
|
-
const { timeToKeyMap, motionMap } = this._resolveListingContext(state);
|
|
201
|
-
const objectKey = timeToKeyMap[String(unix)];
|
|
202
|
-
if (!objectKey) return null;
|
|
203
|
-
|
|
204
|
-
let motionObjectKey = null;
|
|
205
|
-
if (radarVar === 'VEL' && state.nexradStormRelative) {
|
|
206
|
-
motionObjectKey = pickNearestLevel3ObjectKey(unix, motionMap);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const url = objectKeyToUrl(objectKey, radarSource);
|
|
210
|
-
const fetchKey = `${url}|${radarVar}|${radarSource}|${motionObjectKey || ''}`;
|
|
211
|
-
return { objectKey, url, fetchKey, motionObjectKey, radarVar, radarSource, groupId };
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
async _fetchFrame(state, unix, { signal, priority }) {
|
|
215
|
-
const p = this._buildFetchParamsForUnix(state, unix);
|
|
216
|
-
if (!p) return null;
|
|
217
|
-
return fetchAndParseArchive(p.url, p.objectKey, p.radarVar, p.groupId, p.radarSource, {
|
|
218
|
-
signal,
|
|
219
|
-
priority,
|
|
220
|
-
level3MotionObjectKey: p.motionObjectKey,
|
|
221
|
-
});
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
_preloadTimelineSignature(state) {
|
|
225
|
-
const nk = this.core._nexradTimesCacheKey?.() || '';
|
|
226
|
-
const ts = [...(state.availableNexradTimestamps || [])]
|
|
227
|
-
.map(Number)
|
|
228
|
-
.filter((t) => Number.isFinite(t))
|
|
229
|
-
.sort((a, b) => a - b)
|
|
230
|
-
.join(',');
|
|
231
|
-
const tilt =
|
|
232
|
-
state.nexradTilt != null && Number.isFinite(Number(state.nexradTilt))
|
|
233
|
-
? Number(state.nexradTilt).toFixed(3)
|
|
234
|
-
: '';
|
|
235
|
-
return `${nk}|${ts}|sr:${state.nexradStormRelative ? 1 : 0}|tilt:${tilt}`;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* Identity for whether the visible radar frame + upload options match (tilt affects L3 mesh keys and listing object paths).
|
|
240
|
-
* @param {object} state
|
|
241
|
-
* @param {{ fetchKey: string }} p
|
|
242
|
-
* @param {number} unix
|
|
243
|
-
*/
|
|
244
|
-
_syncIdentity(state, p, unix) {
|
|
245
|
-
const tilt =
|
|
246
|
-
state.nexradTilt != null && Number.isFinite(Number(state.nexradTilt))
|
|
247
|
-
? Number(state.nexradTilt).toFixed(3)
|
|
248
|
-
: '';
|
|
249
|
-
return `${p.fetchKey}|tilt:${tilt}|u:${unix}`;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/**
|
|
253
|
-
* Background-load every volume on the slider timeline (like satellite KTX2 preload).
|
|
254
|
-
* Slider / `nexradTimestamp` changes only swap GPU buffers from cache when possible.
|
|
255
|
-
* @param {object} state
|
|
256
|
-
*/
|
|
257
|
-
preloadAllAvailable(state) {
|
|
258
|
-
if (!state.isNexrad || !state.nexradSite) return;
|
|
259
|
-
|
|
260
|
-
const sig = this._preloadTimelineSignature(state);
|
|
261
|
-
if (sig === this._preloadTimelineSig) return;
|
|
262
|
-
|
|
263
|
-
this._preloadTimelineSig = sig;
|
|
264
|
-
this._preloadAbort?.abort();
|
|
265
|
-
this._preloadAbort = new AbortController();
|
|
266
|
-
const signal = this._preloadAbort.signal;
|
|
267
|
-
|
|
268
|
-
this._frameCache.clear();
|
|
269
|
-
|
|
270
|
-
setNexradArchiveApiKey(this.core.apiKey || '');
|
|
271
|
-
|
|
272
|
-
const times = [...(state.availableNexradTimestamps || [])]
|
|
273
|
-
.map(Number)
|
|
274
|
-
.filter((t) => Number.isFinite(t));
|
|
275
|
-
if (!times.length) return;
|
|
276
|
-
|
|
277
|
-
const snapshot = { ...state };
|
|
278
|
-
|
|
279
|
-
const run = async () => {
|
|
280
|
-
let cursor = 0;
|
|
281
|
-
const work = async () => {
|
|
282
|
-
while (cursor < times.length && !signal.aborted) {
|
|
283
|
-
const i = cursor++;
|
|
284
|
-
const unix = times[i];
|
|
285
|
-
const p = this._buildFetchParamsForUnix(snapshot, unix);
|
|
286
|
-
if (!p || signal.aborted) continue;
|
|
287
|
-
if (this._frameCache.has(p.fetchKey)) continue;
|
|
288
|
-
try {
|
|
289
|
-
const frame = await this._fetchFrame(snapshot, unix, {
|
|
290
|
-
signal,
|
|
291
|
-
priority: 'prefetch',
|
|
292
|
-
});
|
|
293
|
-
if (frame && !signal.aborted) {
|
|
294
|
-
this._frameCache.set(p.fetchKey, frame);
|
|
295
|
-
}
|
|
296
|
-
} catch {
|
|
297
|
-
/* ignore failed preload tiles */
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
};
|
|
301
|
-
await Promise.all(Array.from({ length: PRELOAD_CONCURRENCY }, () => work()));
|
|
302
|
-
};
|
|
303
|
-
|
|
304
|
-
void run();
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
_ensureLayer() {
|
|
308
|
-
if (this.mapboxLayer) return;
|
|
309
|
-
this.mapboxLayer = new MapboxRadarLayer(this.layerId);
|
|
310
|
-
let beforeId =
|
|
311
|
-
typeof this._getInsertBeforeLayerId === 'function'
|
|
312
|
-
? this._getInsertBeforeLayerId()
|
|
313
|
-
: undefined;
|
|
314
|
-
if (!beforeId && this.map.getLayer('AML_-_terrain')) {
|
|
315
|
-
beforeId = 'AML_-_terrain';
|
|
316
|
-
}
|
|
317
|
-
if (beforeId) {
|
|
318
|
-
this.map.addLayer(this.mapboxLayer, beforeId);
|
|
319
|
-
} else {
|
|
320
|
-
this.map.addLayer(this.mapboxLayer);
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
_uploadFrame(state, frame) {
|
|
325
|
-
const radarVar = (state.nexradProduct || 'REF').toUpperCase();
|
|
326
|
-
this.mapboxLayer.setInterpolateColormap(this._interpolateColormap);
|
|
327
|
-
this.mapboxLayer.setGateSmoothing(this._gateSmoothing);
|
|
328
|
-
|
|
329
|
-
const colormapFlat = state.colormap;
|
|
330
|
-
const colormapForShader = colormapToMs(colormapFlat, radarVar, state.units);
|
|
331
|
-
const [shaderMin, shaderMax] = radarShaderValueRange(
|
|
332
|
-
colormapFlat?.[0],
|
|
333
|
-
colormapFlat?.[colormapFlat.length - 2],
|
|
334
|
-
radarVar,
|
|
335
|
-
state.units,
|
|
336
|
-
state.nexradDataSource,
|
|
337
|
-
);
|
|
338
|
-
|
|
339
|
-
this.mapboxLayer.setColormap(colormapForShader);
|
|
340
|
-
this.mapboxLayer.setValueRange(shaderMin, shaderMax);
|
|
341
|
-
this.mapboxLayer.setOpacity(state.opacity ?? 1);
|
|
342
|
-
this.mapboxLayer.setFrameData(frame, mapboxFrameUploadOptionsForNexradState(state));
|
|
343
|
-
this.map.triggerRepaint();
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/**
|
|
347
|
-
* @param {object} state - core state + emitted colormap (from state:change)
|
|
348
|
-
*/
|
|
349
|
-
async sync(state) {
|
|
350
|
-
if (!state.isNexrad || !state.nexradSite || state.nexradTimestamp == null) {
|
|
351
|
-
this.destroy();
|
|
352
|
-
return;
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
setNexradArchiveApiKey(this.core.apiKey || '');
|
|
356
|
-
|
|
357
|
-
const unix = Number(state.nexradTimestamp);
|
|
358
|
-
const p = this._buildFetchParamsForUnix(state, unix);
|
|
359
|
-
if (!p) {
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
const syncKey = this._syncIdentity(state, p, unix);
|
|
364
|
-
if (syncKey === this._lastSyncKey && this.mapboxLayer) {
|
|
365
|
-
this._applyStyle(state);
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
const cached = this._frameCache.get(p.fetchKey);
|
|
370
|
-
if (cached) {
|
|
371
|
-
this._ensureLayer();
|
|
372
|
-
this._uploadFrame(state, cached);
|
|
373
|
-
this._lastSyncKey = syncKey;
|
|
374
|
-
return;
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
this._abort?.abort();
|
|
378
|
-
this._abort = new AbortController();
|
|
379
|
-
|
|
380
|
-
let frame;
|
|
381
|
-
try {
|
|
382
|
-
frame = await this._fetchFrame(state, unix, {
|
|
383
|
-
signal: this._abort.signal,
|
|
384
|
-
priority: 'display',
|
|
385
|
-
});
|
|
386
|
-
} catch {
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
if (!frame || this._abort.signal.aborted) return;
|
|
391
|
-
|
|
392
|
-
this._frameCache.set(p.fetchKey, frame);
|
|
393
|
-
|
|
394
|
-
this._ensureLayer();
|
|
395
|
-
this._uploadFrame(state, frame);
|
|
396
|
-
this._lastSyncKey = syncKey;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
_applyStyle(state) {
|
|
400
|
-
if (!this.mapboxLayer) return;
|
|
401
|
-
const radarVar = (state.nexradProduct || 'REF').toUpperCase();
|
|
402
|
-
const colormapFlat = state.colormap;
|
|
403
|
-
const colormapForShader = colormapToMs(colormapFlat, radarVar, state.units);
|
|
404
|
-
const [shaderMin, shaderMax] = radarShaderValueRange(
|
|
405
|
-
colormapFlat?.[0],
|
|
406
|
-
colormapFlat?.[colormapFlat.length - 2],
|
|
407
|
-
radarVar,
|
|
408
|
-
state.units,
|
|
409
|
-
state.nexradDataSource,
|
|
410
|
-
);
|
|
411
|
-
this.mapboxLayer.setColormap(colormapForShader);
|
|
412
|
-
this.mapboxLayer.setValueRange(shaderMin, shaderMax);
|
|
413
|
-
this.mapboxLayer.setOpacity(state.opacity ?? 1);
|
|
414
|
-
this.map.triggerRepaint();
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
/**
|
|
418
|
-
* Mouse readout at lng/lat — same sampling and formatting as aguacero-frontend `NexradReadoutFrame`
|
|
419
|
-
* (polar sample + velocity/hydro/range checks). Uses the cached frame for the current timeline key.
|
|
420
|
-
*
|
|
421
|
-
* @param {number} lng
|
|
422
|
-
* @param {number} lat
|
|
423
|
-
* @param {object} state - Latest `state:change` payload (needs `colormap`, NEXRAD fields).
|
|
424
|
-
* @returns {object | null}
|
|
425
|
-
*/
|
|
426
|
-
getInspectPayload(lng, lat, state) {
|
|
427
|
-
if (!state?.isNexrad || !state.nexradSite || state.nexradTimestamp == null) return null;
|
|
428
|
-
if (state.visible === false || (state.opacity ?? 1) <= 0) return null;
|
|
429
|
-
|
|
430
|
-
const unix = Number(state.nexradTimestamp);
|
|
431
|
-
const p = this._buildFetchParamsForUnix(state, unix);
|
|
432
|
-
if (!p) return null;
|
|
433
|
-
|
|
434
|
-
const frame = this._frameCache.get(p.fetchKey);
|
|
435
|
-
if (!frame) return null;
|
|
436
|
-
|
|
437
|
-
const colormapFlat = state.colormap;
|
|
438
|
-
if (!colormapFlat || colormapFlat.length < 2) return null;
|
|
439
|
-
|
|
440
|
-
const rangeMin = colormapFlat[0];
|
|
441
|
-
const rangeMax = colormapFlat[colormapFlat.length - 2];
|
|
442
|
-
|
|
443
|
-
const radarVariable = (state.nexradProduct || 'REF').toUpperCase();
|
|
444
|
-
const radarSource = state.nexradDataSource === 'level3' ? 'level3' : 'level2';
|
|
445
|
-
const displayUnits =
|
|
446
|
-
state.units && String(state.units).toLowerCase() !== 'none'
|
|
447
|
-
? state.units
|
|
448
|
-
: isVelocityStyleRadarVar(radarVariable)
|
|
449
|
-
? 'm/s'
|
|
450
|
-
: '';
|
|
451
|
-
|
|
452
|
-
const gpuFrame = prepareRadarFrameForGpuReadout(frame, mapboxFrameUploadOptionsForNexradState(state));
|
|
453
|
-
const sample = sampleNexradFrameAtLatLon(gpuFrame, lat, lng, { smoothPolar: this._gateSmoothing });
|
|
454
|
-
if (!sample) return null;
|
|
455
|
-
|
|
456
|
-
const valueMs = sample.value;
|
|
457
|
-
const isHydro = radarSource === 'level3' && nexradLevel3IsHydrometeorClassification(radarVariable);
|
|
458
|
-
|
|
459
|
-
/** @type {number | string} */
|
|
460
|
-
let valueOut;
|
|
461
|
-
let unitOut = displayUnits || '';
|
|
462
|
-
|
|
463
|
-
if (isVelocityStyleRadarVar(radarVariable)) {
|
|
464
|
-
valueOut = velocityMsToDisplay(valueMs, displayUnits);
|
|
465
|
-
} else if (isHydro) {
|
|
466
|
-
const label = nexradHydrometeorLabelForClassIndex(valueMs);
|
|
467
|
-
if (label == null) return null;
|
|
468
|
-
valueOut = label;
|
|
469
|
-
unitOut = '';
|
|
470
|
-
} else {
|
|
471
|
-
valueOut = formatNexradInspectNumeric(valueMs, radarVariable);
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
let inRange;
|
|
475
|
-
if (isHydro) {
|
|
476
|
-
const idx = Math.round(Number(valueMs));
|
|
477
|
-
inRange =
|
|
478
|
-
(rangeMin == null || idx >= rangeMin) && (rangeMax == null || idx <= rangeMax);
|
|
479
|
-
} else if (isVelocityStyleRadarVar(radarVariable)) {
|
|
480
|
-
inRange =
|
|
481
|
-
(rangeMin == null || valueOut >= rangeMin) && (rangeMax == null || valueOut <= rangeMax);
|
|
482
|
-
} else {
|
|
483
|
-
inRange =
|
|
484
|
-
(rangeMin == null || valueOut >= rangeMin) && (rangeMax == null || valueOut <= rangeMax);
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
if (!inRange) return null;
|
|
488
|
-
|
|
489
|
-
const tiltDeg = Number.isFinite(state.nexradTilt) ? state.nexradTilt : getDefaultRadarTilt(state.nexradSite);
|
|
490
|
-
const bhKm = beamHeightKm(sample.groundRangeKm, tiltDeg);
|
|
491
|
-
const metric = state.units === 'metric';
|
|
492
|
-
const beamHeightDisplay = metric ? bhKm : bhKm * 3.280839895013123;
|
|
493
|
-
const beamUnit = metric ? 'km' : 'kft';
|
|
494
|
-
|
|
495
|
-
const fldKey = state.variable;
|
|
496
|
-
return {
|
|
497
|
-
lngLat: { lng, lat },
|
|
498
|
-
variable: {
|
|
499
|
-
code: fldKey,
|
|
500
|
-
name: this.core.getVariableDisplayName(fldKey),
|
|
501
|
-
},
|
|
502
|
-
value: valueOut,
|
|
503
|
-
unit: unitOut,
|
|
504
|
-
beamHeight:
|
|
505
|
-
Number.isFinite(beamHeightDisplay) && Number.isFinite(bhKm)
|
|
506
|
-
? { value: beamHeightDisplay, unit: beamUnit }
|
|
507
|
-
: null,
|
|
508
|
-
};
|
|
509
|
-
}
|
|
510
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Connects AguaceroCore NEXRAD state to MapboxRadarLayer + archive fetch (aguacero-frontend parity).
|
|
3
|
+
* Preloads all timestamps on the active timeline (MRMS/satellite-style) so scrubbing does not trigger loads.
|
|
4
|
+
*/
|
|
5
|
+
import { getUnitConversionFunction, getDefaultRadarTilt } from '@aguacerowx/javascript-sdk';
|
|
6
|
+
import { fetchAndParseArchive, objectKeyToUrl, setNexradArchiveApiKey } from './nexrad/radarArchiveCore.bundled.js';
|
|
7
|
+
import { MapboxRadarLayer } from './nexrad/MapboxRadarLayer.bundled.js';
|
|
8
|
+
import { nexradBinGroupIdForKey, variableToNexradGroup } from '@aguacerowx/javascript-sdk';
|
|
9
|
+
import { sampleNexradFrameAtLatLon } from './nexrad/nexradCrossSectionSampleAtLatLon.bundled.js';
|
|
10
|
+
import { prepareRadarFrameForGpuReadout } from './nexrad/radarFrameGpuMatch.bundled.js';
|
|
11
|
+
import { mapboxFrameUploadOptionsForNexradState } from './nexrad/nexradMapboxFrameOpts.bundled.js';
|
|
12
|
+
|
|
13
|
+
function pickNearestLevel3ObjectKey(unixTime, timeToKeyMap, maxDeltaSec = 600) {
|
|
14
|
+
const direct = timeToKeyMap[String(unixTime)];
|
|
15
|
+
if (direct) return direct;
|
|
16
|
+
const entries = Object.entries(timeToKeyMap || {});
|
|
17
|
+
if (entries.length === 0) return null;
|
|
18
|
+
let bestKey = null;
|
|
19
|
+
let bestDelta = Infinity;
|
|
20
|
+
for (const [tStr, key] of entries) {
|
|
21
|
+
const t = Number(tStr);
|
|
22
|
+
if (!Number.isFinite(t)) continue;
|
|
23
|
+
const d = Math.abs(t - unixTime);
|
|
24
|
+
if (d < bestDelta) {
|
|
25
|
+
bestDelta = d;
|
|
26
|
+
bestKey = key;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (bestKey == null) return null;
|
|
30
|
+
if (bestDelta > maxDeltaSec && Number.isFinite(maxDeltaSec)) return null;
|
|
31
|
+
return bestKey;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function isVelocityStyleRadarVar(radarVariable) {
|
|
35
|
+
return radarVariable === 'VEL' || radarVariable === 'SW' || radarVariable === 'N0G' || radarVariable === 'N0W';
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function velocityRangeToMs(rangeMin, rangeMax, displayUnit) {
|
|
39
|
+
if (rangeMin == null || rangeMax == null || !displayUnit || displayUnit.toLowerCase() === 'm/s' || displayUnit.toLowerCase() === 'ms') {
|
|
40
|
+
return [rangeMin, rangeMax];
|
|
41
|
+
}
|
|
42
|
+
const toMs = getUnitConversionFunction(displayUnit, 'm/s', 'nexrad_vel');
|
|
43
|
+
if (!toMs) return [rangeMin, rangeMax];
|
|
44
|
+
return [toMs(rangeMin), toMs(rangeMax)];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function colormapToMs(colormap, radarVariable, displayUnit) {
|
|
48
|
+
if (!colormap || !Array.isArray(colormap) || colormap.length < 2) return colormap;
|
|
49
|
+
if (!isVelocityStyleRadarVar(radarVariable)) return colormap;
|
|
50
|
+
if (!displayUnit || displayUnit.toLowerCase() === 'm/s' || displayUnit.toLowerCase() === 'ms') return colormap;
|
|
51
|
+
const toMs = getUnitConversionFunction(
|
|
52
|
+
displayUnit,
|
|
53
|
+
'm/s',
|
|
54
|
+
radarVariable === 'SW' || radarVariable === 'N0W' ? 'nexrad_sw' : 'nexrad_vel',
|
|
55
|
+
);
|
|
56
|
+
if (!toMs) return colormap;
|
|
57
|
+
const out = [...colormap];
|
|
58
|
+
for (let i = 0; i < out.length; i += 2) {
|
|
59
|
+
const v = out[i];
|
|
60
|
+
if (typeof v === 'number' && Number.isFinite(v)) out[i] = toMs(v);
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function radarShaderValueRange(rangeMin, rangeMax, radarVariable, units, nexradDataSource) {
|
|
66
|
+
const v = (radarVariable || '').toUpperCase();
|
|
67
|
+
if (isVelocityStyleRadarVar(v)) {
|
|
68
|
+
const [vmin, vmax] = velocityRangeToMs(rangeMin, rangeMax, units);
|
|
69
|
+
return [vmin ?? 0, vmax ?? 80];
|
|
70
|
+
}
|
|
71
|
+
if (nexradDataSource === 'level3' && (v === 'N0H' || v === 'HHC')) {
|
|
72
|
+
const lo = rangeMin ?? 0;
|
|
73
|
+
let hi = rangeMax ?? 11;
|
|
74
|
+
if (hi > 11) hi = 11;
|
|
75
|
+
if (hi < lo) hi = lo;
|
|
76
|
+
return [lo, hi];
|
|
77
|
+
}
|
|
78
|
+
if (v === 'KDP') {
|
|
79
|
+
const lo = rangeMin ?? -2;
|
|
80
|
+
let hi = rangeMax ?? 8;
|
|
81
|
+
if (hi < lo) hi = lo;
|
|
82
|
+
return [lo, hi];
|
|
83
|
+
}
|
|
84
|
+
return [rangeMin ?? 0, rangeMax ?? 80];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Level-III digital / hybrid hydrometeor classification (aguacero-frontend `nexradLevel3IsHydrometeorClassification`). */
|
|
88
|
+
function nexradLevel3IsHydrometeorClassification(radarVariable) {
|
|
89
|
+
const v = (radarVariable || '').toUpperCase();
|
|
90
|
+
return v === 'N0H' || v === 'HHC';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** NWS class labels for digital HC (indices 0–11). */
|
|
94
|
+
const NEXRAD_HYDROMETEOR_CLASS_LABELS = [
|
|
95
|
+
'Biological',
|
|
96
|
+
'Ground clutter',
|
|
97
|
+
'Ice crystals',
|
|
98
|
+
'Dry snow',
|
|
99
|
+
'Wet snow',
|
|
100
|
+
'Light rain',
|
|
101
|
+
'Heavy rain',
|
|
102
|
+
'Big drops',
|
|
103
|
+
'Graupel',
|
|
104
|
+
'Hail/rain',
|
|
105
|
+
'Large hail',
|
|
106
|
+
'Giant hail',
|
|
107
|
+
];
|
|
108
|
+
|
|
109
|
+
function nexradHydrometeorLabelForClassIndex(value) {
|
|
110
|
+
const idx = Math.round(Number(value));
|
|
111
|
+
if (idx >= 12) return null;
|
|
112
|
+
if (idx >= 0 && idx < NEXRAD_HYDROMETEOR_CLASS_LABELS.length) {
|
|
113
|
+
return NEXRAD_HYDROMETEOR_CLASS_LABELS[idx];
|
|
114
|
+
}
|
|
115
|
+
return `Class ${idx}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function velocityMsToDisplay(valueMs, displayUnit) {
|
|
119
|
+
if (!displayUnit || displayUnit.toLowerCase() === 'm/s' || displayUnit.toLowerCase() === 'ms') {
|
|
120
|
+
return valueMs;
|
|
121
|
+
}
|
|
122
|
+
const fromMs = getUnitConversionFunction('m/s', displayUnit, 'nexrad_vel');
|
|
123
|
+
return fromMs ? fromMs(valueMs) : valueMs;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Standard 4/3-earth beam height (km AGL at antenna) from slant range and elevation — matches frontend `beamHeightKm`. */
|
|
127
|
+
function beamHeightKm(slantRangeKm, elevDeg) {
|
|
128
|
+
const el = (elevDeg * Math.PI) / 180;
|
|
129
|
+
const ReKm = (6371000 * 4) / (3 * 1000);
|
|
130
|
+
const cosEl = Math.max(Math.cos(el), 0.05);
|
|
131
|
+
const s = slantRangeKm / cosEl;
|
|
132
|
+
return Math.sqrt(s * s + ReKm * ReKm + 2 * s * ReKm * Math.sin(el)) - ReKm;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function formatNexradInspectNumeric(raw, radarVariable) {
|
|
136
|
+
const vu = (radarVariable || '').toUpperCase();
|
|
137
|
+
const vl = radarVariable || '';
|
|
138
|
+
if (vu.includes('RATE') || vl.toLowerCase().includes('rate')) {
|
|
139
|
+
return Number(raw.toFixed(3).replace(/\.?0+$/, ''));
|
|
140
|
+
}
|
|
141
|
+
if (vu === 'RHO' || vu === 'ZDR' || vl.includes('RhoHV') || vl.includes('Zdr')) {
|
|
142
|
+
return Number(raw.toFixed(2));
|
|
143
|
+
}
|
|
144
|
+
if (Math.abs(raw) < 10) return Number(raw.toFixed(1));
|
|
145
|
+
return Math.round(raw);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const PRELOAD_CONCURRENCY = 4;
|
|
149
|
+
|
|
150
|
+
export class NexradWeatherController {
|
|
151
|
+
constructor(map, core, options = {}) {
|
|
152
|
+
this.map = map;
|
|
153
|
+
this.core = core;
|
|
154
|
+
this.layerId = options.nexradLayerId || 'aguacero-nexrad-layer';
|
|
155
|
+
this.mapboxLayer = null;
|
|
156
|
+
/** Includes tilt/timestamp so elevation changes always re-upload (see {@link _syncIdentity}). */
|
|
157
|
+
this._lastSyncKey = null;
|
|
158
|
+
this._abort = null;
|
|
159
|
+
this._interpolateColormap = options.interpolateNexradColormap !== false;
|
|
160
|
+
this._gateSmoothing = options.nexradGateSmoothing === true;
|
|
161
|
+
/** Decoded radar frames keyed by fetchKey from _buildFetchParamsForUnix. */
|
|
162
|
+
this._frameCache = new Map();
|
|
163
|
+
this._preloadAbort = null;
|
|
164
|
+
this._preloadTimelineSig = null;
|
|
165
|
+
/** @type {(() => string | undefined) | null} */
|
|
166
|
+
this._getInsertBeforeLayerId = options.getInsertBeforeLayerId || null;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
destroy() {
|
|
170
|
+
this._abort?.abort();
|
|
171
|
+
this._abort = null;
|
|
172
|
+
this._preloadAbort?.abort();
|
|
173
|
+
this._preloadAbort = null;
|
|
174
|
+
this._frameCache.clear();
|
|
175
|
+
this._preloadTimelineSig = null;
|
|
176
|
+
if (this.mapboxLayer && this.map.getLayer(this.mapboxLayer.id)) {
|
|
177
|
+
this.map.removeLayer(this.mapboxLayer.id);
|
|
178
|
+
}
|
|
179
|
+
this.mapboxLayer = null;
|
|
180
|
+
this._lastSyncKey = null;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
_resolveListingContext(state) {
|
|
184
|
+
const nk = this.core._nexradTimesCacheKey?.();
|
|
185
|
+
const ent = nk ? this.core.nexradTimesByStation?.[nk] : null;
|
|
186
|
+
const timeToKeyMap = state.nexradTimeToKeyMap || ent?.timeToKeyMap || {};
|
|
187
|
+
const motionMap = state.nexradLevel3MotionTimeToKeyMap || ent?.level3MotionTimeToKeyMap || {};
|
|
188
|
+
return { nk, ent, timeToKeyMap, motionMap };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* @param {object} state
|
|
193
|
+
* @param {number} unix
|
|
194
|
+
* @returns {{ objectKey: string, url: string, fetchKey: string, motionObjectKey: string | null, radarVar: string, radarSource: string, groupId: number } | null}
|
|
195
|
+
*/
|
|
196
|
+
_buildFetchParamsForUnix(state, unix) {
|
|
197
|
+
const radarVar = (state.nexradProduct || 'REF').toUpperCase();
|
|
198
|
+
const radarSource = state.nexradDataSource === 'level3' ? 'level3' : 'level2';
|
|
199
|
+
const groupId = nexradBinGroupIdForKey(variableToNexradGroup(radarVar));
|
|
200
|
+
const { timeToKeyMap, motionMap } = this._resolveListingContext(state);
|
|
201
|
+
const objectKey = timeToKeyMap[String(unix)];
|
|
202
|
+
if (!objectKey) return null;
|
|
203
|
+
|
|
204
|
+
let motionObjectKey = null;
|
|
205
|
+
if (radarVar === 'VEL' && state.nexradStormRelative) {
|
|
206
|
+
motionObjectKey = pickNearestLevel3ObjectKey(unix, motionMap);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const url = objectKeyToUrl(objectKey, radarSource);
|
|
210
|
+
const fetchKey = `${url}|${radarVar}|${radarSource}|${motionObjectKey || ''}`;
|
|
211
|
+
return { objectKey, url, fetchKey, motionObjectKey, radarVar, radarSource, groupId };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async _fetchFrame(state, unix, { signal, priority }) {
|
|
215
|
+
const p = this._buildFetchParamsForUnix(state, unix);
|
|
216
|
+
if (!p) return null;
|
|
217
|
+
return fetchAndParseArchive(p.url, p.objectKey, p.radarVar, p.groupId, p.radarSource, {
|
|
218
|
+
signal,
|
|
219
|
+
priority,
|
|
220
|
+
level3MotionObjectKey: p.motionObjectKey,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
_preloadTimelineSignature(state) {
|
|
225
|
+
const nk = this.core._nexradTimesCacheKey?.() || '';
|
|
226
|
+
const ts = [...(state.availableNexradTimestamps || [])]
|
|
227
|
+
.map(Number)
|
|
228
|
+
.filter((t) => Number.isFinite(t))
|
|
229
|
+
.sort((a, b) => a - b)
|
|
230
|
+
.join(',');
|
|
231
|
+
const tilt =
|
|
232
|
+
state.nexradTilt != null && Number.isFinite(Number(state.nexradTilt))
|
|
233
|
+
? Number(state.nexradTilt).toFixed(3)
|
|
234
|
+
: '';
|
|
235
|
+
return `${nk}|${ts}|sr:${state.nexradStormRelative ? 1 : 0}|tilt:${tilt}`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Identity for whether the visible radar frame + upload options match (tilt affects L3 mesh keys and listing object paths).
|
|
240
|
+
* @param {object} state
|
|
241
|
+
* @param {{ fetchKey: string }} p
|
|
242
|
+
* @param {number} unix
|
|
243
|
+
*/
|
|
244
|
+
_syncIdentity(state, p, unix) {
|
|
245
|
+
const tilt =
|
|
246
|
+
state.nexradTilt != null && Number.isFinite(Number(state.nexradTilt))
|
|
247
|
+
? Number(state.nexradTilt).toFixed(3)
|
|
248
|
+
: '';
|
|
249
|
+
return `${p.fetchKey}|tilt:${tilt}|u:${unix}`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Background-load every volume on the slider timeline (like satellite KTX2 preload).
|
|
254
|
+
* Slider / `nexradTimestamp` changes only swap GPU buffers from cache when possible.
|
|
255
|
+
* @param {object} state
|
|
256
|
+
*/
|
|
257
|
+
preloadAllAvailable(state) {
|
|
258
|
+
if (!state.isNexrad || !state.nexradSite) return;
|
|
259
|
+
|
|
260
|
+
const sig = this._preloadTimelineSignature(state);
|
|
261
|
+
if (sig === this._preloadTimelineSig) return;
|
|
262
|
+
|
|
263
|
+
this._preloadTimelineSig = sig;
|
|
264
|
+
this._preloadAbort?.abort();
|
|
265
|
+
this._preloadAbort = new AbortController();
|
|
266
|
+
const signal = this._preloadAbort.signal;
|
|
267
|
+
|
|
268
|
+
this._frameCache.clear();
|
|
269
|
+
|
|
270
|
+
setNexradArchiveApiKey(this.core.apiKey || '');
|
|
271
|
+
|
|
272
|
+
const times = [...(state.availableNexradTimestamps || [])]
|
|
273
|
+
.map(Number)
|
|
274
|
+
.filter((t) => Number.isFinite(t));
|
|
275
|
+
if (!times.length) return;
|
|
276
|
+
|
|
277
|
+
const snapshot = { ...state };
|
|
278
|
+
|
|
279
|
+
const run = async () => {
|
|
280
|
+
let cursor = 0;
|
|
281
|
+
const work = async () => {
|
|
282
|
+
while (cursor < times.length && !signal.aborted) {
|
|
283
|
+
const i = cursor++;
|
|
284
|
+
const unix = times[i];
|
|
285
|
+
const p = this._buildFetchParamsForUnix(snapshot, unix);
|
|
286
|
+
if (!p || signal.aborted) continue;
|
|
287
|
+
if (this._frameCache.has(p.fetchKey)) continue;
|
|
288
|
+
try {
|
|
289
|
+
const frame = await this._fetchFrame(snapshot, unix, {
|
|
290
|
+
signal,
|
|
291
|
+
priority: 'prefetch',
|
|
292
|
+
});
|
|
293
|
+
if (frame && !signal.aborted) {
|
|
294
|
+
this._frameCache.set(p.fetchKey, frame);
|
|
295
|
+
}
|
|
296
|
+
} catch {
|
|
297
|
+
/* ignore failed preload tiles */
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
await Promise.all(Array.from({ length: PRELOAD_CONCURRENCY }, () => work()));
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
void run();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
_ensureLayer() {
|
|
308
|
+
if (this.mapboxLayer) return;
|
|
309
|
+
this.mapboxLayer = new MapboxRadarLayer(this.layerId);
|
|
310
|
+
let beforeId =
|
|
311
|
+
typeof this._getInsertBeforeLayerId === 'function'
|
|
312
|
+
? this._getInsertBeforeLayerId()
|
|
313
|
+
: undefined;
|
|
314
|
+
if (!beforeId && this.map.getLayer('AML_-_terrain')) {
|
|
315
|
+
beforeId = 'AML_-_terrain';
|
|
316
|
+
}
|
|
317
|
+
if (beforeId) {
|
|
318
|
+
this.map.addLayer(this.mapboxLayer, beforeId);
|
|
319
|
+
} else {
|
|
320
|
+
this.map.addLayer(this.mapboxLayer);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
_uploadFrame(state, frame) {
|
|
325
|
+
const radarVar = (state.nexradProduct || 'REF').toUpperCase();
|
|
326
|
+
this.mapboxLayer.setInterpolateColormap(this._interpolateColormap);
|
|
327
|
+
this.mapboxLayer.setGateSmoothing(this._gateSmoothing);
|
|
328
|
+
|
|
329
|
+
const colormapFlat = state.colormap;
|
|
330
|
+
const colormapForShader = colormapToMs(colormapFlat, radarVar, state.units);
|
|
331
|
+
const [shaderMin, shaderMax] = radarShaderValueRange(
|
|
332
|
+
colormapFlat?.[0],
|
|
333
|
+
colormapFlat?.[colormapFlat.length - 2],
|
|
334
|
+
radarVar,
|
|
335
|
+
state.units,
|
|
336
|
+
state.nexradDataSource,
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
this.mapboxLayer.setColormap(colormapForShader);
|
|
340
|
+
this.mapboxLayer.setValueRange(shaderMin, shaderMax);
|
|
341
|
+
this.mapboxLayer.setOpacity(state.opacity ?? 1);
|
|
342
|
+
this.mapboxLayer.setFrameData(frame, mapboxFrameUploadOptionsForNexradState(state));
|
|
343
|
+
this.map.triggerRepaint();
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* @param {object} state - core state + emitted colormap (from state:change)
|
|
348
|
+
*/
|
|
349
|
+
async sync(state) {
|
|
350
|
+
if (!state.isNexrad || !state.nexradSite || state.nexradTimestamp == null) {
|
|
351
|
+
this.destroy();
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
setNexradArchiveApiKey(this.core.apiKey || '');
|
|
356
|
+
|
|
357
|
+
const unix = Number(state.nexradTimestamp);
|
|
358
|
+
const p = this._buildFetchParamsForUnix(state, unix);
|
|
359
|
+
if (!p) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const syncKey = this._syncIdentity(state, p, unix);
|
|
364
|
+
if (syncKey === this._lastSyncKey && this.mapboxLayer) {
|
|
365
|
+
this._applyStyle(state);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const cached = this._frameCache.get(p.fetchKey);
|
|
370
|
+
if (cached) {
|
|
371
|
+
this._ensureLayer();
|
|
372
|
+
this._uploadFrame(state, cached);
|
|
373
|
+
this._lastSyncKey = syncKey;
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
this._abort?.abort();
|
|
378
|
+
this._abort = new AbortController();
|
|
379
|
+
|
|
380
|
+
let frame;
|
|
381
|
+
try {
|
|
382
|
+
frame = await this._fetchFrame(state, unix, {
|
|
383
|
+
signal: this._abort.signal,
|
|
384
|
+
priority: 'display',
|
|
385
|
+
});
|
|
386
|
+
} catch {
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
if (!frame || this._abort.signal.aborted) return;
|
|
391
|
+
|
|
392
|
+
this._frameCache.set(p.fetchKey, frame);
|
|
393
|
+
|
|
394
|
+
this._ensureLayer();
|
|
395
|
+
this._uploadFrame(state, frame);
|
|
396
|
+
this._lastSyncKey = syncKey;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
_applyStyle(state) {
|
|
400
|
+
if (!this.mapboxLayer) return;
|
|
401
|
+
const radarVar = (state.nexradProduct || 'REF').toUpperCase();
|
|
402
|
+
const colormapFlat = state.colormap;
|
|
403
|
+
const colormapForShader = colormapToMs(colormapFlat, radarVar, state.units);
|
|
404
|
+
const [shaderMin, shaderMax] = radarShaderValueRange(
|
|
405
|
+
colormapFlat?.[0],
|
|
406
|
+
colormapFlat?.[colormapFlat.length - 2],
|
|
407
|
+
radarVar,
|
|
408
|
+
state.units,
|
|
409
|
+
state.nexradDataSource,
|
|
410
|
+
);
|
|
411
|
+
this.mapboxLayer.setColormap(colormapForShader);
|
|
412
|
+
this.mapboxLayer.setValueRange(shaderMin, shaderMax);
|
|
413
|
+
this.mapboxLayer.setOpacity(state.opacity ?? 1);
|
|
414
|
+
this.map.triggerRepaint();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Mouse readout at lng/lat — same sampling and formatting as aguacero-frontend `NexradReadoutFrame`
|
|
419
|
+
* (polar sample + velocity/hydro/range checks). Uses the cached frame for the current timeline key.
|
|
420
|
+
*
|
|
421
|
+
* @param {number} lng
|
|
422
|
+
* @param {number} lat
|
|
423
|
+
* @param {object} state - Latest `state:change` payload (needs `colormap`, NEXRAD fields).
|
|
424
|
+
* @returns {object | null}
|
|
425
|
+
*/
|
|
426
|
+
getInspectPayload(lng, lat, state) {
|
|
427
|
+
if (!state?.isNexrad || !state.nexradSite || state.nexradTimestamp == null) return null;
|
|
428
|
+
if (state.visible === false || (state.opacity ?? 1) <= 0) return null;
|
|
429
|
+
|
|
430
|
+
const unix = Number(state.nexradTimestamp);
|
|
431
|
+
const p = this._buildFetchParamsForUnix(state, unix);
|
|
432
|
+
if (!p) return null;
|
|
433
|
+
|
|
434
|
+
const frame = this._frameCache.get(p.fetchKey);
|
|
435
|
+
if (!frame) return null;
|
|
436
|
+
|
|
437
|
+
const colormapFlat = state.colormap;
|
|
438
|
+
if (!colormapFlat || colormapFlat.length < 2) return null;
|
|
439
|
+
|
|
440
|
+
const rangeMin = colormapFlat[0];
|
|
441
|
+
const rangeMax = colormapFlat[colormapFlat.length - 2];
|
|
442
|
+
|
|
443
|
+
const radarVariable = (state.nexradProduct || 'REF').toUpperCase();
|
|
444
|
+
const radarSource = state.nexradDataSource === 'level3' ? 'level3' : 'level2';
|
|
445
|
+
const displayUnits =
|
|
446
|
+
state.units && String(state.units).toLowerCase() !== 'none'
|
|
447
|
+
? state.units
|
|
448
|
+
: isVelocityStyleRadarVar(radarVariable)
|
|
449
|
+
? 'm/s'
|
|
450
|
+
: '';
|
|
451
|
+
|
|
452
|
+
const gpuFrame = prepareRadarFrameForGpuReadout(frame, mapboxFrameUploadOptionsForNexradState(state));
|
|
453
|
+
const sample = sampleNexradFrameAtLatLon(gpuFrame, lat, lng, { smoothPolar: this._gateSmoothing });
|
|
454
|
+
if (!sample) return null;
|
|
455
|
+
|
|
456
|
+
const valueMs = sample.value;
|
|
457
|
+
const isHydro = radarSource === 'level3' && nexradLevel3IsHydrometeorClassification(radarVariable);
|
|
458
|
+
|
|
459
|
+
/** @type {number | string} */
|
|
460
|
+
let valueOut;
|
|
461
|
+
let unitOut = displayUnits || '';
|
|
462
|
+
|
|
463
|
+
if (isVelocityStyleRadarVar(radarVariable)) {
|
|
464
|
+
valueOut = velocityMsToDisplay(valueMs, displayUnits);
|
|
465
|
+
} else if (isHydro) {
|
|
466
|
+
const label = nexradHydrometeorLabelForClassIndex(valueMs);
|
|
467
|
+
if (label == null) return null;
|
|
468
|
+
valueOut = label;
|
|
469
|
+
unitOut = '';
|
|
470
|
+
} else {
|
|
471
|
+
valueOut = formatNexradInspectNumeric(valueMs, radarVariable);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
let inRange;
|
|
475
|
+
if (isHydro) {
|
|
476
|
+
const idx = Math.round(Number(valueMs));
|
|
477
|
+
inRange =
|
|
478
|
+
(rangeMin == null || idx >= rangeMin) && (rangeMax == null || idx <= rangeMax);
|
|
479
|
+
} else if (isVelocityStyleRadarVar(radarVariable)) {
|
|
480
|
+
inRange =
|
|
481
|
+
(rangeMin == null || valueOut >= rangeMin) && (rangeMax == null || valueOut <= rangeMax);
|
|
482
|
+
} else {
|
|
483
|
+
inRange =
|
|
484
|
+
(rangeMin == null || valueOut >= rangeMin) && (rangeMax == null || valueOut <= rangeMax);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (!inRange) return null;
|
|
488
|
+
|
|
489
|
+
const tiltDeg = Number.isFinite(state.nexradTilt) ? state.nexradTilt : getDefaultRadarTilt(state.nexradSite);
|
|
490
|
+
const bhKm = beamHeightKm(sample.groundRangeKm, tiltDeg);
|
|
491
|
+
const metric = state.units === 'metric';
|
|
492
|
+
const beamHeightDisplay = metric ? bhKm : bhKm * 3.280839895013123;
|
|
493
|
+
const beamUnit = metric ? 'km' : 'kft';
|
|
494
|
+
|
|
495
|
+
const fldKey = state.variable;
|
|
496
|
+
return {
|
|
497
|
+
lngLat: { lng, lat },
|
|
498
|
+
variable: {
|
|
499
|
+
code: fldKey,
|
|
500
|
+
name: this.core.getVariableDisplayName(fldKey),
|
|
501
|
+
},
|
|
502
|
+
value: valueOut,
|
|
503
|
+
unit: unitOut,
|
|
504
|
+
beamHeight:
|
|
505
|
+
Number.isFinite(beamHeightDisplay) && Number.isFinite(bhKm)
|
|
506
|
+
? { value: beamHeightDisplay, unit: beamUnit }
|
|
507
|
+
: null,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
}
|