@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
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GeoJSON markers for all NEXRAD sites (picker mode). Aligned with aguacero-frontend NexradSitesLayer (simplified).
|
|
3
|
+
*/
|
|
4
|
+
const SOURCE_ID = 'aguacero-nexrad-sites-src';
|
|
5
|
+
const CIRCLE_LAYER_ID = 'aguacero-nexrad-sites-circles';
|
|
6
|
+
|
|
7
|
+
export class NexradSitesOverlay {
|
|
8
|
+
constructor(map, options = {}) {
|
|
9
|
+
this.map = map;
|
|
10
|
+
/** Same path as aguacero-frontend: serve `public/data/nexrad.json` (e.g. Vite → `/data/nexrad.json`). */
|
|
11
|
+
this.sitesUrl = options.nexradSitesUrl || '/data/nexrad.json';
|
|
12
|
+
this._onClick = null;
|
|
13
|
+
/** Coalesces overlapping `show()` calls (e.g. NEXRAD timestamp slider firing many state updates). */
|
|
14
|
+
this._showPromise = null;
|
|
15
|
+
this._destroyed = false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
_layersPresent() {
|
|
19
|
+
try {
|
|
20
|
+
return Boolean(this.map.getSource(SOURCE_ID) && this.map.getLayer(CIRCLE_LAYER_ID));
|
|
21
|
+
} catch {
|
|
22
|
+
return false;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async show() {
|
|
27
|
+
if (this._layersPresent()) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (this._showPromise) {
|
|
31
|
+
return this._showPromise;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
this._showPromise = this._loadAndMountSites();
|
|
35
|
+
try {
|
|
36
|
+
await this._showPromise;
|
|
37
|
+
} finally {
|
|
38
|
+
this._showPromise = null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async _loadAndMountSites() {
|
|
43
|
+
if (this._layersPresent()) {
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
await this.hide();
|
|
47
|
+
let res;
|
|
48
|
+
try {
|
|
49
|
+
res = await fetch(this.sitesUrl);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
console.warn('[NexradSitesOverlay] Failed to load site list:', e);
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
if (!res.ok) {
|
|
55
|
+
console.warn('[NexradSitesOverlay] HTTP', res.status, this.sitesUrl);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (this._destroyed) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const payload = await res.json();
|
|
62
|
+
const raw = Array.isArray(payload)
|
|
63
|
+
? payload
|
|
64
|
+
: payload?.features
|
|
65
|
+
? payload.features
|
|
66
|
+
: payload?.sites
|
|
67
|
+
? payload.sites
|
|
68
|
+
: [];
|
|
69
|
+
const features = [];
|
|
70
|
+
for (const item of raw) {
|
|
71
|
+
if (!item) continue;
|
|
72
|
+
const coords = item.geometry?.coordinates
|
|
73
|
+
? item.geometry.coordinates
|
|
74
|
+
: [item.lon ?? item.lng ?? item.longitude, item.lat ?? item.latitude];
|
|
75
|
+
const lon = Number(coords[0]);
|
|
76
|
+
const lat = Number(coords[1]);
|
|
77
|
+
const id = String(item.id ?? item.properties?.id ?? item.station ?? '').toUpperCase();
|
|
78
|
+
if (!id || !Number.isFinite(lat) || !Number.isFinite(lon)) continue;
|
|
79
|
+
features.push({
|
|
80
|
+
type: 'Feature',
|
|
81
|
+
geometry: { type: 'Point', coordinates: [lon, lat] },
|
|
82
|
+
properties: { name: id },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (this._destroyed) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
this.map.addSource(SOURCE_ID, {
|
|
89
|
+
type: 'geojson',
|
|
90
|
+
data: { type: 'FeatureCollection', features },
|
|
91
|
+
});
|
|
92
|
+
this.map.addLayer({
|
|
93
|
+
id: CIRCLE_LAYER_ID,
|
|
94
|
+
type: 'circle',
|
|
95
|
+
source: SOURCE_ID,
|
|
96
|
+
paint: {
|
|
97
|
+
'circle-radius': 5,
|
|
98
|
+
'circle-color': '#3b82f6',
|
|
99
|
+
'circle-stroke-width': 1,
|
|
100
|
+
'circle-stroke-color': '#ffffff',
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
hide() {
|
|
106
|
+
try {
|
|
107
|
+
if (this.map.getLayer(CIRCLE_LAYER_ID)) this.map.removeLayer(CIRCLE_LAYER_ID);
|
|
108
|
+
if (this.map.getSource(SOURCE_ID)) this.map.removeSource(SOURCE_ID);
|
|
109
|
+
} catch {
|
|
110
|
+
/* ignore */
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @param {(siteId: string) => void} handler
|
|
116
|
+
*/
|
|
117
|
+
bindClick(handler) {
|
|
118
|
+
this.unbindClick();
|
|
119
|
+
this._onClick = (e) => {
|
|
120
|
+
const f = e.features?.[0];
|
|
121
|
+
const id = f?.properties?.name;
|
|
122
|
+
if (id) handler(String(id));
|
|
123
|
+
};
|
|
124
|
+
this.map.on('click', CIRCLE_LAYER_ID, this._onClick);
|
|
125
|
+
this.map.on('mouseenter', CIRCLE_LAYER_ID, () => {
|
|
126
|
+
this.map.getCanvas().style.cursor = 'pointer';
|
|
127
|
+
});
|
|
128
|
+
this.map.on('mouseleave', CIRCLE_LAYER_ID, () => {
|
|
129
|
+
this.map.getCanvas().style.cursor = '';
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
unbindClick() {
|
|
134
|
+
if (!this._onClick) return;
|
|
135
|
+
try {
|
|
136
|
+
this.map.off('click', CIRCLE_LAYER_ID, this._onClick);
|
|
137
|
+
} catch {
|
|
138
|
+
/* ignore */
|
|
139
|
+
}
|
|
140
|
+
this._onClick = null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
destroy() {
|
|
144
|
+
this._destroyed = true;
|
|
145
|
+
this.unbindClick();
|
|
146
|
+
this.hide();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,491 @@
|
|
|
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.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
|
+
this._lastFetchKey = null;
|
|
157
|
+
this._abort = null;
|
|
158
|
+
this._interpolateColormap = options.interpolateNexradColormap !== false;
|
|
159
|
+
this._gateSmoothing = options.nexradGateSmoothing === true;
|
|
160
|
+
/** Decoded radar frames keyed by fetchKey from _buildFetchParamsForUnix. */
|
|
161
|
+
this._frameCache = new Map();
|
|
162
|
+
this._preloadAbort = null;
|
|
163
|
+
this._preloadTimelineSig = null;
|
|
164
|
+
/** @type {(() => string | undefined) | null} */
|
|
165
|
+
this._getInsertBeforeLayerId = options.getInsertBeforeLayerId || null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
destroy() {
|
|
169
|
+
this._abort?.abort();
|
|
170
|
+
this._abort = null;
|
|
171
|
+
this._preloadAbort?.abort();
|
|
172
|
+
this._preloadAbort = null;
|
|
173
|
+
this._frameCache.clear();
|
|
174
|
+
this._preloadTimelineSig = null;
|
|
175
|
+
if (this.mapboxLayer && this.map.getLayer(this.mapboxLayer.id)) {
|
|
176
|
+
this.map.removeLayer(this.mapboxLayer.id);
|
|
177
|
+
}
|
|
178
|
+
this.mapboxLayer = null;
|
|
179
|
+
this._lastFetchKey = null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
_resolveListingContext(state) {
|
|
183
|
+
const nk = this.core._nexradTimesCacheKey?.();
|
|
184
|
+
const ent = nk ? this.core.nexradTimesByStation?.[nk] : null;
|
|
185
|
+
const timeToKeyMap = state.nexradTimeToKeyMap || ent?.timeToKeyMap || {};
|
|
186
|
+
const motionMap = state.nexradLevel3MotionTimeToKeyMap || ent?.level3MotionTimeToKeyMap || {};
|
|
187
|
+
return { nk, ent, timeToKeyMap, motionMap };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @param {object} state
|
|
192
|
+
* @param {number} unix
|
|
193
|
+
* @returns {{ objectKey: string, url: string, fetchKey: string, motionObjectKey: string | null, radarVar: string, radarSource: string, groupId: number } | null}
|
|
194
|
+
*/
|
|
195
|
+
_buildFetchParamsForUnix(state, unix) {
|
|
196
|
+
const radarVar = (state.nexradProduct || 'REF').toUpperCase();
|
|
197
|
+
const radarSource = state.nexradDataSource === 'level3' ? 'level3' : 'level2';
|
|
198
|
+
const groupId = nexradBinGroupIdForKey(variableToNexradGroup(radarVar));
|
|
199
|
+
const { timeToKeyMap, motionMap } = this._resolveListingContext(state);
|
|
200
|
+
const objectKey = timeToKeyMap[String(unix)];
|
|
201
|
+
if (!objectKey) return null;
|
|
202
|
+
|
|
203
|
+
let motionObjectKey = null;
|
|
204
|
+
if (radarVar === 'VEL' && state.nexradStormRelative) {
|
|
205
|
+
motionObjectKey = pickNearestLevel3ObjectKey(unix, motionMap);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const url = objectKeyToUrl(objectKey, radarSource);
|
|
209
|
+
const fetchKey = `${url}|${radarVar}|${radarSource}|${motionObjectKey || ''}`;
|
|
210
|
+
return { objectKey, url, fetchKey, motionObjectKey, radarVar, radarSource, groupId };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async _fetchFrame(state, unix, { signal, priority }) {
|
|
214
|
+
const p = this._buildFetchParamsForUnix(state, unix);
|
|
215
|
+
if (!p) return null;
|
|
216
|
+
return fetchAndParseArchive(p.url, p.objectKey, p.radarVar, p.groupId, p.radarSource, {
|
|
217
|
+
signal,
|
|
218
|
+
priority,
|
|
219
|
+
level3MotionObjectKey: p.motionObjectKey,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
_preloadTimelineSignature(state) {
|
|
224
|
+
const nk = this.core._nexradTimesCacheKey?.() || '';
|
|
225
|
+
const ts = [...(state.availableNexradTimestamps || [])]
|
|
226
|
+
.map(Number)
|
|
227
|
+
.filter((t) => Number.isFinite(t))
|
|
228
|
+
.sort((a, b) => a - b)
|
|
229
|
+
.join(',');
|
|
230
|
+
return `${nk}|${ts}|sr:${state.nexradStormRelative ? 1 : 0}`;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Background-load every volume on the slider timeline (like satellite KTX2 preload).
|
|
235
|
+
* Slider / `nexradTimestamp` changes only swap GPU buffers from cache when possible.
|
|
236
|
+
* @param {object} state
|
|
237
|
+
*/
|
|
238
|
+
preloadAllAvailable(state) {
|
|
239
|
+
if (!state.isNexrad || !state.nexradSite) return;
|
|
240
|
+
|
|
241
|
+
const sig = this._preloadTimelineSignature(state);
|
|
242
|
+
if (sig === this._preloadTimelineSig) return;
|
|
243
|
+
|
|
244
|
+
this._preloadTimelineSig = sig;
|
|
245
|
+
this._preloadAbort?.abort();
|
|
246
|
+
this._preloadAbort = new AbortController();
|
|
247
|
+
const signal = this._preloadAbort.signal;
|
|
248
|
+
|
|
249
|
+
this._frameCache.clear();
|
|
250
|
+
|
|
251
|
+
setNexradArchiveApiKey(this.core.apiKey || '');
|
|
252
|
+
|
|
253
|
+
const times = [...(state.availableNexradTimestamps || [])]
|
|
254
|
+
.map(Number)
|
|
255
|
+
.filter((t) => Number.isFinite(t));
|
|
256
|
+
if (!times.length) return;
|
|
257
|
+
|
|
258
|
+
const snapshot = { ...state };
|
|
259
|
+
|
|
260
|
+
const run = async () => {
|
|
261
|
+
let cursor = 0;
|
|
262
|
+
const work = async () => {
|
|
263
|
+
while (cursor < times.length && !signal.aborted) {
|
|
264
|
+
const i = cursor++;
|
|
265
|
+
const unix = times[i];
|
|
266
|
+
const p = this._buildFetchParamsForUnix(snapshot, unix);
|
|
267
|
+
if (!p || signal.aborted) continue;
|
|
268
|
+
if (this._frameCache.has(p.fetchKey)) continue;
|
|
269
|
+
try {
|
|
270
|
+
const frame = await this._fetchFrame(snapshot, unix, {
|
|
271
|
+
signal,
|
|
272
|
+
priority: 'prefetch',
|
|
273
|
+
});
|
|
274
|
+
if (frame && !signal.aborted) {
|
|
275
|
+
this._frameCache.set(p.fetchKey, frame);
|
|
276
|
+
}
|
|
277
|
+
} catch {
|
|
278
|
+
/* ignore failed preload tiles */
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
await Promise.all(Array.from({ length: PRELOAD_CONCURRENCY }, () => work()));
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
void run();
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
_ensureLayer() {
|
|
289
|
+
if (this.mapboxLayer) return;
|
|
290
|
+
this.mapboxLayer = new MapboxRadarLayer(this.layerId);
|
|
291
|
+
let beforeId =
|
|
292
|
+
typeof this._getInsertBeforeLayerId === 'function'
|
|
293
|
+
? this._getInsertBeforeLayerId()
|
|
294
|
+
: undefined;
|
|
295
|
+
if (!beforeId && this.map.getLayer('AML_-_terrain')) {
|
|
296
|
+
beforeId = 'AML_-_terrain';
|
|
297
|
+
}
|
|
298
|
+
if (beforeId) {
|
|
299
|
+
this.map.addLayer(this.mapboxLayer, beforeId);
|
|
300
|
+
} else {
|
|
301
|
+
this.map.addLayer(this.mapboxLayer);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
_uploadFrame(state, frame) {
|
|
306
|
+
const radarVar = (state.nexradProduct || 'REF').toUpperCase();
|
|
307
|
+
this.mapboxLayer.setInterpolateColormap(this._interpolateColormap);
|
|
308
|
+
this.mapboxLayer.setGateSmoothing(this._gateSmoothing);
|
|
309
|
+
|
|
310
|
+
const colormapFlat = state.colormap;
|
|
311
|
+
const colormapForShader = colormapToMs(colormapFlat, radarVar, state.units);
|
|
312
|
+
const [shaderMin, shaderMax] = radarShaderValueRange(
|
|
313
|
+
colormapFlat?.[0],
|
|
314
|
+
colormapFlat?.[colormapFlat.length - 2],
|
|
315
|
+
radarVar,
|
|
316
|
+
state.units,
|
|
317
|
+
state.nexradDataSource,
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
this.mapboxLayer.setColormap(colormapForShader);
|
|
321
|
+
this.mapboxLayer.setValueRange(shaderMin, shaderMax);
|
|
322
|
+
this.mapboxLayer.setOpacity(state.opacity ?? 1);
|
|
323
|
+
this.mapboxLayer.setFrameData(frame, mapboxFrameUploadOptionsForNexradState(state));
|
|
324
|
+
this.map.triggerRepaint();
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* @param {object} state - core state + emitted colormap (from state:change)
|
|
329
|
+
*/
|
|
330
|
+
async sync(state) {
|
|
331
|
+
if (!state.isNexrad || !state.nexradSite || state.nexradTimestamp == null) {
|
|
332
|
+
this.destroy();
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
setNexradArchiveApiKey(this.core.apiKey || '');
|
|
337
|
+
|
|
338
|
+
const unix = Number(state.nexradTimestamp);
|
|
339
|
+
const p = this._buildFetchParamsForUnix(state, unix);
|
|
340
|
+
if (!p) {
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (p.fetchKey === this._lastFetchKey && this.mapboxLayer) {
|
|
345
|
+
this._applyStyle(state);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const cached = this._frameCache.get(p.fetchKey);
|
|
350
|
+
if (cached) {
|
|
351
|
+
this._ensureLayer();
|
|
352
|
+
this._uploadFrame(state, cached);
|
|
353
|
+
this._lastFetchKey = p.fetchKey;
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
this._lastFetchKey = p.fetchKey;
|
|
358
|
+
|
|
359
|
+
this._abort?.abort();
|
|
360
|
+
this._abort = new AbortController();
|
|
361
|
+
|
|
362
|
+
let frame;
|
|
363
|
+
try {
|
|
364
|
+
frame = await this._fetchFrame(state, unix, {
|
|
365
|
+
signal: this._abort.signal,
|
|
366
|
+
priority: 'display',
|
|
367
|
+
});
|
|
368
|
+
} catch {
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (!frame || this._abort.signal.aborted) return;
|
|
373
|
+
|
|
374
|
+
this._frameCache.set(p.fetchKey, frame);
|
|
375
|
+
|
|
376
|
+
this._ensureLayer();
|
|
377
|
+
this._uploadFrame(state, frame);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
_applyStyle(state) {
|
|
381
|
+
if (!this.mapboxLayer) return;
|
|
382
|
+
const radarVar = (state.nexradProduct || 'REF').toUpperCase();
|
|
383
|
+
const colormapFlat = state.colormap;
|
|
384
|
+
const colormapForShader = colormapToMs(colormapFlat, radarVar, state.units);
|
|
385
|
+
const [shaderMin, shaderMax] = radarShaderValueRange(
|
|
386
|
+
colormapFlat?.[0],
|
|
387
|
+
colormapFlat?.[colormapFlat.length - 2],
|
|
388
|
+
radarVar,
|
|
389
|
+
state.units,
|
|
390
|
+
state.nexradDataSource,
|
|
391
|
+
);
|
|
392
|
+
this.mapboxLayer.setColormap(colormapForShader);
|
|
393
|
+
this.mapboxLayer.setValueRange(shaderMin, shaderMax);
|
|
394
|
+
this.mapboxLayer.setOpacity(state.opacity ?? 1);
|
|
395
|
+
this.map.triggerRepaint();
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Mouse readout at lng/lat — same sampling and formatting as aguacero-frontend `NexradReadoutFrame`
|
|
400
|
+
* (polar sample + velocity/hydro/range checks). Uses the cached frame for the current timeline key.
|
|
401
|
+
*
|
|
402
|
+
* @param {number} lng
|
|
403
|
+
* @param {number} lat
|
|
404
|
+
* @param {object} state - Latest `state:change` payload (needs `colormap`, NEXRAD fields).
|
|
405
|
+
* @returns {object | null}
|
|
406
|
+
*/
|
|
407
|
+
getInspectPayload(lng, lat, state) {
|
|
408
|
+
if (!state?.isNexrad || !state.nexradSite || state.nexradTimestamp == null) return null;
|
|
409
|
+
if (state.visible === false || (state.opacity ?? 1) <= 0) return null;
|
|
410
|
+
|
|
411
|
+
const unix = Number(state.nexradTimestamp);
|
|
412
|
+
const p = this._buildFetchParamsForUnix(state, unix);
|
|
413
|
+
if (!p) return null;
|
|
414
|
+
|
|
415
|
+
const frame = this._frameCache.get(p.fetchKey);
|
|
416
|
+
if (!frame) return null;
|
|
417
|
+
|
|
418
|
+
const colormapFlat = state.colormap;
|
|
419
|
+
if (!colormapFlat || colormapFlat.length < 2) return null;
|
|
420
|
+
|
|
421
|
+
const rangeMin = colormapFlat[0];
|
|
422
|
+
const rangeMax = colormapFlat[colormapFlat.length - 2];
|
|
423
|
+
|
|
424
|
+
const radarVariable = (state.nexradProduct || 'REF').toUpperCase();
|
|
425
|
+
const radarSource = state.nexradDataSource === 'level3' ? 'level3' : 'level2';
|
|
426
|
+
const displayUnits =
|
|
427
|
+
state.units && String(state.units).toLowerCase() !== 'none'
|
|
428
|
+
? state.units
|
|
429
|
+
: isVelocityStyleRadarVar(radarVariable)
|
|
430
|
+
? 'm/s'
|
|
431
|
+
: '';
|
|
432
|
+
|
|
433
|
+
const gpuFrame = prepareRadarFrameForGpuReadout(frame, mapboxFrameUploadOptionsForNexradState(state));
|
|
434
|
+
const sample = sampleNexradFrameAtLatLon(gpuFrame, lat, lng, { smoothPolar: this._gateSmoothing });
|
|
435
|
+
if (!sample) return null;
|
|
436
|
+
|
|
437
|
+
const valueMs = sample.value;
|
|
438
|
+
const isHydro = radarSource === 'level3' && nexradLevel3IsHydrometeorClassification(radarVariable);
|
|
439
|
+
|
|
440
|
+
/** @type {number | string} */
|
|
441
|
+
let valueOut;
|
|
442
|
+
let unitOut = displayUnits || '';
|
|
443
|
+
|
|
444
|
+
if (isVelocityStyleRadarVar(radarVariable)) {
|
|
445
|
+
valueOut = velocityMsToDisplay(valueMs, displayUnits);
|
|
446
|
+
} else if (isHydro) {
|
|
447
|
+
const label = nexradHydrometeorLabelForClassIndex(valueMs);
|
|
448
|
+
if (label == null) return null;
|
|
449
|
+
valueOut = label;
|
|
450
|
+
unitOut = '';
|
|
451
|
+
} else {
|
|
452
|
+
valueOut = formatNexradInspectNumeric(valueMs, radarVariable);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
let inRange;
|
|
456
|
+
if (isHydro) {
|
|
457
|
+
const idx = Math.round(Number(valueMs));
|
|
458
|
+
inRange =
|
|
459
|
+
(rangeMin == null || idx >= rangeMin) && (rangeMax == null || idx <= rangeMax);
|
|
460
|
+
} else if (isVelocityStyleRadarVar(radarVariable)) {
|
|
461
|
+
inRange =
|
|
462
|
+
(rangeMin == null || valueOut >= rangeMin) && (rangeMax == null || valueOut <= rangeMax);
|
|
463
|
+
} else {
|
|
464
|
+
inRange =
|
|
465
|
+
(rangeMin == null || valueOut >= rangeMin) && (rangeMax == null || valueOut <= rangeMax);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
if (!inRange) return null;
|
|
469
|
+
|
|
470
|
+
const tiltDeg = Number.isFinite(state.nexradTilt) ? state.nexradTilt : getDefaultRadarTilt(state.nexradSite);
|
|
471
|
+
const bhKm = beamHeightKm(sample.groundRangeKm, tiltDeg);
|
|
472
|
+
const metric = state.units === 'metric';
|
|
473
|
+
const beamHeightDisplay = metric ? bhKm : bhKm * 3.280839895013123;
|
|
474
|
+
const beamUnit = metric ? 'km' : 'kft';
|
|
475
|
+
|
|
476
|
+
const fldKey = state.variable;
|
|
477
|
+
return {
|
|
478
|
+
lngLat: { lng, lat },
|
|
479
|
+
variable: {
|
|
480
|
+
code: fldKey,
|
|
481
|
+
name: this.core.getVariableDisplayName(fldKey),
|
|
482
|
+
},
|
|
483
|
+
value: valueOut,
|
|
484
|
+
unit: unitOut,
|
|
485
|
+
beamHeight:
|
|
486
|
+
Number.isFinite(beamHeightDisplay) && Number.isFinite(bhKm)
|
|
487
|
+
? { value: beamHeightDisplay, unit: beamUnit }
|
|
488
|
+
: null,
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
}
|