@aguacerowx/javascript-sdk 0.0.23 → 0.0.25
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/dist/AguaceroCore.js +794 -207
- package/dist/default-colormaps.js +32 -0
- package/dist/dictionaries.js +73 -0
- package/dist/getBundleId.js +9 -20
- package/dist/getBundleId.native.js +18 -0
- package/dist/gridDecodePipeline.js +37 -0
- package/dist/gridDecodeWorker.js +31 -0
- package/dist/index.js +172 -1
- package/dist/nexradTiltCoalesce.js +95 -0
- package/dist/nexradTilts.js +129 -0
- package/dist/nexrad_level3_catalog.js +56 -0
- package/dist/nexrad_support.js +269 -0
- package/dist/satellite_support.js +395 -0
- package/package.json +7 -1
- package/src/AguaceroCore.js +1623 -1623
- package/src/nexrad_support.js +306 -306
package/src/AguaceroCore.js
CHANGED
|
@@ -1,1624 +1,1624 @@
|
|
|
1
|
-
// AguaceroCore.js - The Headless "Engine"
|
|
2
|
-
|
|
3
|
-
// --- Non-UI Imports ---
|
|
4
|
-
import { EventEmitter } from './events.js';
|
|
5
|
-
import { COORDINATE_CONFIGS } from './coordinate_configs.js';
|
|
6
|
-
import { getUnitConversionFunction } from './unitConversions.js';
|
|
7
|
-
import { DICTIONARIES, MODEL_CONFIGS } from './dictionaries.js';
|
|
8
|
-
import { DEFAULT_COLORMAPS } from './default-colormaps.js';
|
|
9
|
-
import proj4 from 'proj4';
|
|
10
|
-
import { processCompressedGrid } from './gridDecodePipeline.js';
|
|
11
|
-
import { getBundleId } from './getBundleId';
|
|
12
|
-
import {
|
|
13
|
-
SATELLITE_FRAMES_URL,
|
|
14
|
-
buildSatelliteTimelineForSelection,
|
|
15
|
-
formatTimelineDurationValue,
|
|
16
|
-
resolveSatelliteSectorLabel,
|
|
17
|
-
resolveSatelliteDurationOption,
|
|
18
|
-
parseTimelineDurationHours,
|
|
19
|
-
} from './satellite_support.js';
|
|
20
|
-
import {
|
|
21
|
-
fetchNexradTimesListing,
|
|
22
|
-
inferNexradDataSourceForProduct,
|
|
23
|
-
nexradColormapFldKey,
|
|
24
|
-
variableToNexradGroup,
|
|
25
|
-
getAvailableNexradTilts,
|
|
26
|
-
getRawNexradTiltsForCoalesce,
|
|
27
|
-
} from './nexrad_support.js';
|
|
28
|
-
import { nexradLayerTiltToDisplayOption } from './nexradTiltCoalesce.js';
|
|
29
|
-
import { NEXRAD_LEVEL3_ELEV, getNexradLevel3EntryByRadarKey } from './nexrad_level3_catalog.js';
|
|
30
|
-
import {
|
|
31
|
-
setRadarTiltsManifest,
|
|
32
|
-
fetchRadarTiltsManifestFromNetwork,
|
|
33
|
-
getDefaultRadarTilt,
|
|
34
|
-
formatTiltForApi,
|
|
35
|
-
clampNexradTiltForVariable,
|
|
36
|
-
getRadarTilts,
|
|
37
|
-
} from './nexradTilts.js';
|
|
38
|
-
|
|
39
|
-
// --- Non-UI Helper Functions ---
|
|
40
|
-
function hrdpsObliqueTransform(rotated_lon, rotated_lat) {
|
|
41
|
-
const o_lat_p = 53.91148; const o_lon_p = 245.305142;
|
|
42
|
-
const DEG_TO_RAD = Math.PI / 180.0; const RAD_TO_DEG = 180.0 / Math.PI;
|
|
43
|
-
const o_lat_p_rad = o_lat_p * DEG_TO_RAD;
|
|
44
|
-
const rot_lon_rad = rotated_lon * DEG_TO_RAD;
|
|
45
|
-
const rot_lat_rad = rotated_lat * DEG_TO_RAD;
|
|
46
|
-
const sin_rot_lat = Math.sin(rot_lat_rad); const cos_rot_lat = Math.cos(rot_lat_rad);
|
|
47
|
-
const sin_rot_lon = Math.sin(rot_lon_rad); const cos_rot_lon = Math.cos(rot_lon_rad);
|
|
48
|
-
const sin_o_lat_p = Math.sin(o_lat_p_rad); const cos_o_lat_p = Math.cos(o_lat_p_rad);
|
|
49
|
-
const sin_lat = cos_o_lat_p * sin_rot_lat + sin_o_lat_p * cos_rot_lat * cos_rot_lon;
|
|
50
|
-
let lat = Math.asin(sin_lat) * RAD_TO_DEG;
|
|
51
|
-
const sin_lon_num = cos_rot_lat * sin_rot_lon;
|
|
52
|
-
const sin_lon_den = -sin_o_lat_p * sin_rot_lat + cos_o_lat_p * cos_rot_lat * cos_rot_lon;
|
|
53
|
-
let lon = Math.atan2(sin_lon_num, sin_lon_den) * RAD_TO_DEG + o_lon_p;
|
|
54
|
-
if (lon > 180) lon -= 360; else if (lon < -180) lon += 360;
|
|
55
|
-
return [lon, lat];
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function findLatestModelRun(modelsData, modelName) {
|
|
59
|
-
const model = modelsData?.[modelName];
|
|
60
|
-
if (!model) return null;
|
|
61
|
-
const availableDates = Object.keys(model).sort((a, b) => b.localeCompare(a));
|
|
62
|
-
for (const date of availableDates) {
|
|
63
|
-
const runs = model[date];
|
|
64
|
-
if (!runs) continue;
|
|
65
|
-
const availableRuns = Object.keys(runs).sort((a, b) => b.localeCompare(a));
|
|
66
|
-
if (availableRuns.length > 0) return { date: date, run: availableRuns[0] };
|
|
67
|
-
}
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* model-status JSON uses string keys for runs (often zero-padded: "00", "06").
|
|
73
|
-
* Direct lookup modelStatus[model][date][run] fails if state.run is "6" but the key is "06".
|
|
74
|
-
* Returns the hour list and the run key that matched.
|
|
75
|
-
*/
|
|
76
|
-
function resolveModelRunHours(modelStatus, model, date, run) {
|
|
77
|
-
const runs = modelStatus?.[model]?.[date];
|
|
78
|
-
if (!runs || run == null || run === '') {
|
|
79
|
-
return {
|
|
80
|
-
hours: [],
|
|
81
|
-
matchedRunKey: null,
|
|
82
|
-
availableRunKeys: runs ? Object.keys(runs) : [],
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
const runStr = String(run);
|
|
86
|
-
const candidates = new Set([runStr]);
|
|
87
|
-
const n = parseInt(runStr, 10);
|
|
88
|
-
if (!Number.isNaN(n)) {
|
|
89
|
-
candidates.add(String(n));
|
|
90
|
-
candidates.add(String(n).padStart(2, '0'));
|
|
91
|
-
candidates.add(String(n).padStart(3, '0'));
|
|
92
|
-
}
|
|
93
|
-
for (const key of candidates) {
|
|
94
|
-
const h = runs[key];
|
|
95
|
-
if (h && Array.isArray(h) && h.length > 0) {
|
|
96
|
-
return { hours: h, matchedRunKey: key, availableRunKeys: Object.keys(runs) };
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
if (!Number.isNaN(n)) {
|
|
100
|
-
for (const k of Object.keys(runs)) {
|
|
101
|
-
const kn = parseInt(k, 10);
|
|
102
|
-
if (!Number.isNaN(kn) && kn === n) {
|
|
103
|
-
const h = runs[k];
|
|
104
|
-
if (h && Array.isArray(h) && h.length > 0) {
|
|
105
|
-
return { hours: h, matchedRunKey: k, availableRunKeys: Object.keys(runs) };
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
return { hours: [], matchedRunKey: null, availableRunKeys: Object.keys(runs) };
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export class AguaceroCore extends EventEmitter {
|
|
114
|
-
constructor(options = {}) {
|
|
115
|
-
super();
|
|
116
|
-
this.isReactNative = typeof navigator !== 'undefined' && navigator.product === 'ReactNative';
|
|
117
|
-
this.apiKey = options.apiKey;
|
|
118
|
-
/** Passed as CloudFront `userId` for satellite KTX2 URLs (production uses the authenticated account id). */
|
|
119
|
-
this.userId = options.userId ?? 'sdk-user';
|
|
120
|
-
this.bundleId = getBundleId();
|
|
121
|
-
this.baseGridUrl = 'https://d3dc62msmxkrd7.cloudfront.net';
|
|
122
|
-
/** @type {Worker | null} */
|
|
123
|
-
this._gridDecodeWorker = null;
|
|
124
|
-
/** When true, skip Worker and use {@link processCompressedGrid} on the main thread. */
|
|
125
|
-
this._gridDecodeWorkerDisabled = false;
|
|
126
|
-
this._gridDecodeMsgId = 0;
|
|
127
|
-
this.statusUrl = 'https://d3dc62msmxkrd7.cloudfront.net/model-status';
|
|
128
|
-
this.modelStatus = null;
|
|
129
|
-
this.mrmsStatus = null;
|
|
130
|
-
/** @type {{ objects?: Array<{ key: string }> } | null} */
|
|
131
|
-
this.satelliteListing = null;
|
|
132
|
-
this.dataCache = new Map();
|
|
133
|
-
this.abortControllers = new Map();
|
|
134
|
-
this.isPlaying = false;
|
|
135
|
-
this.playIntervalId = null;
|
|
136
|
-
this.playbackSpeed = options.playbackSpeed || 500;
|
|
137
|
-
this.customColormaps = options.customColormaps || {};
|
|
138
|
-
|
|
139
|
-
const userLayerOptions = options.layerOptions || {};
|
|
140
|
-
// EDIT: Determine initial mode from options
|
|
141
|
-
const initialMode = userLayerOptions.mode || 'model';
|
|
142
|
-
const initialVariable = userLayerOptions.variable || null;
|
|
143
|
-
|
|
144
|
-
const initialSatellite = initialMode === 'satellite';
|
|
145
|
-
const initialNexrad = initialMode === 'nexrad';
|
|
146
|
-
const initialNexradProd = userLayerOptions.nexradProduct || 'REF';
|
|
147
|
-
const initialNexradDs = inferNexradDataSourceForProduct(initialNexradProd);
|
|
148
|
-
const initialNexradFld = initialNexrad
|
|
149
|
-
? nexradColormapFldKey(initialNexradDs, initialNexradProd)
|
|
150
|
-
: initialVariable;
|
|
151
|
-
let initialSatelliteInstrumentId = null;
|
|
152
|
-
let initialSatelliteSectorLabel = null;
|
|
153
|
-
let initialSatelliteChannel = null;
|
|
154
|
-
if (initialSatellite) {
|
|
155
|
-
initialSatelliteInstrumentId = userLayerOptions.satelliteId ?? 'GOES19-EAST';
|
|
156
|
-
initialSatelliteSectorLabel = resolveSatelliteSectorLabel(
|
|
157
|
-
userLayerOptions.satelliteSector ?? userLayerOptions.sector ?? 'conus',
|
|
158
|
-
);
|
|
159
|
-
initialSatelliteChannel =
|
|
160
|
-
userLayerOptions.satelliteProduct ??
|
|
161
|
-
userLayerOptions.satelliteChannel ??
|
|
162
|
-
initialVariable ??
|
|
163
|
-
'C13';
|
|
164
|
-
}
|
|
165
|
-
this.state = {
|
|
166
|
-
model: userLayerOptions.model || 'gfs',
|
|
167
|
-
// EDIT: Set isMRMS based on the initial mode
|
|
168
|
-
isMRMS: initialMode === 'mrms' && !initialSatellite && !initialNexrad,
|
|
169
|
-
mrmsTimestamp: null,
|
|
170
|
-
variable: initialNexrad
|
|
171
|
-
? initialNexradFld
|
|
172
|
-
: initialSatellite && initialSatelliteInstrumentId
|
|
173
|
-
? initialSatelliteChannel
|
|
174
|
-
: initialVariable,
|
|
175
|
-
date: null,
|
|
176
|
-
run: null,
|
|
177
|
-
forecastHour: 0,
|
|
178
|
-
visible: true,
|
|
179
|
-
opacity: userLayerOptions.opacity ?? 1,
|
|
180
|
-
units: options.initialUnit || 'imperial',
|
|
181
|
-
shaderSmoothingEnabled: options.shaderSmoothingEnabled ?? true,
|
|
182
|
-
isSatellite: initialSatellite,
|
|
183
|
-
satelliteInstrumentId: initialSatelliteInstrumentId,
|
|
184
|
-
satelliteSectorLabel: initialSatelliteSectorLabel,
|
|
185
|
-
satelliteChannel: initialSatelliteChannel,
|
|
186
|
-
satelliteTimestamp: userLayerOptions.satelliteTimestamp != null ? Number(userLayerOptions.satelliteTimestamp) : null,
|
|
187
|
-
satelliteTier: userLayerOptions.satelliteTier || 'basic',
|
|
188
|
-
satelliteDurationValue: formatTimelineDurationValue(
|
|
189
|
-
userLayerOptions.satelliteDurationValue != null ? userLayerOptions.satelliteDurationValue : '1',
|
|
190
|
-
),
|
|
191
|
-
mrmsDurationValue: formatTimelineDurationValue(
|
|
192
|
-
userLayerOptions.mrmsDurationValue != null ? userLayerOptions.mrmsDurationValue : '1',
|
|
193
|
-
),
|
|
194
|
-
nexradDurationValue: formatTimelineDurationValue(
|
|
195
|
-
userLayerOptions.nexradDurationValue != null ? userLayerOptions.nexradDurationValue : '1',
|
|
196
|
-
),
|
|
197
|
-
isNexrad: initialNexrad,
|
|
198
|
-
nexradSite: userLayerOptions.nexradSite ?? null,
|
|
199
|
-
nexradDataSource: initialNexradDs,
|
|
200
|
-
nexradProduct: initialNexradProd,
|
|
201
|
-
nexradTilt:
|
|
202
|
-
userLayerOptions.nexradTilt != null
|
|
203
|
-
? Number(userLayerOptions.nexradTilt)
|
|
204
|
-
: userLayerOptions.nexradSite
|
|
205
|
-
? getDefaultRadarTilt(userLayerOptions.nexradSite)
|
|
206
|
-
: null,
|
|
207
|
-
nexradTimestamp: userLayerOptions.nexradTimestamp != null ? Number(userLayerOptions.nexradTimestamp) : null,
|
|
208
|
-
nexradStormRelative: userLayerOptions.nexradStormRelative === true,
|
|
209
|
-
/** When true, mapsgl shows clickable NEXRAD site markers (independent of selected site). */
|
|
210
|
-
nexradShowSitesPicker: userLayerOptions.nexradShowSitesPicker !== false,
|
|
211
|
-
};
|
|
212
|
-
|
|
213
|
-
this.autoRefreshEnabled = options.autoRefresh ?? false;
|
|
214
|
-
this.autoRefreshIntervalSeconds = options.autoRefreshInterval ?? 60;
|
|
215
|
-
this.autoRefreshIntervalId = null;
|
|
216
|
-
|
|
217
|
-
/** @type {Record<string, { unixTimes?: number[]; timeToKeyMap?: Record<string, string>; listWindowHours?: number }>} */
|
|
218
|
-
this.nexradTimesByStation = {};
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
async setState(newState) {
|
|
222
|
-
const patch = { ...newState };
|
|
223
|
-
if ('satelliteKey' in patch) delete patch.satelliteKey;
|
|
224
|
-
if ('nexradDataSource' in patch && !('nexradProduct' in patch)) {
|
|
225
|
-
delete patch.nexradDataSource;
|
|
226
|
-
}
|
|
227
|
-
const willBeNexrad =
|
|
228
|
-
patch.isNexrad !== undefined ? Boolean(patch.isNexrad) : this.state.isNexrad;
|
|
229
|
-
if (willBeNexrad && 'nexradProduct' in patch && patch.nexradProduct != null) {
|
|
230
|
-
const p = (patch.nexradProduct || 'REF').toUpperCase();
|
|
231
|
-
const prevP = (this.state.nexradProduct || 'REF').toUpperCase();
|
|
232
|
-
const ds = inferNexradDataSourceForProduct(p);
|
|
233
|
-
patch.nexradProduct = p;
|
|
234
|
-
patch.nexradDataSource = ds;
|
|
235
|
-
patch.variable = nexradColormapFldKey(ds, p);
|
|
236
|
-
if (!('nexradStormRelative' in newState)) {
|
|
237
|
-
// Default: base radial velocity (L3 N0G only) — one fetch per time; fast scrub.
|
|
238
|
-
// SRV (N0G + N0S) is opt-in: setNexradStormRelative(true) or pass nexradStormRelative in setState.
|
|
239
|
-
// Re-selecting the same product (e.g. VEL → VEL) leaves the current SRV flag alone.
|
|
240
|
-
if (p !== 'VEL' || prevP !== 'VEL') {
|
|
241
|
-
patch.nexradStormRelative = false;
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
if ('forecastHour' in patch && patch.forecastHour != null) {
|
|
246
|
-
patch.forecastHour = Number(patch.forecastHour);
|
|
247
|
-
}
|
|
248
|
-
if ('mrmsTimestamp' in patch && patch.mrmsTimestamp != null) {
|
|
249
|
-
patch.mrmsTimestamp = Number(patch.mrmsTimestamp);
|
|
250
|
-
}
|
|
251
|
-
if ('satelliteTimestamp' in patch && patch.satelliteTimestamp != null) {
|
|
252
|
-
patch.satelliteTimestamp = Number(patch.satelliteTimestamp);
|
|
253
|
-
}
|
|
254
|
-
if ('satelliteDurationValue' in patch && patch.satelliteDurationValue != null) {
|
|
255
|
-
patch.satelliteDurationValue = formatTimelineDurationValue(patch.satelliteDurationValue);
|
|
256
|
-
}
|
|
257
|
-
if ('mrmsDurationValue' in patch && patch.mrmsDurationValue != null) {
|
|
258
|
-
patch.mrmsDurationValue = formatTimelineDurationValue(patch.mrmsDurationValue);
|
|
259
|
-
}
|
|
260
|
-
if ('nexradDurationValue' in patch && patch.nexradDurationValue != null) {
|
|
261
|
-
patch.nexradDurationValue = formatTimelineDurationValue(patch.nexradDurationValue);
|
|
262
|
-
}
|
|
263
|
-
if ('nexradTimestamp' in patch && patch.nexradTimestamp != null) {
|
|
264
|
-
patch.nexradTimestamp = Number(patch.nexradTimestamp);
|
|
265
|
-
}
|
|
266
|
-
if ('nexradTilt' in patch && patch.nexradTilt != null) {
|
|
267
|
-
patch.nexradTilt = Number(patch.nexradTilt);
|
|
268
|
-
}
|
|
269
|
-
Object.assign(this.state, patch);
|
|
270
|
-
this._emitStateChange();
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
/**
|
|
274
|
-
* Forecast hours for the current model/date/run (normalized numbers).
|
|
275
|
-
* Tolerates model-status run keys like "06" vs state.run "6" so preload and slider stay in sync.
|
|
276
|
-
*/
|
|
277
|
-
getAvailableForecastHours() {
|
|
278
|
-
if (this.state.isMRMS || this.state.isSatellite || this.state.isNexrad) return [];
|
|
279
|
-
if (!this.state.model || this.state.date == null || this.state.run == null) return [];
|
|
280
|
-
|
|
281
|
-
const resolved = resolveModelRunHours(
|
|
282
|
-
this.modelStatus,
|
|
283
|
-
this.state.model,
|
|
284
|
-
this.state.date,
|
|
285
|
-
this.state.run
|
|
286
|
-
);
|
|
287
|
-
let hours = resolved.hours || [];
|
|
288
|
-
|
|
289
|
-
if (hours.length > 0) {
|
|
290
|
-
hours = hours.map(h => (typeof h === 'string' ? parseInt(h, 10) : Number(h))).filter(h => !Number.isNaN(h));
|
|
291
|
-
}
|
|
292
|
-
if (this.state.variable === 'ptypeRefl' && this.state.model === 'hrrr' && hours.length > 0) {
|
|
293
|
-
hours = hours.filter(hour => hour !== 0);
|
|
294
|
-
}
|
|
295
|
-
return hours;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
_computeSatelliteTimeline() {
|
|
299
|
-
const { satelliteInstrumentId, satelliteSectorLabel, satelliteChannel } = this.state;
|
|
300
|
-
if (!satelliteInstrumentId || !satelliteSectorLabel || !satelliteChannel || !this.satelliteListing?.objects) {
|
|
301
|
-
return { unixTimes: [], timeToFileMap: {} };
|
|
302
|
-
}
|
|
303
|
-
const allFiles = this.satelliteListing.objects.map((o) => o.key);
|
|
304
|
-
const sectorName = satelliteSectorLabel;
|
|
305
|
-
const tier = this.state.satelliteTier || 'basic';
|
|
306
|
-
const durationOpt = resolveSatelliteDurationOption(sectorName, tier, this.state.satelliteDurationValue);
|
|
307
|
-
return buildSatelliteTimelineForSelection(
|
|
308
|
-
{
|
|
309
|
-
satelliteInstrumentId,
|
|
310
|
-
satelliteSectorLabel,
|
|
311
|
-
satelliteChannel,
|
|
312
|
-
},
|
|
313
|
-
allFiles,
|
|
314
|
-
durationOpt,
|
|
315
|
-
);
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* MRMS timestamps for a variable, oldest first (matches satellite timelines and left→right time sliders).
|
|
320
|
-
* Limited to the last N hours relative to the newest frame (see mrmsDurationValue).
|
|
321
|
-
* @param {string} variable
|
|
322
|
-
* @returns {number[]}
|
|
323
|
-
*/
|
|
324
|
-
_getFilteredMrmsTimestampsForVariable(variable) {
|
|
325
|
-
const raw = this.mrmsStatus?.[variable];
|
|
326
|
-
if (!raw || !raw.length) return [];
|
|
327
|
-
const hours = parseTimelineDurationHours(this.state.mrmsDurationValue);
|
|
328
|
-
let list = [...raw]
|
|
329
|
-
.map((t) => Number(t))
|
|
330
|
-
.filter((t) => !Number.isNaN(t))
|
|
331
|
-
.sort((a, b) => a - b);
|
|
332
|
-
if (hours > 0 && list.length > 0) {
|
|
333
|
-
const latest = list[list.length - 1];
|
|
334
|
-
const cutoff = latest - hours * 3600;
|
|
335
|
-
list = list.filter((t) => t >= cutoff);
|
|
336
|
-
}
|
|
337
|
-
return list;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
_nexradListingWindowHours() {
|
|
341
|
-
return parseTimelineDurationHours(this.state.nexradDurationValue);
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
_getFilteredNexradTimestampsForVariable(rawList) {
|
|
345
|
-
if (!rawList || !rawList.length) return [];
|
|
346
|
-
const hours = this._nexradListingWindowHours();
|
|
347
|
-
let list = [...rawList]
|
|
348
|
-
.map((t) => Number(t))
|
|
349
|
-
.filter((t) => !Number.isNaN(t))
|
|
350
|
-
.sort((a, b) => a - b);
|
|
351
|
-
if (hours > 0 && list.length > 0) {
|
|
352
|
-
const latest = list[list.length - 1];
|
|
353
|
-
const cutoff = latest - hours * 3600;
|
|
354
|
-
list = list.filter((t) => t >= cutoff);
|
|
355
|
-
}
|
|
356
|
-
return list;
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
/**
|
|
360
|
-
* Cache key for {@link this.nexradTimesByStation} — matches frontend composite keys (tilt + variable group/source).
|
|
361
|
-
*/
|
|
362
|
-
_nexradTimesCacheKey() {
|
|
363
|
-
const s = this.state;
|
|
364
|
-
if (!s.isNexrad || !s.nexradSite) return null;
|
|
365
|
-
const site = s.nexradSite;
|
|
366
|
-
const variable = s.nexradProduct || 'REF';
|
|
367
|
-
const ds = s.nexradDataSource || 'level2';
|
|
368
|
-
const tiltNum = s.nexradTilt != null ? s.nexradTilt : getDefaultRadarTilt(site);
|
|
369
|
-
const elevNormUse =
|
|
370
|
-
ds === 'level3'
|
|
371
|
-
? NEXRAD_LEVEL3_ELEV
|
|
372
|
-
: formatTiltForApi(clampNexradTiltForVariable(site, variable, tiltNum));
|
|
373
|
-
const group = ds === 'level3' ? 'l3' : variableToNexradGroup(variable);
|
|
374
|
-
const l3Product =
|
|
375
|
-
ds === 'level3'
|
|
376
|
-
? getNexradLevel3EntryByRadarKey(variable)?.product ?? (variable === 'VEL' ? 'N0G' : variable)
|
|
377
|
-
: '';
|
|
378
|
-
if (ds === 'level3') {
|
|
379
|
-
return `${site}_l3_${l3Product}_${elevNormUse}`;
|
|
380
|
-
}
|
|
381
|
-
return `${site}_${group}_${elevNormUse}`;
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
_emitStateChange() {
|
|
385
|
-
const { colormap, baseUnit } = this._getColormapForVariable(this.state.variable);
|
|
386
|
-
const toUnit = this._getTargetUnit(baseUnit, this.state.units);
|
|
387
|
-
const displayColormap = this._convertColormapUnits(colormap, baseUnit, toUnit);
|
|
388
|
-
|
|
389
|
-
let availableTimestamps = [];
|
|
390
|
-
if (this.state.isMRMS && this.state.variable && this.mrmsStatus) {
|
|
391
|
-
availableTimestamps = this._getFilteredMrmsTimestampsForVariable(this.state.variable);
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
let availableSatelliteTimestamps = [];
|
|
395
|
-
let satelliteTimeToFileMap = {};
|
|
396
|
-
if (this.state.isSatellite && this.state.satelliteInstrumentId) {
|
|
397
|
-
const timeline = this._computeSatelliteTimeline();
|
|
398
|
-
satelliteTimeToFileMap = timeline.timeToFileMap || {};
|
|
399
|
-
availableSatelliteTimestamps = [...(timeline.unixTimes || [])]
|
|
400
|
-
.map((t) => Number(t))
|
|
401
|
-
.filter((t) => !Number.isNaN(t))
|
|
402
|
-
.sort((a, b) => a - b);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
let availableNexradTimestamps = [];
|
|
406
|
-
let nexradTimeToKeyMap = {};
|
|
407
|
-
let nexradLevel3MotionTimeToKeyMap = {};
|
|
408
|
-
let availableNexradTilts = [];
|
|
409
|
-
if (this.state.isNexrad && this.state.nexradSite) {
|
|
410
|
-
const nk = this._nexradTimesCacheKey();
|
|
411
|
-
const ent = nk ? this.nexradTimesByStation[nk] : null;
|
|
412
|
-
const raw = ent?.unixTimes || [];
|
|
413
|
-
availableNexradTimestamps = this._getFilteredNexradTimestampsForVariable(raw);
|
|
414
|
-
nexradTimeToKeyMap = ent?.timeToKeyMap || {};
|
|
415
|
-
nexradLevel3MotionTimeToKeyMap = ent?.level3MotionTimeToKeyMap || {};
|
|
416
|
-
availableNexradTilts = getAvailableNexradTilts(
|
|
417
|
-
this.state.nexradSite,
|
|
418
|
-
this.state.nexradDataSource || 'level2',
|
|
419
|
-
this.state.nexradProduct || 'REF',
|
|
420
|
-
);
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
const availableHours = this.getAvailableForecastHours();
|
|
424
|
-
|
|
425
|
-
const eventPayload = {
|
|
426
|
-
...this.state,
|
|
427
|
-
availableModels: this.modelStatus ? Object.keys(this.modelStatus).sort() : [],
|
|
428
|
-
availableRuns: this.modelStatus?.[this.state.model] || {},
|
|
429
|
-
availableHours: availableHours, // <-- Changed from inline calculation
|
|
430
|
-
availableVariables: this.getAvailableVariables(this.state.isMRMS ? 'mrms' : this.state.model),
|
|
431
|
-
// We need to confirm this line is working as expected.
|
|
432
|
-
availableMRMSVariables: this.getAvailableVariables('mrms'),
|
|
433
|
-
availableTimestamps: availableTimestamps,
|
|
434
|
-
availableSatelliteTimestamps,
|
|
435
|
-
satelliteTimeToFileMap,
|
|
436
|
-
availableNexradTimestamps,
|
|
437
|
-
nexradTimeToKeyMap,
|
|
438
|
-
nexradLevel3MotionTimeToKeyMap,
|
|
439
|
-
availableNexradTilts,
|
|
440
|
-
isPlaying: this.isPlaying,
|
|
441
|
-
colormap: displayColormap,
|
|
442
|
-
colormapBaseUnit: toUnit,
|
|
443
|
-
};
|
|
444
|
-
|
|
445
|
-
this.emit('state:change', eventPayload);
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
async initialize(options = {}) {
|
|
449
|
-
await this.fetchModelStatus(true);
|
|
450
|
-
await this.fetchMRMSStatus(true);
|
|
451
|
-
await this.fetchSatelliteListing(true);
|
|
452
|
-
|
|
453
|
-
let initialState = { ...this.state };
|
|
454
|
-
|
|
455
|
-
if (initialState.isSatellite && initialState.satelliteInstrumentId) {
|
|
456
|
-
const timeline = this._computeSatelliteTimeline();
|
|
457
|
-
const tsList = [...(timeline.unixTimes || [])].sort((a, b) => a - b);
|
|
458
|
-
if (initialState.satelliteTimestamp == null && tsList.length > 0) {
|
|
459
|
-
initialState.satelliteTimestamp = tsList[tsList.length - 1];
|
|
460
|
-
}
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
// ADD: Logic to handle an initial MRMS state
|
|
464
|
-
if (initialState.isMRMS) {
|
|
465
|
-
const variable = initialState.variable;
|
|
466
|
-
if (variable && this.mrmsStatus && this.mrmsStatus[variable]) {
|
|
467
|
-
const sortedTimestamps = this._getFilteredMrmsTimestampsForVariable(variable);
|
|
468
|
-
initialState.mrmsTimestamp =
|
|
469
|
-
sortedTimestamps.length > 0 ? sortedTimestamps[sortedTimestamps.length - 1] : null;
|
|
470
|
-
} else {
|
|
471
|
-
const availableMRMSVars = this.getAvailableVariables('mrms');
|
|
472
|
-
if (availableMRMSVars.length > 0) {
|
|
473
|
-
const firstVar = availableMRMSVars[0];
|
|
474
|
-
initialState.variable = firstVar;
|
|
475
|
-
const sortedTimestamps = this._getFilteredMrmsTimestampsForVariable(firstVar);
|
|
476
|
-
initialState.mrmsTimestamp =
|
|
477
|
-
sortedTimestamps.length > 0 ? sortedTimestamps[sortedTimestamps.length - 1] : null;
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
} else if (!initialState.isSatellite && !initialState.isNexrad) {
|
|
481
|
-
// EDIT: This is the existing logic, now in an else block
|
|
482
|
-
const latestRun = findLatestModelRun(this.modelStatus, initialState.model);
|
|
483
|
-
if (latestRun) {
|
|
484
|
-
initialState = { ...initialState, ...latestRun, forecastHour: 0 };
|
|
485
|
-
if (!initialState.variable) {
|
|
486
|
-
const availableVariables = this.getAvailableVariables(initialState.model);
|
|
487
|
-
if (availableVariables && availableVariables.length > 0) {
|
|
488
|
-
initialState.variable = availableVariables[0];
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
await this.setState(initialState);
|
|
495
|
-
|
|
496
|
-
if (this.state.isNexrad) {
|
|
497
|
-
const manifest = await fetchRadarTiltsManifestFromNetwork();
|
|
498
|
-
if (manifest) setRadarTiltsManifest(manifest);
|
|
499
|
-
if (this.state.nexradSite) {
|
|
500
|
-
await this.refreshNexradTimes();
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
if (options.autoRefresh ?? this.autoRefreshEnabled) {
|
|
504
|
-
this.startAutoRefresh(options.refreshInterval ?? this.autoRefreshIntervalSeconds);
|
|
505
|
-
}
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
destroy() {
|
|
509
|
-
this.pause();
|
|
510
|
-
this.stopAutoRefresh();
|
|
511
|
-
this.dataCache.clear();
|
|
512
|
-
this.callbacks = {};
|
|
513
|
-
if (this._gridDecodeWorker) {
|
|
514
|
-
this._gridDecodeWorker.terminate();
|
|
515
|
-
this._gridDecodeWorker = null;
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
// --- Public API Methods ---
|
|
520
|
-
|
|
521
|
-
play() {
|
|
522
|
-
if (this.isPlaying) return;
|
|
523
|
-
this.isPlaying = true;
|
|
524
|
-
clearInterval(this.playIntervalId);
|
|
525
|
-
this.playIntervalId = setInterval(() => { this.step(1); }, this.playbackSpeed);
|
|
526
|
-
this.emit('playback:start', { speed: this.playbackSpeed });
|
|
527
|
-
this._emitStateChange(); // Notify UI that isPlaying is now true
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
pause() {
|
|
531
|
-
if (!this.isPlaying) return;
|
|
532
|
-
this.isPlaying = false;
|
|
533
|
-
clearInterval(this.playIntervalId);
|
|
534
|
-
this.playIntervalId = null;
|
|
535
|
-
this.emit('playback:stop');
|
|
536
|
-
this._emitStateChange(); // Notify UI that isPlaying is now false
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
togglePlay() {
|
|
540
|
-
this.isPlaying ? this.pause() : this.play();
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
step(direction = 1) {
|
|
544
|
-
if (this.state.isSatellite) {
|
|
545
|
-
const timeline = this._computeSatelliteTimeline();
|
|
546
|
-
const availableTimestamps = [...(timeline.unixTimes || [])]
|
|
547
|
-
.sort((a, b) => a - b)
|
|
548
|
-
.map((t) => Number(t));
|
|
549
|
-
if (availableTimestamps.length === 0) return;
|
|
550
|
-
|
|
551
|
-
const ts = this.state.satelliteTimestamp == null ? null : Number(this.state.satelliteTimestamp);
|
|
552
|
-
const currentIndex = ts == null ? -1 : availableTimestamps.indexOf(ts);
|
|
553
|
-
|
|
554
|
-
let nextIndex = currentIndex === -1 ? availableTimestamps.length - 1 : currentIndex + direction;
|
|
555
|
-
const maxIndex = availableTimestamps.length - 1;
|
|
556
|
-
if (nextIndex > maxIndex) nextIndex = 0;
|
|
557
|
-
if (nextIndex < 0) nextIndex = maxIndex;
|
|
558
|
-
|
|
559
|
-
this.setState({ satelliteTimestamp: availableTimestamps[nextIndex] });
|
|
560
|
-
return;
|
|
561
|
-
}
|
|
562
|
-
// --- THIS IS THE CORRECTED MRMS LOGIC ---
|
|
563
|
-
if (this.state.isMRMS) {
|
|
564
|
-
const { variable, mrmsTimestamp } = this.state;
|
|
565
|
-
if (!this.mrmsStatus || !this.mrmsStatus[variable]) {
|
|
566
|
-
return;
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
const availableTimestamps = this._getFilteredMrmsTimestampsForVariable(variable);
|
|
570
|
-
if (availableTimestamps.length === 0) return;
|
|
571
|
-
|
|
572
|
-
const ts = mrmsTimestamp == null ? null : Number(mrmsTimestamp);
|
|
573
|
-
const currentIndex = ts == null ? -1 : availableTimestamps.indexOf(ts);
|
|
574
|
-
|
|
575
|
-
if (currentIndex === -1) {
|
|
576
|
-
// If not found, reset to the latest frame (end of ascending list)
|
|
577
|
-
this.setState({ mrmsTimestamp: availableTimestamps[availableTimestamps.length - 1] });
|
|
578
|
-
return;
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
const maxIndex = availableTimestamps.length - 1;
|
|
582
|
-
let nextIndex = currentIndex + direction;
|
|
583
|
-
|
|
584
|
-
// Loop animation
|
|
585
|
-
if (nextIndex > maxIndex) nextIndex = 0;
|
|
586
|
-
if (nextIndex < 0) nextIndex = maxIndex;
|
|
587
|
-
|
|
588
|
-
const newTimestamp = availableTimestamps[nextIndex];
|
|
589
|
-
this.setState({ mrmsTimestamp: newTimestamp });
|
|
590
|
-
|
|
591
|
-
} else if (this.state.isNexrad) {
|
|
592
|
-
const nk = this._nexradTimesCacheKey();
|
|
593
|
-
const raw = nk ? this.nexradTimesByStation[nk]?.unixTimes : [];
|
|
594
|
-
const availableTimestamps = this._getFilteredNexradTimestampsForVariable(raw || []);
|
|
595
|
-
if (availableTimestamps.length === 0) return;
|
|
596
|
-
|
|
597
|
-
const ts = this.state.nexradTimestamp == null ? null : Number(this.state.nexradTimestamp);
|
|
598
|
-
const currentIndex = ts == null ? -1 : availableTimestamps.indexOf(ts);
|
|
599
|
-
|
|
600
|
-
let nextIndex = currentIndex === -1 ? availableTimestamps.length - 1 : currentIndex + direction;
|
|
601
|
-
const maxIndex = availableTimestamps.length - 1;
|
|
602
|
-
if (nextIndex > maxIndex) nextIndex = 0;
|
|
603
|
-
if (nextIndex < 0) nextIndex = maxIndex;
|
|
604
|
-
|
|
605
|
-
this.setState({ nexradTimestamp: availableTimestamps[nextIndex] });
|
|
606
|
-
} else {
|
|
607
|
-
const { forecastHour } = this.state;
|
|
608
|
-
const forecastHours = this.getAvailableForecastHours();
|
|
609
|
-
if (!forecastHours || forecastHours.length === 0) return;
|
|
610
|
-
|
|
611
|
-
const fh = Number(forecastHour);
|
|
612
|
-
const currentIndex = forecastHours.indexOf(fh);
|
|
613
|
-
if (currentIndex === -1) return;
|
|
614
|
-
|
|
615
|
-
const maxIndex = forecastHours.length - 1;
|
|
616
|
-
let nextIndex = currentIndex + direction;
|
|
617
|
-
if (nextIndex > maxIndex) nextIndex = 0;
|
|
618
|
-
if (nextIndex < 0) nextIndex = maxIndex;
|
|
619
|
-
|
|
620
|
-
const newHour = forecastHours[nextIndex];
|
|
621
|
-
this.setState({ forecastHour: newHour });
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
setPlaybackSpeed(speed) {
|
|
626
|
-
if (speed > 0) {
|
|
627
|
-
this.playbackSpeed = speed;
|
|
628
|
-
if (this.isPlaying) this.play();
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
async setShaderSmoothing(enabled) {
|
|
633
|
-
if (typeof enabled !== 'boolean' || enabled === this.state.shaderSmoothingEnabled) return;
|
|
634
|
-
await this.setState({ shaderSmoothingEnabled: enabled });
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
async setOpacity(newOpacity) {
|
|
638
|
-
const clampedOpacity = Math.max(0, Math.min(1, newOpacity));
|
|
639
|
-
if (clampedOpacity === this.state.opacity) return;
|
|
640
|
-
await this.setState({ opacity: clampedOpacity });
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
async setVariable(variable) {
|
|
644
|
-
// --- NEW CODE: Handle switching TO ptypeRefl on HRRR ---
|
|
645
|
-
if (variable === 'ptypeRefl' && this.state.model === 'hrrr' && this.state.forecastHour === 0) {
|
|
646
|
-
const availableHours = resolveModelRunHours(
|
|
647
|
-
this.modelStatus,
|
|
648
|
-
this.state.model,
|
|
649
|
-
this.state.date,
|
|
650
|
-
this.state.run
|
|
651
|
-
).hours || [];
|
|
652
|
-
const firstValidHour = availableHours.find(hour => hour !== 0) || 0;
|
|
653
|
-
await this.setState({ variable, forecastHour: firstValidHour });
|
|
654
|
-
return;
|
|
655
|
-
}
|
|
656
|
-
// --- END NEW CODE ---
|
|
657
|
-
|
|
658
|
-
await this.setState({ variable });
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
async setModel(modelName) {
|
|
662
|
-
if (modelName === this.state.model || !this.modelStatus?.[modelName]) return;
|
|
663
|
-
if (this.state.isSatellite) {
|
|
664
|
-
await this.setState({
|
|
665
|
-
isSatellite: false,
|
|
666
|
-
satelliteInstrumentId: null,
|
|
667
|
-
satelliteSectorLabel: null,
|
|
668
|
-
satelliteChannel: null,
|
|
669
|
-
satelliteTimestamp: null,
|
|
670
|
-
});
|
|
671
|
-
}
|
|
672
|
-
const latestRun = findLatestModelRun(this.modelStatus, modelName);
|
|
673
|
-
if (latestRun) {
|
|
674
|
-
// --- NEW CODE: Determine initial forecast hour ---
|
|
675
|
-
let initialHour = 0;
|
|
676
|
-
|
|
677
|
-
// If switching to HRRR with ptypeRefl, start at hour 1 instead of 0
|
|
678
|
-
if (modelName === 'hrrr' && this.state.variable === 'ptypeRefl') {
|
|
679
|
-
const availableHours = resolveModelRunHours(
|
|
680
|
-
this.modelStatus,
|
|
681
|
-
modelName,
|
|
682
|
-
latestRun.date,
|
|
683
|
-
latestRun.run
|
|
684
|
-
).hours || [];
|
|
685
|
-
initialHour = availableHours.find(hour => hour !== 0) || 0;
|
|
686
|
-
}
|
|
687
|
-
// --- END NEW CODE ---
|
|
688
|
-
|
|
689
|
-
await this.setState({
|
|
690
|
-
model: modelName,
|
|
691
|
-
date: latestRun.date,
|
|
692
|
-
run: latestRun.run,
|
|
693
|
-
forecastHour: initialHour // <-- Changed from hardcoded 0
|
|
694
|
-
});
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
async setRun(runString) {
|
|
699
|
-
const [date, run] = runString.split(':');
|
|
700
|
-
if (date !== this.state.date || run !== this.state.run) {
|
|
701
|
-
await this.setState({ date, run, forecastHour: 0 });
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
|
|
705
|
-
async setUnits(newUnits) {
|
|
706
|
-
if (newUnits === this.state.units || !['metric', 'imperial'].includes(newUnits)) return;
|
|
707
|
-
await this.setState({ units: newUnits });
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
async setMRMSVariable(variable) {
|
|
711
|
-
const sortedTimestamps = this._getFilteredMrmsTimestampsForVariable(variable);
|
|
712
|
-
const initialTimestamp =
|
|
713
|
-
sortedTimestamps.length > 0 ? sortedTimestamps[sortedTimestamps.length - 1] : null;
|
|
714
|
-
|
|
715
|
-
await this.setState({
|
|
716
|
-
variable,
|
|
717
|
-
isMRMS: true,
|
|
718
|
-
isSatellite: false,
|
|
719
|
-
satelliteInstrumentId: null,
|
|
720
|
-
satelliteSectorLabel: null,
|
|
721
|
-
satelliteChannel: null,
|
|
722
|
-
satelliteTimestamp: null,
|
|
723
|
-
mrmsTimestamp: initialTimestamp,
|
|
724
|
-
});
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
async setMRMSTimestamp(timestamp) {
|
|
728
|
-
if (!this.state.isMRMS) return;
|
|
729
|
-
await this.setState({ mrmsTimestamp: timestamp });
|
|
730
|
-
}
|
|
731
|
-
|
|
732
|
-
async setSatelliteTimestamp(timestamp) {
|
|
733
|
-
if (!this.state.isSatellite) return;
|
|
734
|
-
await this.setState({ satelliteTimestamp: timestamp != null ? Number(timestamp) : null });
|
|
735
|
-
}
|
|
736
|
-
|
|
737
|
-
/**
|
|
738
|
-
* How many hours of satellite frames to include in the timeline (positive, at most 12 hours).
|
|
739
|
-
* API default: `layerOptions.satelliteDurationValue` on construction.
|
|
740
|
-
*/
|
|
741
|
-
async setSatelliteDurationValue(value) {
|
|
742
|
-
const v = formatTimelineDurationValue(value);
|
|
743
|
-
await this.setState({ satelliteDurationValue: v });
|
|
744
|
-
if (!this.state.isSatellite || !this.state.satelliteInstrumentId) return;
|
|
745
|
-
const timeline = this._computeSatelliteTimeline();
|
|
746
|
-
const tsList = [...(timeline.unixTimes || [])]
|
|
747
|
-
.map((t) => Number(t))
|
|
748
|
-
.filter((t) => !Number.isNaN(t))
|
|
749
|
-
.sort((a, b) => a - b);
|
|
750
|
-
if (tsList.length === 0) return;
|
|
751
|
-
const cur = this.state.satelliteTimestamp;
|
|
752
|
-
const curN = cur == null ? null : Number(cur);
|
|
753
|
-
if (curN == null || !tsList.includes(curN)) {
|
|
754
|
-
await this.setState({ satelliteTimestamp: tsList[tsList.length - 1] });
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
/**
|
|
759
|
-
* Set satellite view using spacecraft id, sector, and channel/product.
|
|
760
|
-
* Omitted fields keep the current selection; when not in satellite mode, missing fields use GOES-19 East CONUS / C13 defaults.
|
|
761
|
-
* @param {{ satelliteId?: string, sector?: string, satelliteSector?: string, satelliteProduct?: string, satelliteChannel?: string, satelliteTimestamp?: number|null }} opts
|
|
762
|
-
*/
|
|
763
|
-
async setSatelliteSelection(opts = {}) {
|
|
764
|
-
const cur = this.state.isSatellite ? this.state : null;
|
|
765
|
-
const tsArg =
|
|
766
|
-
opts.satelliteTimestamp !== undefined
|
|
767
|
-
? opts.satelliteTimestamp
|
|
768
|
-
: this.state.isSatellite && this.state.satelliteTimestamp != null
|
|
769
|
-
? Number(this.state.satelliteTimestamp)
|
|
770
|
-
: undefined;
|
|
771
|
-
return this.switchMode({
|
|
772
|
-
mode: 'satellite',
|
|
773
|
-
satelliteId: opts.satelliteId ?? cur?.satelliteInstrumentId ?? 'GOES19-EAST',
|
|
774
|
-
satelliteSector: opts.satelliteSector ?? opts.sector ?? cur?.satelliteSectorLabel ?? 'conus',
|
|
775
|
-
satelliteProduct:
|
|
776
|
-
opts.satelliteProduct ?? opts.satelliteChannel ?? cur?.satelliteChannel ?? 'C13',
|
|
777
|
-
satelliteTimestamp: tsArg,
|
|
778
|
-
});
|
|
779
|
-
}
|
|
780
|
-
|
|
781
|
-
/**
|
|
782
|
-
* How many hours of MRMS frames to include in the timeline (positive, at most 12 hours).
|
|
783
|
-
* API default: `layerOptions.mrmsDurationValue` on construction.
|
|
784
|
-
*/
|
|
785
|
-
async setMRMSDurationValue(value) {
|
|
786
|
-
const v = formatTimelineDurationValue(value);
|
|
787
|
-
await this.setState({ mrmsDurationValue: v });
|
|
788
|
-
if (!this.state.isMRMS || !this.state.variable) return;
|
|
789
|
-
const filtered = this._getFilteredMrmsTimestampsForVariable(this.state.variable);
|
|
790
|
-
if (filtered.length === 0) return;
|
|
791
|
-
const cur = this.state.mrmsTimestamp;
|
|
792
|
-
const curN = cur == null ? null : Number(cur);
|
|
793
|
-
if (curN == null || !filtered.includes(curN)) {
|
|
794
|
-
await this.setState({ mrmsTimestamp: filtered[filtered.length - 1] });
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
/**
|
|
799
|
-
* NEXRAD sweep listing / scrub window in hours (independent of MRMS duration).
|
|
800
|
-
* API default: `layerOptions.nexradDurationValue` on construction.
|
|
801
|
-
*/
|
|
802
|
-
async setNexradDurationValue(value) {
|
|
803
|
-
const v = formatTimelineDurationValue(value);
|
|
804
|
-
await this.setState({ nexradDurationValue: v });
|
|
805
|
-
if (!this.state.isNexrad || !this.state.nexradSite) return;
|
|
806
|
-
await this.refreshNexradTimes();
|
|
807
|
-
const nk = this._nexradTimesCacheKey();
|
|
808
|
-
const filtered = this._getFilteredNexradTimestampsForVariable(
|
|
809
|
-
nk ? this.nexradTimesByStation[nk]?.unixTimes || [] : [],
|
|
810
|
-
);
|
|
811
|
-
if (filtered.length === 0) return;
|
|
812
|
-
const cur = this.state.nexradTimestamp;
|
|
813
|
-
const curN = cur == null ? null : Number(cur);
|
|
814
|
-
if (curN == null || !filtered.includes(curN)) {
|
|
815
|
-
await this.setState({ nexradTimestamp: filtered[filtered.length - 1] });
|
|
816
|
-
}
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
async switchMode(options) {
|
|
820
|
-
let { mode, variable, model, forecastHour, mrmsTimestamp, satelliteTimestamp } = options;
|
|
821
|
-
if (!mode) {
|
|
822
|
-
return;
|
|
823
|
-
}
|
|
824
|
-
if (mode === 'model' && !model) {
|
|
825
|
-
return;
|
|
826
|
-
}
|
|
827
|
-
if ((mode === 'mrms' || mode === 'model') && !variable) {
|
|
828
|
-
return;
|
|
829
|
-
}
|
|
830
|
-
let targetState = {};
|
|
831
|
-
if (mode === 'satellite') {
|
|
832
|
-
const satelliteInstrumentId = options.satelliteId ?? 'GOES19-EAST';
|
|
833
|
-
const satelliteSectorLabel = resolveSatelliteSectorLabel(
|
|
834
|
-
options.satelliteSector ?? options.sector ?? 'conus',
|
|
835
|
-
);
|
|
836
|
-
const satelliteChannel =
|
|
837
|
-
options.satelliteProduct ?? options.satelliteChannel ?? variable ?? 'C13';
|
|
838
|
-
const channelToken = satelliteChannel;
|
|
839
|
-
// Emit satellite mode immediately so map layers (e.g. model grid) clear before listing fetch finishes.
|
|
840
|
-
await this.setState({
|
|
841
|
-
isSatellite: true,
|
|
842
|
-
isMRMS: false,
|
|
843
|
-
isNexrad: false,
|
|
844
|
-
satelliteInstrumentId,
|
|
845
|
-
satelliteSectorLabel,
|
|
846
|
-
satelliteChannel,
|
|
847
|
-
variable: channelToken,
|
|
848
|
-
satelliteTimestamp: null,
|
|
849
|
-
mrmsTimestamp: null,
|
|
850
|
-
date: null,
|
|
851
|
-
run: null,
|
|
852
|
-
forecastHour: 0,
|
|
853
|
-
});
|
|
854
|
-
|
|
855
|
-
await this.fetchSatelliteListing(true);
|
|
856
|
-
const allFiles = this.satelliteListing?.objects?.map((o) => o.key) || [];
|
|
857
|
-
const sectorName = satelliteSectorLabel;
|
|
858
|
-
const tier = this.state.satelliteTier || 'basic';
|
|
859
|
-
const durationOpt = resolveSatelliteDurationOption(sectorName, tier, this.state.satelliteDurationValue);
|
|
860
|
-
const timeline = buildSatelliteTimelineForSelection(
|
|
861
|
-
{ satelliteInstrumentId, satelliteSectorLabel, satelliteChannel },
|
|
862
|
-
allFiles,
|
|
863
|
-
durationOpt,
|
|
864
|
-
);
|
|
865
|
-
const tsList = [...(timeline.unixTimes || [])].sort((a, b) => a - b);
|
|
866
|
-
let finalTs = satelliteTimestamp !== undefined ? satelliteTimestamp : null;
|
|
867
|
-
if (finalTs == null && tsList.length > 0) {
|
|
868
|
-
finalTs = tsList[tsList.length - 1];
|
|
869
|
-
}
|
|
870
|
-
await this.setState({
|
|
871
|
-
satelliteTimestamp: finalTs != null ? Number(finalTs) : null,
|
|
872
|
-
});
|
|
873
|
-
return;
|
|
874
|
-
} else if (mode === 'mrms') {
|
|
875
|
-
const sortedTimestamps = this._getFilteredMrmsTimestampsForVariable(variable);
|
|
876
|
-
let finalTimestamp = mrmsTimestamp;
|
|
877
|
-
if (finalTimestamp === undefined) {
|
|
878
|
-
finalTimestamp =
|
|
879
|
-
sortedTimestamps.length > 0 ? sortedTimestamps[sortedTimestamps.length - 1] : null;
|
|
880
|
-
} else if (sortedTimestamps.length > 0) {
|
|
881
|
-
const n = Number(finalTimestamp);
|
|
882
|
-
if (!sortedTimestamps.includes(n)) {
|
|
883
|
-
finalTimestamp = sortedTimestamps[sortedTimestamps.length - 1];
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
|
-
targetState = {
|
|
887
|
-
isMRMS: true,
|
|
888
|
-
isSatellite: false,
|
|
889
|
-
isNexrad: false,
|
|
890
|
-
variable: variable,
|
|
891
|
-
mrmsTimestamp: finalTimestamp,
|
|
892
|
-
model: this.state.model, date: null, run: null, forecastHour: 0,
|
|
893
|
-
satelliteInstrumentId: null,
|
|
894
|
-
satelliteSectorLabel: null,
|
|
895
|
-
satelliteChannel: null,
|
|
896
|
-
satelliteTimestamp: null,
|
|
897
|
-
};
|
|
898
|
-
} else if (mode === 'model') {
|
|
899
|
-
const latestRun = findLatestModelRun(this.modelStatus, model);
|
|
900
|
-
if (!latestRun) {
|
|
901
|
-
return;
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
// --- NEW CODE: Determine initial forecast hour for switchMode ---
|
|
905
|
-
let initialHour = forecastHour !== undefined ? forecastHour : 0;
|
|
906
|
-
|
|
907
|
-
// If switching to HRRR with ptypeRefl and hour is 0, use hour 1
|
|
908
|
-
if (model === 'hrrr' && variable === 'ptypeRefl' && initialHour === 0) {
|
|
909
|
-
const availableHours = resolveModelRunHours(
|
|
910
|
-
this.modelStatus,
|
|
911
|
-
model,
|
|
912
|
-
latestRun.date,
|
|
913
|
-
latestRun.run
|
|
914
|
-
).hours || [];
|
|
915
|
-
initialHour = availableHours.find(hour => hour !== 0) || 0;
|
|
916
|
-
}
|
|
917
|
-
// --- END NEW CODE ---
|
|
918
|
-
|
|
919
|
-
targetState = {
|
|
920
|
-
isMRMS: false,
|
|
921
|
-
isSatellite: false,
|
|
922
|
-
isNexrad: false,
|
|
923
|
-
model: model,
|
|
924
|
-
variable: variable,
|
|
925
|
-
date: latestRun.date,
|
|
926
|
-
run: latestRun.run,
|
|
927
|
-
forecastHour: initialHour, // <-- Changed
|
|
928
|
-
mrmsTimestamp: null,
|
|
929
|
-
satelliteInstrumentId: null,
|
|
930
|
-
satelliteSectorLabel: null,
|
|
931
|
-
satelliteChannel: null,
|
|
932
|
-
satelliteTimestamp: null,
|
|
933
|
-
};
|
|
934
|
-
} else if (mode === 'nexrad') {
|
|
935
|
-
const nexradProduct = options.nexradProduct || 'REF';
|
|
936
|
-
const p = nexradProduct.toUpperCase();
|
|
937
|
-
const site = options.nexradSite ?? null;
|
|
938
|
-
let tilt =
|
|
939
|
-
options.nexradTilt != null
|
|
940
|
-
? Number(options.nexradTilt)
|
|
941
|
-
: site != null
|
|
942
|
-
? getDefaultRadarTilt(site)
|
|
943
|
-
: null;
|
|
944
|
-
await this.setState({
|
|
945
|
-
isNexrad: true,
|
|
946
|
-
isMRMS: false,
|
|
947
|
-
isSatellite: false,
|
|
948
|
-
nexradSite: site,
|
|
949
|
-
nexradProduct: p,
|
|
950
|
-
nexradTilt: tilt,
|
|
951
|
-
nexradTimestamp: options.nexradTimestamp != null ? Number(options.nexradTimestamp) : null,
|
|
952
|
-
nexradStormRelative: options.nexradStormRelative === true,
|
|
953
|
-
nexradShowSitesPicker: options.nexradShowSitesPicker !== false,
|
|
954
|
-
...(options.nexradDurationValue != null
|
|
955
|
-
? { nexradDurationValue: formatTimelineDurationValue(options.nexradDurationValue) }
|
|
956
|
-
: {}),
|
|
957
|
-
mrmsTimestamp: null,
|
|
958
|
-
satelliteInstrumentId: null,
|
|
959
|
-
satelliteSectorLabel: null,
|
|
960
|
-
satelliteChannel: null,
|
|
961
|
-
satelliteTimestamp: null,
|
|
962
|
-
date: null,
|
|
963
|
-
run: null,
|
|
964
|
-
forecastHour: 0,
|
|
965
|
-
});
|
|
966
|
-
const manifest = await fetchRadarTiltsManifestFromNetwork();
|
|
967
|
-
if (manifest) setRadarTiltsManifest(manifest);
|
|
968
|
-
if (site) {
|
|
969
|
-
await this.refreshNexradTimes();
|
|
970
|
-
const filtered = this._getFilteredNexradTimestampsForVariable(
|
|
971
|
-
this.nexradTimesByStation[this._nexradTimesCacheKey()]?.unixTimes || [],
|
|
972
|
-
);
|
|
973
|
-
let ts =
|
|
974
|
-
options.nexradTimestamp != null
|
|
975
|
-
? Number(options.nexradTimestamp)
|
|
976
|
-
: this.state.nexradTimestamp;
|
|
977
|
-
if (ts == null && filtered.length > 0) ts = filtered[filtered.length - 1];
|
|
978
|
-
else if (ts != null && filtered.length > 0 && !filtered.includes(ts)) {
|
|
979
|
-
ts = filtered[filtered.length - 1];
|
|
980
|
-
}
|
|
981
|
-
await this.setState({ nexradTimestamp: ts });
|
|
982
|
-
}
|
|
983
|
-
return;
|
|
984
|
-
} else {
|
|
985
|
-
return;
|
|
986
|
-
}
|
|
987
|
-
await this.setState(targetState);
|
|
988
|
-
}
|
|
989
|
-
|
|
990
|
-
/**
|
|
991
|
-
* Keep `nexradTilt` on a coalesced elevation (same rules as aguacero-frontend tilt controls).
|
|
992
|
-
*/
|
|
993
|
-
async _snapNexradTiltToAvailableOptions() {
|
|
994
|
-
const s = this.state;
|
|
995
|
-
if (!s.isNexrad || !s.nexradSite) return;
|
|
996
|
-
const raw = getRawNexradTiltsForCoalesce(
|
|
997
|
-
s.nexradSite,
|
|
998
|
-
s.nexradDataSource || 'level2',
|
|
999
|
-
s.nexradProduct || 'REF',
|
|
1000
|
-
);
|
|
1001
|
-
if (!raw.length) return;
|
|
1002
|
-
const t = s.nexradTilt;
|
|
1003
|
-
const target = t != null && Number.isFinite(Number(t)) ? Number(t) : getDefaultRadarTilt(s.nexradSite);
|
|
1004
|
-
const canonical = nexradLayerTiltToDisplayOption(target, raw);
|
|
1005
|
-
const match = (a, b) => Math.abs(Number(a) - Number(b)) < 1e-4;
|
|
1006
|
-
if (match(s.nexradTilt, canonical)) return;
|
|
1007
|
-
await this.setState({ nexradTilt: canonical });
|
|
1008
|
-
}
|
|
1009
|
-
|
|
1010
|
-
async refreshNexradTimes() {
|
|
1011
|
-
const s0 = this.state;
|
|
1012
|
-
if (!s0.isNexrad || !s0.nexradSite) return;
|
|
1013
|
-
await this._snapNexradTiltToAvailableOptions();
|
|
1014
|
-
const s = this.state;
|
|
1015
|
-
const listingHours = this._nexradListingWindowHours();
|
|
1016
|
-
const out = await fetchNexradTimesListing({
|
|
1017
|
-
stationId: s.nexradSite,
|
|
1018
|
-
variable: s.nexradProduct || 'REF',
|
|
1019
|
-
elev: s.nexradTilt,
|
|
1020
|
-
source: s.nexradDataSource || 'level2',
|
|
1021
|
-
level3StormRelative: s.nexradStormRelative,
|
|
1022
|
-
level3Product: getNexradLevel3EntryByRadarKey(s.nexradProduct)?.product,
|
|
1023
|
-
listingWindowHours: listingHours,
|
|
1024
|
-
});
|
|
1025
|
-
const nk = this._nexradTimesCacheKey();
|
|
1026
|
-
if (!nk) return;
|
|
1027
|
-
this.nexradTimesByStation[nk] = {
|
|
1028
|
-
unixTimes: out.unixTimes,
|
|
1029
|
-
timeToKeyMap: out.timeToKeyMap,
|
|
1030
|
-
level3MotionTimeToKeyMap: out.level3MotionTimeToKeyMap,
|
|
1031
|
-
listWindowHours: listingHours,
|
|
1032
|
-
};
|
|
1033
|
-
if (out.level3MotionKey && out.level3MotionUnixTimes?.length) {
|
|
1034
|
-
this.nexradTimesByStation[out.level3MotionKey] = {
|
|
1035
|
-
unixTimes: out.level3MotionUnixTimes,
|
|
1036
|
-
timeToKeyMap: out.level3MotionTimeToKeyMap,
|
|
1037
|
-
listWindowHours: listingHours,
|
|
1038
|
-
};
|
|
1039
|
-
}
|
|
1040
|
-
if (out.l2StormMotionListKey && out.level3MotionUnixTimes?.length) {
|
|
1041
|
-
this.nexradTimesByStation[out.l2StormMotionListKey] = {
|
|
1042
|
-
unixTimes: out.level3MotionUnixTimes,
|
|
1043
|
-
timeToKeyMap: out.level3MotionTimeToKeyMap,
|
|
1044
|
-
listWindowHours: listingHours,
|
|
1045
|
-
};
|
|
1046
|
-
}
|
|
1047
|
-
const filtered = this._getFilteredNexradTimestampsForVariable(out.unixTimes || []);
|
|
1048
|
-
const map = out.timeToKeyMap || {};
|
|
1049
|
-
if (filtered.length > 0) {
|
|
1050
|
-
const cur = this.state.nexradTimestamp != null ? Number(this.state.nexradTimestamp) : null;
|
|
1051
|
-
const hasKey = cur != null && Object.prototype.hasOwnProperty.call(map, String(cur));
|
|
1052
|
-
const inWindow = cur != null && filtered.includes(cur);
|
|
1053
|
-
if (cur == null || !inWindow || !hasKey) {
|
|
1054
|
-
this.state.nexradTimestamp = filtered[filtered.length - 1];
|
|
1055
|
-
}
|
|
1056
|
-
} else if (this.state.nexradTimestamp != null) {
|
|
1057
|
-
this.state.nexradTimestamp = null;
|
|
1058
|
-
}
|
|
1059
|
-
this._emitStateChange();
|
|
1060
|
-
}
|
|
1061
|
-
|
|
1062
|
-
async setNexradSite(siteId) {
|
|
1063
|
-
if (!this.state.isNexrad) return;
|
|
1064
|
-
const tilt = siteId ? getDefaultRadarTilt(siteId) : null;
|
|
1065
|
-
await this.setState({
|
|
1066
|
-
nexradSite: siteId || null,
|
|
1067
|
-
nexradTilt: tilt,
|
|
1068
|
-
nexradTimestamp: null,
|
|
1069
|
-
});
|
|
1070
|
-
await this.refreshNexradTimes();
|
|
1071
|
-
const filtered = this._getFilteredNexradTimestampsForVariable(
|
|
1072
|
-
this.nexradTimesByStation[this._nexradTimesCacheKey()]?.unixTimes || [],
|
|
1073
|
-
);
|
|
1074
|
-
if (filtered.length > 0) {
|
|
1075
|
-
await this.setState({ nexradTimestamp: filtered[filtered.length - 1] });
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
async setNexradProduct(product) {
|
|
1080
|
-
if (!this.state.isNexrad) return;
|
|
1081
|
-
const p = (product || 'REF').toUpperCase();
|
|
1082
|
-
await this.setState({ nexradProduct: p });
|
|
1083
|
-
await this.refreshNexradTimes();
|
|
1084
|
-
const filtered = this._getFilteredNexradTimestampsForVariable(
|
|
1085
|
-
this.nexradTimesByStation[this._nexradTimesCacheKey()]?.unixTimes || [],
|
|
1086
|
-
);
|
|
1087
|
-
if (filtered.length > 0) {
|
|
1088
|
-
await this.setState({ nexradTimestamp: filtered[filtered.length - 1] });
|
|
1089
|
-
}
|
|
1090
|
-
}
|
|
1091
|
-
|
|
1092
|
-
async setNexradTilt(tilt) {
|
|
1093
|
-
if (!this.state.isNexrad || !this.state.nexradSite) return;
|
|
1094
|
-
await this.setState({ nexradTilt: tilt != null ? Number(tilt) : null });
|
|
1095
|
-
await this.refreshNexradTimes();
|
|
1096
|
-
}
|
|
1097
|
-
|
|
1098
|
-
/**
|
|
1099
|
-
* Opt-in storm-relative velocity (L3: N0G + N0S). When off (default), only base radial velocity is used (faster).
|
|
1100
|
-
* @param {boolean} enabled
|
|
1101
|
-
*/
|
|
1102
|
-
async setNexradStormRelative(enabled) {
|
|
1103
|
-
if (!this.state.isNexrad) return;
|
|
1104
|
-
await this.setState({ nexradStormRelative: Boolean(enabled) });
|
|
1105
|
-
await this.refreshNexradTimes();
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
|
-
async setNexradTimestamp(ts) {
|
|
1109
|
-
if (!this.state.isNexrad) return;
|
|
1110
|
-
await this.setState({ nexradTimestamp: ts != null ? Number(ts) : null });
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
// --- Data and Calculation Methods ---
|
|
1114
|
-
|
|
1115
|
-
_ensureGridDecodeWorker() {
|
|
1116
|
-
if (this._gridDecodeWorkerDisabled) {
|
|
1117
|
-
return null;
|
|
1118
|
-
}
|
|
1119
|
-
if (this._gridDecodeWorker) {
|
|
1120
|
-
return this._gridDecodeWorker;
|
|
1121
|
-
}
|
|
1122
|
-
if (typeof Worker === 'undefined') {
|
|
1123
|
-
this._gridDecodeWorkerDisabled = true;
|
|
1124
|
-
return null;
|
|
1125
|
-
}
|
|
1126
|
-
try {
|
|
1127
|
-
this._gridDecodeWorker = new Worker(new URL('./gridDecodeWorker.js', import.meta.url), {
|
|
1128
|
-
type: 'module',
|
|
1129
|
-
});
|
|
1130
|
-
this._gridDecodeWorker.addEventListener('error', () => {
|
|
1131
|
-
this._gridDecodeWorkerDisabled = true;
|
|
1132
|
-
if (this._gridDecodeWorker) {
|
|
1133
|
-
this._gridDecodeWorker.terminate();
|
|
1134
|
-
this._gridDecodeWorker = null;
|
|
1135
|
-
}
|
|
1136
|
-
});
|
|
1137
|
-
} catch {
|
|
1138
|
-
this._gridDecodeWorkerDisabled = true;
|
|
1139
|
-
return null;
|
|
1140
|
-
}
|
|
1141
|
-
return this._gridDecodeWorker;
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
/**
|
|
1145
|
-
* Offloads zstd + delta decode + transform to a Worker when available; falls back to main-thread
|
|
1146
|
-
* {@link processCompressedGrid}. Uses a copy for postMessage transfer so the original `compressedData`
|
|
1147
|
-
* stays valid if the Worker path fails.
|
|
1148
|
-
*/
|
|
1149
|
-
_decodeGridPayload(compressedData, encoding) {
|
|
1150
|
-
const worker = this._ensureGridDecodeWorker();
|
|
1151
|
-
if (!worker) {
|
|
1152
|
-
return Promise.resolve(processCompressedGrid(compressedData, encoding));
|
|
1153
|
-
}
|
|
1154
|
-
const payload = compressedData.slice();
|
|
1155
|
-
return new Promise((resolve, reject) => {
|
|
1156
|
-
const id = ++this._gridDecodeMsgId;
|
|
1157
|
-
const onMsg = (e) => {
|
|
1158
|
-
if (!e.data || e.data.id !== id) {
|
|
1159
|
-
return;
|
|
1160
|
-
}
|
|
1161
|
-
worker.removeEventListener('message', onMsg);
|
|
1162
|
-
worker.removeEventListener('error', onErr);
|
|
1163
|
-
if (e.data.error) {
|
|
1164
|
-
reject(new Error(e.data.error));
|
|
1165
|
-
return;
|
|
1166
|
-
}
|
|
1167
|
-
const data = new Uint8Array(e.data.dataBuffer, e.data.dataByteOffset, e.data.dataByteLength);
|
|
1168
|
-
resolve({ data, encoding: e.data.encoding });
|
|
1169
|
-
};
|
|
1170
|
-
const onErr = (err) => {
|
|
1171
|
-
worker.removeEventListener('message', onMsg);
|
|
1172
|
-
worker.removeEventListener('error', onErr);
|
|
1173
|
-
reject(err);
|
|
1174
|
-
};
|
|
1175
|
-
worker.addEventListener('message', onMsg);
|
|
1176
|
-
worker.addEventListener('error', onErr);
|
|
1177
|
-
try {
|
|
1178
|
-
worker.postMessage(
|
|
1179
|
-
{
|
|
1180
|
-
id,
|
|
1181
|
-
encoding,
|
|
1182
|
-
compressedBuffer: payload.buffer,
|
|
1183
|
-
compressedByteOffset: payload.byteOffset,
|
|
1184
|
-
compressedByteLength: payload.byteLength,
|
|
1185
|
-
},
|
|
1186
|
-
[payload.buffer]
|
|
1187
|
-
);
|
|
1188
|
-
} catch (err) {
|
|
1189
|
-
worker.removeEventListener('message', onMsg);
|
|
1190
|
-
worker.removeEventListener('error', onErr);
|
|
1191
|
-
reject(err);
|
|
1192
|
-
}
|
|
1193
|
-
});
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
async _loadGridData(state) {
|
|
1197
|
-
if (this.isReactNative) {
|
|
1198
|
-
return null;
|
|
1199
|
-
}
|
|
1200
|
-
if (state.isNexrad) {
|
|
1201
|
-
return null;
|
|
1202
|
-
}
|
|
1203
|
-
const { model, date, run, forecastHour, variable, isMRMS, mrmsTimestamp } = state;
|
|
1204
|
-
let effectiveSmoothing = 0;
|
|
1205
|
-
const customVariableSettings = this.customColormaps[variable];
|
|
1206
|
-
if (customVariableSettings && typeof customVariableSettings.smoothing === 'number') {
|
|
1207
|
-
effectiveSmoothing = customVariableSettings.smoothing;
|
|
1208
|
-
}
|
|
1209
|
-
let resourcePath;
|
|
1210
|
-
let dataUrlIdentifier;
|
|
1211
|
-
if (isMRMS) {
|
|
1212
|
-
if (!mrmsTimestamp) return null;
|
|
1213
|
-
const mrmsDate = new Date(mrmsTimestamp * 1000);
|
|
1214
|
-
const y = mrmsDate.getUTCFullYear(), m = (mrmsDate.getUTCMonth() + 1).toString().padStart(2, '0'), d = mrmsDate.getUTCDate().toString().padStart(2, '0');
|
|
1215
|
-
dataUrlIdentifier = `mrms-${mrmsTimestamp}-${variable}-${effectiveSmoothing}`;
|
|
1216
|
-
resourcePath = `/grids/mrms/${y}${m}${d}/${mrmsTimestamp}/0/${variable}/${effectiveSmoothing}`;
|
|
1217
|
-
} else {
|
|
1218
|
-
dataUrlIdentifier = `${model}-${date}-${run}-${forecastHour}-${variable}-${effectiveSmoothing}`;
|
|
1219
|
-
resourcePath = `/grids/${model}/${date}/${run}/${forecastHour}/${variable}/${effectiveSmoothing}`;
|
|
1220
|
-
}
|
|
1221
|
-
if (this.dataCache.has(dataUrlIdentifier)) {
|
|
1222
|
-
return this.dataCache.get(dataUrlIdentifier);
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
const abortController = new AbortController();
|
|
1226
|
-
this.abortControllers.set(dataUrlIdentifier, abortController);
|
|
1227
|
-
|
|
1228
|
-
const loadPromise = (async () => {
|
|
1229
|
-
if (!this.apiKey) {
|
|
1230
|
-
throw new Error('API key is not configured.');
|
|
1231
|
-
}
|
|
1232
|
-
try {
|
|
1233
|
-
const baseUrl = `${this.baseGridUrl}${resourcePath}`;
|
|
1234
|
-
const urlWithApiKeyParam = `${baseUrl}?apiKey=${this.apiKey}`;
|
|
1235
|
-
const headers = { 'x-api-key': this.apiKey };
|
|
1236
|
-
if (this.bundleId && this.isReactNative) {
|
|
1237
|
-
headers['x-app-identifier'] = this.bundleId;
|
|
1238
|
-
}
|
|
1239
|
-
|
|
1240
|
-
const response = await fetch(urlWithApiKeyParam, {
|
|
1241
|
-
headers: headers,
|
|
1242
|
-
signal: abortController.signal
|
|
1243
|
-
});
|
|
1244
|
-
|
|
1245
|
-
if (!response.ok) {
|
|
1246
|
-
throw new Error(`Failed to fetch grid data: ${response.status} ${response.statusText}`);
|
|
1247
|
-
}
|
|
1248
|
-
const { data: b64Data, encoding } = await response.json();
|
|
1249
|
-
const compressedData = Uint8Array.from(atob(b64Data), c => c.charCodeAt(0));
|
|
1250
|
-
|
|
1251
|
-
let gridPayload;
|
|
1252
|
-
try {
|
|
1253
|
-
gridPayload = await this._decodeGridPayload(compressedData, encoding);
|
|
1254
|
-
} catch {
|
|
1255
|
-
gridPayload = processCompressedGrid(compressedData, encoding);
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
|
-
this.abortControllers.delete(dataUrlIdentifier);
|
|
1259
|
-
|
|
1260
|
-
return { data: gridPayload.data, encoding: gridPayload.encoding };
|
|
1261
|
-
|
|
1262
|
-
} catch (error) {
|
|
1263
|
-
this.dataCache.delete(dataUrlIdentifier);
|
|
1264
|
-
this.abortControllers.delete(dataUrlIdentifier);
|
|
1265
|
-
return null;
|
|
1266
|
-
}
|
|
1267
|
-
})();
|
|
1268
|
-
this.dataCache.set(dataUrlIdentifier, loadPromise);
|
|
1269
|
-
return loadPromise;
|
|
1270
|
-
}
|
|
1271
|
-
|
|
1272
|
-
cancelAllRequests() {
|
|
1273
|
-
// Abort all in-flight requests
|
|
1274
|
-
this.abortControllers.forEach((controller, key) => {
|
|
1275
|
-
controller.abort();
|
|
1276
|
-
});
|
|
1277
|
-
|
|
1278
|
-
// Clear both maps
|
|
1279
|
-
this.abortControllers.clear();
|
|
1280
|
-
this.dataCache.clear();
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
async getValueAtLngLat(lng, lat) {
|
|
1284
|
-
if (this.state.isSatellite || this.state.isNexrad) {
|
|
1285
|
-
return null;
|
|
1286
|
-
}
|
|
1287
|
-
const { variable, isMRMS, mrmsTimestamp, model, date, run, forecastHour, units } = this.state;
|
|
1288
|
-
if (!variable) return null;
|
|
1289
|
-
|
|
1290
|
-
const gridIndices = this._getGridIndexFromLngLat(lng, lat);
|
|
1291
|
-
if (!gridIndices) return null;
|
|
1292
|
-
|
|
1293
|
-
const { i, j } = gridIndices;
|
|
1294
|
-
const gridModel = isMRMS ? 'mrms' : model;
|
|
1295
|
-
const normalizedGridModel = this._normalizeModelName(gridModel);
|
|
1296
|
-
const { nx } = COORDINATE_CONFIGS[normalizedGridModel].grid_params;
|
|
1297
|
-
|
|
1298
|
-
const customSettings = this.customColormaps[variable];
|
|
1299
|
-
const effectiveSmoothing = (customSettings && typeof customSettings.smoothing === 'number') ? customSettings.smoothing : 0;
|
|
1300
|
-
|
|
1301
|
-
const dataUrlIdentifier = isMRMS
|
|
1302
|
-
? `mrms-${mrmsTimestamp}-${variable}-${effectiveSmoothing}`
|
|
1303
|
-
: `${model}-${date}-${run}-${forecastHour}-${variable}-${effectiveSmoothing}`;
|
|
1304
|
-
|
|
1305
|
-
const gridDataPromise = this.dataCache.get(dataUrlIdentifier);
|
|
1306
|
-
if (!gridDataPromise) return null;
|
|
1307
|
-
|
|
1308
|
-
try {
|
|
1309
|
-
const gridData = await gridDataPromise;
|
|
1310
|
-
if (!gridData || !gridData.data) return null;
|
|
1311
|
-
|
|
1312
|
-
const index1D = j * nx + i;
|
|
1313
|
-
const byteValue = gridData.data[index1D];
|
|
1314
|
-
const signedQuantizedValue = byteValue - 128;
|
|
1315
|
-
|
|
1316
|
-
// --- START OF FIX ---
|
|
1317
|
-
// You were missing 'scale_type' in this destructuring assignment.
|
|
1318
|
-
const { scale, offset, missing_quantized, scale_type } = gridData.encoding;
|
|
1319
|
-
// --- END OF FIX ---
|
|
1320
|
-
|
|
1321
|
-
if (signedQuantizedValue === missing_quantized) return null;
|
|
1322
|
-
|
|
1323
|
-
const intermediateValue = signedQuantizedValue * scale + offset;
|
|
1324
|
-
|
|
1325
|
-
// Step 2: Apply non-linear scaling if specified
|
|
1326
|
-
let nativeValue = intermediateValue;
|
|
1327
|
-
if (scale_type === 'sqrt') {
|
|
1328
|
-
// Square the value while preserving its sign
|
|
1329
|
-
nativeValue = intermediateValue < 0 ? -(intermediateValue * intermediateValue) : (intermediateValue * intermediateValue);
|
|
1330
|
-
}
|
|
1331
|
-
const { colormap, baseUnit } = this._getColormapForVariable(variable);
|
|
1332
|
-
|
|
1333
|
-
// If the value is outside the colormap's bounds, return null.
|
|
1334
|
-
if (colormap && colormap.length >= 2) {
|
|
1335
|
-
const minBound = colormap[0];
|
|
1336
|
-
const maxBound = colormap[colormap.length - 2];
|
|
1337
|
-
if (nativeValue < minBound || nativeValue > maxBound) {
|
|
1338
|
-
return null;
|
|
1339
|
-
}
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
let dataNativeUnit = baseUnit || (DICTIONARIES.fld[variable] || {}).defaultUnit || 'none';
|
|
1343
|
-
|
|
1344
|
-
const displayUnit = this._getTargetUnit(dataNativeUnit, units);
|
|
1345
|
-
const conversionFunc = getUnitConversionFunction(dataNativeUnit, displayUnit);
|
|
1346
|
-
let displayValue = conversionFunc ? conversionFunc(nativeValue) : nativeValue;
|
|
1347
|
-
|
|
1348
|
-
return {
|
|
1349
|
-
lngLat: { lng, lat },
|
|
1350
|
-
variable: {
|
|
1351
|
-
code: variable,
|
|
1352
|
-
name: this.getVariableDisplayName(variable),
|
|
1353
|
-
},
|
|
1354
|
-
value: displayValue,
|
|
1355
|
-
unit: displayUnit,
|
|
1356
|
-
};
|
|
1357
|
-
} catch (error) {
|
|
1358
|
-
return null;
|
|
1359
|
-
}
|
|
1360
|
-
}
|
|
1361
|
-
|
|
1362
|
-
getAvailableVariables(modelName = null) {
|
|
1363
|
-
const model = modelName || this.state.model;
|
|
1364
|
-
return MODEL_CONFIGS[model]?.vars || [];
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
getVariableDisplayName(variableCode) {
|
|
1368
|
-
const varInfo = DICTIONARIES.fld[variableCode];
|
|
1369
|
-
return varInfo?.displayName || varInfo?.name || variableCode;
|
|
1370
|
-
}
|
|
1371
|
-
|
|
1372
|
-
_getColormapForVariable(variable) {
|
|
1373
|
-
if (!variable) return { colormap: [], baseUnit: '' };
|
|
1374
|
-
|
|
1375
|
-
if (this.customColormaps[variable] && this.customColormaps[variable].colormap) {
|
|
1376
|
-
return {
|
|
1377
|
-
colormap: this.customColormaps[variable].colormap,
|
|
1378
|
-
baseUnit: this.customColormaps[variable].baseUnit || ''
|
|
1379
|
-
};
|
|
1380
|
-
}
|
|
1381
|
-
|
|
1382
|
-
const colormapKey = DICTIONARIES.variable_cmap[variable] || variable;
|
|
1383
|
-
const customColormap = this.customColormaps[colormapKey];
|
|
1384
|
-
if (customColormap && customColormap.colormap) {
|
|
1385
|
-
return { colormap: customColormap.colormap, baseUnit: customColormap.baseUnit || '' };
|
|
1386
|
-
}
|
|
1387
|
-
|
|
1388
|
-
const defaultColormapData = DEFAULT_COLORMAPS[colormapKey];
|
|
1389
|
-
if (defaultColormapData && defaultColormapData.units) {
|
|
1390
|
-
// ✅ Get defaultUnit from the field dictionary
|
|
1391
|
-
const fieldInfo = DICTIONARIES.fld[variable] || {};
|
|
1392
|
-
const baseUnit = fieldInfo.defaultUnit || Object.keys(defaultColormapData.units)[0];
|
|
1393
|
-
|
|
1394
|
-
const unitData = defaultColormapData.units[baseUnit];
|
|
1395
|
-
if (unitData && unitData.colormap) {
|
|
1396
|
-
return { colormap: unitData.colormap, baseUnit: baseUnit };
|
|
1397
|
-
}
|
|
1398
|
-
}
|
|
1399
|
-
return { colormap: [], baseUnit: '' };
|
|
1400
|
-
}
|
|
1401
|
-
|
|
1402
|
-
_convertColormapUnits(colormap, fromUnits, toUnits) {
|
|
1403
|
-
if (fromUnits === toUnits) return colormap;
|
|
1404
|
-
const conversionFunc = getUnitConversionFunction(fromUnits, toUnits);
|
|
1405
|
-
if (!conversionFunc) return colormap;
|
|
1406
|
-
const newColormap = [];
|
|
1407
|
-
for (let i = 0; i < colormap.length; i += 2) {
|
|
1408
|
-
newColormap.push(conversionFunc(colormap[i]), colormap[i + 1]);
|
|
1409
|
-
}
|
|
1410
|
-
return newColormap;
|
|
1411
|
-
}
|
|
1412
|
-
|
|
1413
|
-
_normalizeModelName(modelName) {
|
|
1414
|
-
const mapping = { 'hrrr': ['mpashn', 'mpasrt', 'mpasht', 'hrrrsub', 'rrfs', 'namnest', 'mpasrn', 'mpasrn3', 'mpasht2'], 'arw': ['arw2', 'fv3', 'href'], 'rtma': ['nbm'], 'ecmwf': ['ecmwfaifs'], 'gfs': ['arpege', 'graphcastgfs'] };
|
|
1415
|
-
for (const [normalized, aliases] of Object.entries(mapping)) { if (aliases.includes(modelName)) return normalized; }
|
|
1416
|
-
return modelName;
|
|
1417
|
-
}
|
|
1418
|
-
|
|
1419
|
-
_getGridCornersAndDef(model) {
|
|
1420
|
-
const normalizedModel = this._normalizeModelName(model);
|
|
1421
|
-
const gridDef = { ...COORDINATE_CONFIGS[normalizedModel], modelName: model };
|
|
1422
|
-
if (!gridDef) return null;
|
|
1423
|
-
const { nx, ny } = gridDef.grid_params;
|
|
1424
|
-
const gridType = gridDef.type;
|
|
1425
|
-
let corners;
|
|
1426
|
-
if (gridType === 'latlon') {
|
|
1427
|
-
let { lon_first, lat_first, lat_last, lon_last, dx_degrees, dy_degrees } = gridDef.grid_params;
|
|
1428
|
-
corners = {
|
|
1429
|
-
lon_tl: lon_first, lat_tl: lat_first,
|
|
1430
|
-
lon_tr: lon_last !== undefined ? lon_last : (lon_first + (nx - 1) * dx_degrees), lat_tr: lat_first,
|
|
1431
|
-
lon_bl: lon_first, lat_bl: lat_last !== undefined ? lat_last : (lat_first + (ny - 1) * dy_degrees),
|
|
1432
|
-
lon_br: lon_last !== undefined ? lon_last : (lon_first + (nx - 1) * dx_degrees), lat_br: lat_last !== undefined ? lat_last : (lat_first + (ny - 1) * dy_degrees),
|
|
1433
|
-
};
|
|
1434
|
-
} else if (gridType === 'rotated_latlon') {
|
|
1435
|
-
const [lon_tl, lat_tl] = hrdpsObliqueTransform(gridDef.grid_params.lon_first, gridDef.grid_params.lat_first);
|
|
1436
|
-
const [lon_tr, lat_tr] = hrdpsObliqueTransform(gridDef.grid_params.lon_first + (nx - 1) * gridDef.grid_params.dx_degrees, gridDef.grid_params.lat_first);
|
|
1437
|
-
const [lon_bl, lat_bl] = hrdpsObliqueTransform(gridDef.grid_params.lon_first, gridDef.grid_params.lat_first + (ny - 1) * gridDef.grid_params.dy_degrees);
|
|
1438
|
-
const [lon_br, lat_br] = hrdpsObliqueTransform(gridDef.grid_params.lon_first + (nx - 1) * gridDef.grid_params.dx_degrees, gridDef.grid_params.lat_first + (ny - 1) * gridDef.grid_params.dy_degrees);
|
|
1439
|
-
corners = { lon_tl, lat_tl, lon_tr, lat_tr, lon_bl, lat_bl, lon_br, lat_br };
|
|
1440
|
-
} else if (gridType === 'lambert_conformal_conic' || gridType === 'polar_ stereographic') {
|
|
1441
|
-
let projString = Object.entries(gridDef.proj_params).map(([k,v]) => `+${k}=${v}`).join(' ');
|
|
1442
|
-
if(gridType === 'polar_stereographic') projString += ' +lat_0=90';
|
|
1443
|
-
const { x_origin, y_origin, dx, dy } = gridDef.grid_params;
|
|
1444
|
-
const [lon_tl, lat_tl] = proj4(projString, 'EPSG:4326', [x_origin, y_origin]);
|
|
1445
|
-
const [lon_tr, lat_tr] = proj4(projString, 'EPSG:4326', [x_origin + (nx - 1) * dx, y_origin]);
|
|
1446
|
-
const [lon_bl, lat_bl] = proj4(projString, 'EPSG:4326', [x_origin, y_origin + (ny - 1) * dy]);
|
|
1447
|
-
const [lon_br, lat_br] = proj4(projString, 'EPSG:4326', [x_origin + (nx - 1) * dx, y_origin + (ny - 1) * dy]);
|
|
1448
|
-
corners = { lon_tl, lat_tl, lon_tr, lat_tr, lon_bl, lat_bl, lon_br, lat_br };
|
|
1449
|
-
} else {
|
|
1450
|
-
return null;
|
|
1451
|
-
}
|
|
1452
|
-
return { corners, gridDef };
|
|
1453
|
-
}
|
|
1454
|
-
|
|
1455
|
-
_getTargetUnit(defaultUnit, system) {
|
|
1456
|
-
if (system === 'metric') {
|
|
1457
|
-
if (['°F', '°C', 'fahrenheit', 'celsius'].includes(defaultUnit)) return '°C';
|
|
1458
|
-
if (['kts', 'mph', 'm/s'].includes(defaultUnit)) return 'km/h';
|
|
1459
|
-
if (['in', 'mm', 'cm'].includes(defaultUnit)) return 'mm';
|
|
1460
|
-
}
|
|
1461
|
-
if (system === 'imperial') {
|
|
1462
|
-
if (['°F', '°C', 'fahrenheit', 'celsius'].includes(defaultUnit)) return '°F';
|
|
1463
|
-
if (['kts', 'mph', 'm/s'].includes(defaultUnit)) return 'mph';
|
|
1464
|
-
if (['in', 'mm', 'cm'].includes(defaultUnit)) return 'in';
|
|
1465
|
-
}
|
|
1466
|
-
return defaultUnit;
|
|
1467
|
-
}
|
|
1468
|
-
|
|
1469
|
-
_getGridIndexFromLngLat(lng, lat) {
|
|
1470
|
-
const gridModel = this.state.isMRMS ? 'mrms' : this.state.model;
|
|
1471
|
-
const normalizedGridModel = this._normalizeModelName(gridModel);
|
|
1472
|
-
const gridDef = COORDINATE_CONFIGS[normalizedGridModel];
|
|
1473
|
-
if (!gridDef) return null;
|
|
1474
|
-
const { nx, ny } = gridDef.grid_params;
|
|
1475
|
-
const pixelCoords = this.latLonToGridPixel(lat, lng, gridDef, gridModel);
|
|
1476
|
-
if (!pixelCoords || !isFinite(pixelCoords.x) || !isFinite(pixelCoords.y) || pixelCoords.x < 0 || pixelCoords.y < 0) return null;
|
|
1477
|
-
const i = Math.round(pixelCoords.x);
|
|
1478
|
-
const j = Math.round(pixelCoords.y);
|
|
1479
|
-
if (i >= 0 && i < nx && j >= 0 && j < ny) return { i, j };
|
|
1480
|
-
return null;
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
latLonToProjected(lat, lon, gridDef) {
|
|
1484
|
-
if (!isFinite(lat) || !isFinite(lon)) throw new Error(`Invalid coordinates: lat=${lat}, lon=${lon}`);
|
|
1485
|
-
const gridType = gridDef.type;
|
|
1486
|
-
if (gridType === 'latlon') return { x: lon, y: lat };
|
|
1487
|
-
let projString = Object.entries(gridDef.proj_params).map(([k,v]) => `+${k}=${v}`).join(' ');
|
|
1488
|
-
if(gridType === 'polar_stereographic') projString += ' +lat_0=90';
|
|
1489
|
-
const projected = proj4('EPSG:4326', projString, [lon, lat]);
|
|
1490
|
-
return { x: projected[0], y: projected[1] };
|
|
1491
|
-
}
|
|
1492
|
-
|
|
1493
|
-
latLonToGridPixel(lat, lon, gridDef, modelName) {
|
|
1494
|
-
if (!gridDef) return null;
|
|
1495
|
-
if (modelName === 'rgem' && gridDef.type === 'polar_stereographic') return this.latLonToGridPixelPolarStereographic(lat, lon, gridDef);
|
|
1496
|
-
const projected = this.latLonToProjected(lat, lon, gridDef);
|
|
1497
|
-
let x, y;
|
|
1498
|
-
const gridOrigin = { x: gridDef.grid_params.lon_first, y: gridDef.grid_params.lat_first };
|
|
1499
|
-
const gridPixelSize = { x: gridDef.grid_params.dx_degrees, y: gridDef.grid_params.dy_degrees };
|
|
1500
|
-
if (gridDef.type === 'latlon' || gridDef.type === 'rotated_latlon') {
|
|
1501
|
-
let adjustedLon = projected.x;
|
|
1502
|
-
if (modelName === 'mrms') {
|
|
1503
|
-
if (adjustedLon < 0) adjustedLon += 360;
|
|
1504
|
-
x = (adjustedLon - gridOrigin.x) / gridPixelSize.x;
|
|
1505
|
-
y = (gridOrigin.y - projected.y) / gridPixelSize.y;
|
|
1506
|
-
} else {
|
|
1507
|
-
const isGFSType = gridDef.grid_params && gridDef.grid_params.lon_first === 0.0 && Math.abs(gridDef.grid_params.lat_first) === 90.0;
|
|
1508
|
-
const isECMWFType = gridDef.grid_params && gridDef.grid_params.lon_first === 180.0 && gridDef.grid_params.lat_first === 90.0;
|
|
1509
|
-
const isGEMType = modelName === 'gem' || (gridDef.grid_params.lon_first === 180.0 && gridDef.grid_params.lat_first === -90.0 && gridDef.grid_params.lon_last === 179.85);
|
|
1510
|
-
if (isGEMType) {
|
|
1511
|
-
while (adjustedLon < gridOrigin.x) adjustedLon += 360;
|
|
1512
|
-
x = (adjustedLon - gridOrigin.x) / gridPixelSize.x;
|
|
1513
|
-
y = (projected.y - gridOrigin.y) / gridPixelSize.y;
|
|
1514
|
-
return { x, y };
|
|
1515
|
-
}
|
|
1516
|
-
let isFlippedGrid = isECMWFType ? true : (gridDef.grid_params.lat_first < (gridDef.grid_params.lat_last || (gridDef.grid_params.ny - 1) * gridDef.grid_params.dy_degrees));
|
|
1517
|
-
if (isGFSType) adjustedLon = projected.x + 180;
|
|
1518
|
-
else if (isECMWFType) { if (adjustedLon < gridOrigin.x) adjustedLon += 360; }
|
|
1519
|
-
else if (['arome1', 'arome25', 'arpegeeu', 'iconeu', 'icond2'].includes(modelName)) {
|
|
1520
|
-
while (adjustedLon < 0) adjustedLon += 360;
|
|
1521
|
-
while (adjustedLon >= 360) adjustedLon -= 360;
|
|
1522
|
-
x = (adjustedLon >= gridOrigin.x) ? (adjustedLon - gridOrigin.x) / gridPixelSize.x : (adjustedLon + 360 - gridOrigin.x) / gridPixelSize.x;
|
|
1523
|
-
if (['arome1', 'arome25', 'arpegeeu'].includes(modelName)) y = (gridOrigin.y - projected.y) / Math.abs(gridPixelSize.y);
|
|
1524
|
-
else if (['iconeu', 'icond2'].includes(modelName)) y = (projected.y - gridOrigin.y) / gridPixelSize.y;
|
|
1525
|
-
return { x, y };
|
|
1526
|
-
} else {
|
|
1527
|
-
const lonFirst = gridOrigin.x;
|
|
1528
|
-
if (lonFirst > 180 && adjustedLon < 0) adjustedLon += 360;
|
|
1529
|
-
else if (lonFirst < 0 && adjustedLon > 180) adjustedLon -= 360;
|
|
1530
|
-
}
|
|
1531
|
-
x = (adjustedLon - gridOrigin.x) / gridPixelSize.x;
|
|
1532
|
-
if (isFlippedGrid) {
|
|
1533
|
-
if (isECMWFType) y = (gridOrigin.y - projected.y) / Math.abs(gridPixelSize.y);
|
|
1534
|
-
else {
|
|
1535
|
-
const maxLat = gridDef.grid_params.lat_last || (gridDef.grid_params.ny - 1) * gridDef.grid_params.dy_degrees;
|
|
1536
|
-
y = (maxLat - projected.y) / Math.abs(gridPixelSize.y);
|
|
1537
|
-
}
|
|
1538
|
-
} else y = (projected.y - gridOrigin.y) / gridPixelSize.y;
|
|
1539
|
-
}
|
|
1540
|
-
} else {
|
|
1541
|
-
const projOrigin = { x: gridDef.grid_params.x_origin, y: gridDef.grid_params.y_origin };
|
|
1542
|
-
const projPixelSize = { x: gridDef.grid_params.dx, y: gridDef.grid_params.dy };
|
|
1543
|
-
x = (projected.x - projOrigin.x) / projPixelSize.x;
|
|
1544
|
-
y = (projOrigin.y - projected.y) / Math.abs(projPixelSize.y);
|
|
1545
|
-
}
|
|
1546
|
-
return { x, y };
|
|
1547
|
-
}
|
|
1548
|
-
|
|
1549
|
-
latLonToGridPixelPolarStereographic(lat, lon, gridDef) {
|
|
1550
|
-
try {
|
|
1551
|
-
const projParams = gridDef.proj_params;
|
|
1552
|
-
let projectionString = `+proj=${projParams.proj}`;
|
|
1553
|
-
Object.keys(projParams).forEach(key => { if (key !== 'proj') projectionString += ` +${key}=${projParams[key]}`; });
|
|
1554
|
-
projectionString += ' +lat_0=90 +no_defs';
|
|
1555
|
-
const { nx, ny, dx, dy, x_origin, y_origin } = gridDef.grid_params;
|
|
1556
|
-
const x_min = x_origin;
|
|
1557
|
-
const x_max = x_origin + (nx - 1) * dx;
|
|
1558
|
-
const y_max = y_origin;
|
|
1559
|
-
const y_min = y_origin + (ny - 1) * dy;
|
|
1560
|
-
const [proj_x, proj_y] = proj4('EPSG:4326', projectionString, [lon, lat]);
|
|
1561
|
-
if (!isFinite(proj_x) || !isFinite(proj_y)) return { x: -1, y: -1 };
|
|
1562
|
-
const t_x = (proj_x - x_min) / (x_max - x_min);
|
|
1563
|
-
const t_y = (proj_y - y_max) / (y_min - y_max);
|
|
1564
|
-
const x = t_x * (nx - 1);
|
|
1565
|
-
const y = t_y * (ny - 1);
|
|
1566
|
-
return { x, y };
|
|
1567
|
-
} catch (error) {
|
|
1568
|
-
return { x: -1, y: -1 };
|
|
1569
|
-
}
|
|
1570
|
-
}
|
|
1571
|
-
|
|
1572
|
-
// --- Status Methods ---
|
|
1573
|
-
|
|
1574
|
-
async fetchModelStatus(force = false) {
|
|
1575
|
-
if (!this.modelStatus || force) {
|
|
1576
|
-
try {
|
|
1577
|
-
const response = await fetch(this.statusUrl);
|
|
1578
|
-
if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`);
|
|
1579
|
-
this.modelStatus = (await response.json()).models;
|
|
1580
|
-
} catch (error) { this.modelStatus = null; }
|
|
1581
|
-
}
|
|
1582
|
-
return this.modelStatus;
|
|
1583
|
-
}
|
|
1584
|
-
|
|
1585
|
-
async fetchMRMSStatus(force = false) {
|
|
1586
|
-
const mrmsStatusUrl = 'https://h3dfvh5pq6euq36ymlpz4zqiha0obqju.lambda-url.us-east-2.on.aws';
|
|
1587
|
-
if (!this.mrmsStatus || force) {
|
|
1588
|
-
try {
|
|
1589
|
-
const response = await fetch(mrmsStatusUrl);
|
|
1590
|
-
if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`);
|
|
1591
|
-
this.mrmsStatus = await response.json();
|
|
1592
|
-
} catch (error) { this.mrmsStatus = null; }
|
|
1593
|
-
}
|
|
1594
|
-
return this.mrmsStatus;
|
|
1595
|
-
}
|
|
1596
|
-
|
|
1597
|
-
async fetchSatelliteListing(force = false) {
|
|
1598
|
-
if (!this.satelliteListing || force) {
|
|
1599
|
-
try {
|
|
1600
|
-
const response = await fetch(SATELLITE_FRAMES_URL);
|
|
1601
|
-
if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`);
|
|
1602
|
-
this.satelliteListing = await response.json();
|
|
1603
|
-
} catch (error) {
|
|
1604
|
-
this.satelliteListing = null;
|
|
1605
|
-
}
|
|
1606
|
-
}
|
|
1607
|
-
return this.satelliteListing;
|
|
1608
|
-
}
|
|
1609
|
-
|
|
1610
|
-
startAutoRefresh(intervalSeconds) {
|
|
1611
|
-
this.stopAutoRefresh();
|
|
1612
|
-
this.autoRefreshIntervalId = setInterval(async () => {
|
|
1613
|
-
await this.fetchModelStatus(true);
|
|
1614
|
-
this._emitStateChange();
|
|
1615
|
-
}, (intervalSeconds || 60) * 1000);
|
|
1616
|
-
}
|
|
1617
|
-
|
|
1618
|
-
stopAutoRefresh() {
|
|
1619
|
-
if (this.autoRefreshIntervalId) {
|
|
1620
|
-
clearInterval(this.autoRefreshIntervalId);
|
|
1621
|
-
this.autoRefreshIntervalId = null;
|
|
1622
|
-
}
|
|
1623
|
-
}
|
|
1
|
+
// AguaceroCore.js - The Headless "Engine"
|
|
2
|
+
|
|
3
|
+
// --- Non-UI Imports ---
|
|
4
|
+
import { EventEmitter } from './events.js';
|
|
5
|
+
import { COORDINATE_CONFIGS } from './coordinate_configs.js';
|
|
6
|
+
import { getUnitConversionFunction } from './unitConversions.js';
|
|
7
|
+
import { DICTIONARIES, MODEL_CONFIGS } from './dictionaries.js';
|
|
8
|
+
import { DEFAULT_COLORMAPS } from './default-colormaps.js';
|
|
9
|
+
import proj4 from 'proj4';
|
|
10
|
+
import { processCompressedGrid } from './gridDecodePipeline.js';
|
|
11
|
+
import { getBundleId } from './getBundleId';
|
|
12
|
+
import {
|
|
13
|
+
SATELLITE_FRAMES_URL,
|
|
14
|
+
buildSatelliteTimelineForSelection,
|
|
15
|
+
formatTimelineDurationValue,
|
|
16
|
+
resolveSatelliteSectorLabel,
|
|
17
|
+
resolveSatelliteDurationOption,
|
|
18
|
+
parseTimelineDurationHours,
|
|
19
|
+
} from './satellite_support.js';
|
|
20
|
+
import {
|
|
21
|
+
fetchNexradTimesListing,
|
|
22
|
+
inferNexradDataSourceForProduct,
|
|
23
|
+
nexradColormapFldKey,
|
|
24
|
+
variableToNexradGroup,
|
|
25
|
+
getAvailableNexradTilts,
|
|
26
|
+
getRawNexradTiltsForCoalesce,
|
|
27
|
+
} from './nexrad_support.js';
|
|
28
|
+
import { nexradLayerTiltToDisplayOption } from './nexradTiltCoalesce.js';
|
|
29
|
+
import { NEXRAD_LEVEL3_ELEV, getNexradLevel3EntryByRadarKey } from './nexrad_level3_catalog.js';
|
|
30
|
+
import {
|
|
31
|
+
setRadarTiltsManifest,
|
|
32
|
+
fetchRadarTiltsManifestFromNetwork,
|
|
33
|
+
getDefaultRadarTilt,
|
|
34
|
+
formatTiltForApi,
|
|
35
|
+
clampNexradTiltForVariable,
|
|
36
|
+
getRadarTilts,
|
|
37
|
+
} from './nexradTilts.js';
|
|
38
|
+
|
|
39
|
+
// --- Non-UI Helper Functions ---
|
|
40
|
+
function hrdpsObliqueTransform(rotated_lon, rotated_lat) {
|
|
41
|
+
const o_lat_p = 53.91148; const o_lon_p = 245.305142;
|
|
42
|
+
const DEG_TO_RAD = Math.PI / 180.0; const RAD_TO_DEG = 180.0 / Math.PI;
|
|
43
|
+
const o_lat_p_rad = o_lat_p * DEG_TO_RAD;
|
|
44
|
+
const rot_lon_rad = rotated_lon * DEG_TO_RAD;
|
|
45
|
+
const rot_lat_rad = rotated_lat * DEG_TO_RAD;
|
|
46
|
+
const sin_rot_lat = Math.sin(rot_lat_rad); const cos_rot_lat = Math.cos(rot_lat_rad);
|
|
47
|
+
const sin_rot_lon = Math.sin(rot_lon_rad); const cos_rot_lon = Math.cos(rot_lon_rad);
|
|
48
|
+
const sin_o_lat_p = Math.sin(o_lat_p_rad); const cos_o_lat_p = Math.cos(o_lat_p_rad);
|
|
49
|
+
const sin_lat = cos_o_lat_p * sin_rot_lat + sin_o_lat_p * cos_rot_lat * cos_rot_lon;
|
|
50
|
+
let lat = Math.asin(sin_lat) * RAD_TO_DEG;
|
|
51
|
+
const sin_lon_num = cos_rot_lat * sin_rot_lon;
|
|
52
|
+
const sin_lon_den = -sin_o_lat_p * sin_rot_lat + cos_o_lat_p * cos_rot_lat * cos_rot_lon;
|
|
53
|
+
let lon = Math.atan2(sin_lon_num, sin_lon_den) * RAD_TO_DEG + o_lon_p;
|
|
54
|
+
if (lon > 180) lon -= 360; else if (lon < -180) lon += 360;
|
|
55
|
+
return [lon, lat];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function findLatestModelRun(modelsData, modelName) {
|
|
59
|
+
const model = modelsData?.[modelName];
|
|
60
|
+
if (!model) return null;
|
|
61
|
+
const availableDates = Object.keys(model).sort((a, b) => b.localeCompare(a));
|
|
62
|
+
for (const date of availableDates) {
|
|
63
|
+
const runs = model[date];
|
|
64
|
+
if (!runs) continue;
|
|
65
|
+
const availableRuns = Object.keys(runs).sort((a, b) => b.localeCompare(a));
|
|
66
|
+
if (availableRuns.length > 0) return { date: date, run: availableRuns[0] };
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* model-status JSON uses string keys for runs (often zero-padded: "00", "06").
|
|
73
|
+
* Direct lookup modelStatus[model][date][run] fails if state.run is "6" but the key is "06".
|
|
74
|
+
* Returns the hour list and the run key that matched.
|
|
75
|
+
*/
|
|
76
|
+
function resolveModelRunHours(modelStatus, model, date, run) {
|
|
77
|
+
const runs = modelStatus?.[model]?.[date];
|
|
78
|
+
if (!runs || run == null || run === '') {
|
|
79
|
+
return {
|
|
80
|
+
hours: [],
|
|
81
|
+
matchedRunKey: null,
|
|
82
|
+
availableRunKeys: runs ? Object.keys(runs) : [],
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const runStr = String(run);
|
|
86
|
+
const candidates = new Set([runStr]);
|
|
87
|
+
const n = parseInt(runStr, 10);
|
|
88
|
+
if (!Number.isNaN(n)) {
|
|
89
|
+
candidates.add(String(n));
|
|
90
|
+
candidates.add(String(n).padStart(2, '0'));
|
|
91
|
+
candidates.add(String(n).padStart(3, '0'));
|
|
92
|
+
}
|
|
93
|
+
for (const key of candidates) {
|
|
94
|
+
const h = runs[key];
|
|
95
|
+
if (h && Array.isArray(h) && h.length > 0) {
|
|
96
|
+
return { hours: h, matchedRunKey: key, availableRunKeys: Object.keys(runs) };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (!Number.isNaN(n)) {
|
|
100
|
+
for (const k of Object.keys(runs)) {
|
|
101
|
+
const kn = parseInt(k, 10);
|
|
102
|
+
if (!Number.isNaN(kn) && kn === n) {
|
|
103
|
+
const h = runs[k];
|
|
104
|
+
if (h && Array.isArray(h) && h.length > 0) {
|
|
105
|
+
return { hours: h, matchedRunKey: k, availableRunKeys: Object.keys(runs) };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { hours: [], matchedRunKey: null, availableRunKeys: Object.keys(runs) };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class AguaceroCore extends EventEmitter {
|
|
114
|
+
constructor(options = {}) {
|
|
115
|
+
super();
|
|
116
|
+
this.isReactNative = typeof navigator !== 'undefined' && navigator.product === 'ReactNative';
|
|
117
|
+
this.apiKey = options.apiKey;
|
|
118
|
+
/** Passed as CloudFront `userId` for satellite KTX2 URLs (production uses the authenticated account id). */
|
|
119
|
+
this.userId = options.userId ?? 'sdk-user';
|
|
120
|
+
this.bundleId = getBundleId();
|
|
121
|
+
this.baseGridUrl = 'https://d3dc62msmxkrd7.cloudfront.net';
|
|
122
|
+
/** @type {Worker | null} */
|
|
123
|
+
this._gridDecodeWorker = null;
|
|
124
|
+
/** When true, skip Worker and use {@link processCompressedGrid} on the main thread. */
|
|
125
|
+
this._gridDecodeWorkerDisabled = false;
|
|
126
|
+
this._gridDecodeMsgId = 0;
|
|
127
|
+
this.statusUrl = 'https://d3dc62msmxkrd7.cloudfront.net/model-status';
|
|
128
|
+
this.modelStatus = null;
|
|
129
|
+
this.mrmsStatus = null;
|
|
130
|
+
/** @type {{ objects?: Array<{ key: string }> } | null} */
|
|
131
|
+
this.satelliteListing = null;
|
|
132
|
+
this.dataCache = new Map();
|
|
133
|
+
this.abortControllers = new Map();
|
|
134
|
+
this.isPlaying = false;
|
|
135
|
+
this.playIntervalId = null;
|
|
136
|
+
this.playbackSpeed = options.playbackSpeed || 500;
|
|
137
|
+
this.customColormaps = options.customColormaps || {};
|
|
138
|
+
|
|
139
|
+
const userLayerOptions = options.layerOptions || {};
|
|
140
|
+
// EDIT: Determine initial mode from options
|
|
141
|
+
const initialMode = userLayerOptions.mode || 'model';
|
|
142
|
+
const initialVariable = userLayerOptions.variable || null;
|
|
143
|
+
|
|
144
|
+
const initialSatellite = initialMode === 'satellite';
|
|
145
|
+
const initialNexrad = initialMode === 'nexrad';
|
|
146
|
+
const initialNexradProd = userLayerOptions.nexradProduct || 'REF';
|
|
147
|
+
const initialNexradDs = inferNexradDataSourceForProduct(initialNexradProd);
|
|
148
|
+
const initialNexradFld = initialNexrad
|
|
149
|
+
? nexradColormapFldKey(initialNexradDs, initialNexradProd)
|
|
150
|
+
: initialVariable;
|
|
151
|
+
let initialSatelliteInstrumentId = null;
|
|
152
|
+
let initialSatelliteSectorLabel = null;
|
|
153
|
+
let initialSatelliteChannel = null;
|
|
154
|
+
if (initialSatellite) {
|
|
155
|
+
initialSatelliteInstrumentId = userLayerOptions.satelliteId ?? 'GOES19-EAST';
|
|
156
|
+
initialSatelliteSectorLabel = resolveSatelliteSectorLabel(
|
|
157
|
+
userLayerOptions.satelliteSector ?? userLayerOptions.sector ?? 'conus',
|
|
158
|
+
);
|
|
159
|
+
initialSatelliteChannel =
|
|
160
|
+
userLayerOptions.satelliteProduct ??
|
|
161
|
+
userLayerOptions.satelliteChannel ??
|
|
162
|
+
initialVariable ??
|
|
163
|
+
'C13';
|
|
164
|
+
}
|
|
165
|
+
this.state = {
|
|
166
|
+
model: userLayerOptions.model || 'gfs',
|
|
167
|
+
// EDIT: Set isMRMS based on the initial mode
|
|
168
|
+
isMRMS: initialMode === 'mrms' && !initialSatellite && !initialNexrad,
|
|
169
|
+
mrmsTimestamp: null,
|
|
170
|
+
variable: initialNexrad
|
|
171
|
+
? initialNexradFld
|
|
172
|
+
: initialSatellite && initialSatelliteInstrumentId
|
|
173
|
+
? initialSatelliteChannel
|
|
174
|
+
: initialVariable,
|
|
175
|
+
date: null,
|
|
176
|
+
run: null,
|
|
177
|
+
forecastHour: 0,
|
|
178
|
+
visible: true,
|
|
179
|
+
opacity: userLayerOptions.opacity ?? 1,
|
|
180
|
+
units: options.initialUnit || 'imperial',
|
|
181
|
+
shaderSmoothingEnabled: options.shaderSmoothingEnabled ?? true,
|
|
182
|
+
isSatellite: initialSatellite,
|
|
183
|
+
satelliteInstrumentId: initialSatelliteInstrumentId,
|
|
184
|
+
satelliteSectorLabel: initialSatelliteSectorLabel,
|
|
185
|
+
satelliteChannel: initialSatelliteChannel,
|
|
186
|
+
satelliteTimestamp: userLayerOptions.satelliteTimestamp != null ? Number(userLayerOptions.satelliteTimestamp) : null,
|
|
187
|
+
satelliteTier: userLayerOptions.satelliteTier || 'basic',
|
|
188
|
+
satelliteDurationValue: formatTimelineDurationValue(
|
|
189
|
+
userLayerOptions.satelliteDurationValue != null ? userLayerOptions.satelliteDurationValue : '1',
|
|
190
|
+
),
|
|
191
|
+
mrmsDurationValue: formatTimelineDurationValue(
|
|
192
|
+
userLayerOptions.mrmsDurationValue != null ? userLayerOptions.mrmsDurationValue : '1',
|
|
193
|
+
),
|
|
194
|
+
nexradDurationValue: formatTimelineDurationValue(
|
|
195
|
+
userLayerOptions.nexradDurationValue != null ? userLayerOptions.nexradDurationValue : '1',
|
|
196
|
+
),
|
|
197
|
+
isNexrad: initialNexrad,
|
|
198
|
+
nexradSite: userLayerOptions.nexradSite ?? null,
|
|
199
|
+
nexradDataSource: initialNexradDs,
|
|
200
|
+
nexradProduct: initialNexradProd,
|
|
201
|
+
nexradTilt:
|
|
202
|
+
userLayerOptions.nexradTilt != null
|
|
203
|
+
? Number(userLayerOptions.nexradTilt)
|
|
204
|
+
: userLayerOptions.nexradSite
|
|
205
|
+
? getDefaultRadarTilt(userLayerOptions.nexradSite)
|
|
206
|
+
: null,
|
|
207
|
+
nexradTimestamp: userLayerOptions.nexradTimestamp != null ? Number(userLayerOptions.nexradTimestamp) : null,
|
|
208
|
+
nexradStormRelative: userLayerOptions.nexradStormRelative === true,
|
|
209
|
+
/** When true, mapsgl shows clickable NEXRAD site markers (independent of selected site). */
|
|
210
|
+
nexradShowSitesPicker: userLayerOptions.nexradShowSitesPicker !== false,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
this.autoRefreshEnabled = options.autoRefresh ?? false;
|
|
214
|
+
this.autoRefreshIntervalSeconds = options.autoRefreshInterval ?? 60;
|
|
215
|
+
this.autoRefreshIntervalId = null;
|
|
216
|
+
|
|
217
|
+
/** @type {Record<string, { unixTimes?: number[]; timeToKeyMap?: Record<string, string>; listWindowHours?: number }>} */
|
|
218
|
+
this.nexradTimesByStation = {};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async setState(newState) {
|
|
222
|
+
const patch = { ...newState };
|
|
223
|
+
if ('satelliteKey' in patch) delete patch.satelliteKey;
|
|
224
|
+
if ('nexradDataSource' in patch && !('nexradProduct' in patch)) {
|
|
225
|
+
delete patch.nexradDataSource;
|
|
226
|
+
}
|
|
227
|
+
const willBeNexrad =
|
|
228
|
+
patch.isNexrad !== undefined ? Boolean(patch.isNexrad) : this.state.isNexrad;
|
|
229
|
+
if (willBeNexrad && 'nexradProduct' in patch && patch.nexradProduct != null) {
|
|
230
|
+
const p = (patch.nexradProduct || 'REF').toUpperCase();
|
|
231
|
+
const prevP = (this.state.nexradProduct || 'REF').toUpperCase();
|
|
232
|
+
const ds = inferNexradDataSourceForProduct(p);
|
|
233
|
+
patch.nexradProduct = p;
|
|
234
|
+
patch.nexradDataSource = ds;
|
|
235
|
+
patch.variable = nexradColormapFldKey(ds, p);
|
|
236
|
+
if (!('nexradStormRelative' in newState)) {
|
|
237
|
+
// Default: base radial velocity (L3 N0G only) — one fetch per time; fast scrub.
|
|
238
|
+
// SRV (N0G + N0S) is opt-in: setNexradStormRelative(true) or pass nexradStormRelative in setState.
|
|
239
|
+
// Re-selecting the same product (e.g. VEL → VEL) leaves the current SRV flag alone.
|
|
240
|
+
if (p !== 'VEL' || prevP !== 'VEL') {
|
|
241
|
+
patch.nexradStormRelative = false;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if ('forecastHour' in patch && patch.forecastHour != null) {
|
|
246
|
+
patch.forecastHour = Number(patch.forecastHour);
|
|
247
|
+
}
|
|
248
|
+
if ('mrmsTimestamp' in patch && patch.mrmsTimestamp != null) {
|
|
249
|
+
patch.mrmsTimestamp = Number(patch.mrmsTimestamp);
|
|
250
|
+
}
|
|
251
|
+
if ('satelliteTimestamp' in patch && patch.satelliteTimestamp != null) {
|
|
252
|
+
patch.satelliteTimestamp = Number(patch.satelliteTimestamp);
|
|
253
|
+
}
|
|
254
|
+
if ('satelliteDurationValue' in patch && patch.satelliteDurationValue != null) {
|
|
255
|
+
patch.satelliteDurationValue = formatTimelineDurationValue(patch.satelliteDurationValue);
|
|
256
|
+
}
|
|
257
|
+
if ('mrmsDurationValue' in patch && patch.mrmsDurationValue != null) {
|
|
258
|
+
patch.mrmsDurationValue = formatTimelineDurationValue(patch.mrmsDurationValue);
|
|
259
|
+
}
|
|
260
|
+
if ('nexradDurationValue' in patch && patch.nexradDurationValue != null) {
|
|
261
|
+
patch.nexradDurationValue = formatTimelineDurationValue(patch.nexradDurationValue);
|
|
262
|
+
}
|
|
263
|
+
if ('nexradTimestamp' in patch && patch.nexradTimestamp != null) {
|
|
264
|
+
patch.nexradTimestamp = Number(patch.nexradTimestamp);
|
|
265
|
+
}
|
|
266
|
+
if ('nexradTilt' in patch && patch.nexradTilt != null) {
|
|
267
|
+
patch.nexradTilt = Number(patch.nexradTilt);
|
|
268
|
+
}
|
|
269
|
+
Object.assign(this.state, patch);
|
|
270
|
+
this._emitStateChange();
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Forecast hours for the current model/date/run (normalized numbers).
|
|
275
|
+
* Tolerates model-status run keys like "06" vs state.run "6" so preload and slider stay in sync.
|
|
276
|
+
*/
|
|
277
|
+
getAvailableForecastHours() {
|
|
278
|
+
if (this.state.isMRMS || this.state.isSatellite || this.state.isNexrad) return [];
|
|
279
|
+
if (!this.state.model || this.state.date == null || this.state.run == null) return [];
|
|
280
|
+
|
|
281
|
+
const resolved = resolveModelRunHours(
|
|
282
|
+
this.modelStatus,
|
|
283
|
+
this.state.model,
|
|
284
|
+
this.state.date,
|
|
285
|
+
this.state.run
|
|
286
|
+
);
|
|
287
|
+
let hours = resolved.hours || [];
|
|
288
|
+
|
|
289
|
+
if (hours.length > 0) {
|
|
290
|
+
hours = hours.map(h => (typeof h === 'string' ? parseInt(h, 10) : Number(h))).filter(h => !Number.isNaN(h));
|
|
291
|
+
}
|
|
292
|
+
if (this.state.variable === 'ptypeRefl' && this.state.model === 'hrrr' && hours.length > 0) {
|
|
293
|
+
hours = hours.filter(hour => hour !== 0);
|
|
294
|
+
}
|
|
295
|
+
return hours;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
_computeSatelliteTimeline() {
|
|
299
|
+
const { satelliteInstrumentId, satelliteSectorLabel, satelliteChannel } = this.state;
|
|
300
|
+
if (!satelliteInstrumentId || !satelliteSectorLabel || !satelliteChannel || !this.satelliteListing?.objects) {
|
|
301
|
+
return { unixTimes: [], timeToFileMap: {} };
|
|
302
|
+
}
|
|
303
|
+
const allFiles = this.satelliteListing.objects.map((o) => o.key);
|
|
304
|
+
const sectorName = satelliteSectorLabel;
|
|
305
|
+
const tier = this.state.satelliteTier || 'basic';
|
|
306
|
+
const durationOpt = resolveSatelliteDurationOption(sectorName, tier, this.state.satelliteDurationValue);
|
|
307
|
+
return buildSatelliteTimelineForSelection(
|
|
308
|
+
{
|
|
309
|
+
satelliteInstrumentId,
|
|
310
|
+
satelliteSectorLabel,
|
|
311
|
+
satelliteChannel,
|
|
312
|
+
},
|
|
313
|
+
allFiles,
|
|
314
|
+
durationOpt,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* MRMS timestamps for a variable, oldest first (matches satellite timelines and left→right time sliders).
|
|
320
|
+
* Limited to the last N hours relative to the newest frame (see mrmsDurationValue).
|
|
321
|
+
* @param {string} variable
|
|
322
|
+
* @returns {number[]}
|
|
323
|
+
*/
|
|
324
|
+
_getFilteredMrmsTimestampsForVariable(variable) {
|
|
325
|
+
const raw = this.mrmsStatus?.[variable];
|
|
326
|
+
if (!raw || !raw.length) return [];
|
|
327
|
+
const hours = parseTimelineDurationHours(this.state.mrmsDurationValue);
|
|
328
|
+
let list = [...raw]
|
|
329
|
+
.map((t) => Number(t))
|
|
330
|
+
.filter((t) => !Number.isNaN(t))
|
|
331
|
+
.sort((a, b) => a - b);
|
|
332
|
+
if (hours > 0 && list.length > 0) {
|
|
333
|
+
const latest = list[list.length - 1];
|
|
334
|
+
const cutoff = latest - hours * 3600;
|
|
335
|
+
list = list.filter((t) => t >= cutoff);
|
|
336
|
+
}
|
|
337
|
+
return list;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
_nexradListingWindowHours() {
|
|
341
|
+
return parseTimelineDurationHours(this.state.nexradDurationValue);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
_getFilteredNexradTimestampsForVariable(rawList) {
|
|
345
|
+
if (!rawList || !rawList.length) return [];
|
|
346
|
+
const hours = this._nexradListingWindowHours();
|
|
347
|
+
let list = [...rawList]
|
|
348
|
+
.map((t) => Number(t))
|
|
349
|
+
.filter((t) => !Number.isNaN(t))
|
|
350
|
+
.sort((a, b) => a - b);
|
|
351
|
+
if (hours > 0 && list.length > 0) {
|
|
352
|
+
const latest = list[list.length - 1];
|
|
353
|
+
const cutoff = latest - hours * 3600;
|
|
354
|
+
list = list.filter((t) => t >= cutoff);
|
|
355
|
+
}
|
|
356
|
+
return list;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Cache key for {@link this.nexradTimesByStation} — matches frontend composite keys (tilt + variable group/source).
|
|
361
|
+
*/
|
|
362
|
+
_nexradTimesCacheKey() {
|
|
363
|
+
const s = this.state;
|
|
364
|
+
if (!s.isNexrad || !s.nexradSite) return null;
|
|
365
|
+
const site = s.nexradSite;
|
|
366
|
+
const variable = s.nexradProduct || 'REF';
|
|
367
|
+
const ds = s.nexradDataSource || 'level2';
|
|
368
|
+
const tiltNum = s.nexradTilt != null ? s.nexradTilt : getDefaultRadarTilt(site);
|
|
369
|
+
const elevNormUse =
|
|
370
|
+
ds === 'level3'
|
|
371
|
+
? NEXRAD_LEVEL3_ELEV
|
|
372
|
+
: formatTiltForApi(clampNexradTiltForVariable(site, variable, tiltNum));
|
|
373
|
+
const group = ds === 'level3' ? 'l3' : variableToNexradGroup(variable);
|
|
374
|
+
const l3Product =
|
|
375
|
+
ds === 'level3'
|
|
376
|
+
? getNexradLevel3EntryByRadarKey(variable)?.product ?? (variable === 'VEL' ? 'N0G' : variable)
|
|
377
|
+
: '';
|
|
378
|
+
if (ds === 'level3') {
|
|
379
|
+
return `${site}_l3_${l3Product}_${elevNormUse}`;
|
|
380
|
+
}
|
|
381
|
+
return `${site}_${group}_${elevNormUse}`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
_emitStateChange() {
|
|
385
|
+
const { colormap, baseUnit } = this._getColormapForVariable(this.state.variable);
|
|
386
|
+
const toUnit = this._getTargetUnit(baseUnit, this.state.units);
|
|
387
|
+
const displayColormap = this._convertColormapUnits(colormap, baseUnit, toUnit);
|
|
388
|
+
|
|
389
|
+
let availableTimestamps = [];
|
|
390
|
+
if (this.state.isMRMS && this.state.variable && this.mrmsStatus) {
|
|
391
|
+
availableTimestamps = this._getFilteredMrmsTimestampsForVariable(this.state.variable);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
let availableSatelliteTimestamps = [];
|
|
395
|
+
let satelliteTimeToFileMap = {};
|
|
396
|
+
if (this.state.isSatellite && this.state.satelliteInstrumentId) {
|
|
397
|
+
const timeline = this._computeSatelliteTimeline();
|
|
398
|
+
satelliteTimeToFileMap = timeline.timeToFileMap || {};
|
|
399
|
+
availableSatelliteTimestamps = [...(timeline.unixTimes || [])]
|
|
400
|
+
.map((t) => Number(t))
|
|
401
|
+
.filter((t) => !Number.isNaN(t))
|
|
402
|
+
.sort((a, b) => a - b);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
let availableNexradTimestamps = [];
|
|
406
|
+
let nexradTimeToKeyMap = {};
|
|
407
|
+
let nexradLevel3MotionTimeToKeyMap = {};
|
|
408
|
+
let availableNexradTilts = [];
|
|
409
|
+
if (this.state.isNexrad && this.state.nexradSite) {
|
|
410
|
+
const nk = this._nexradTimesCacheKey();
|
|
411
|
+
const ent = nk ? this.nexradTimesByStation[nk] : null;
|
|
412
|
+
const raw = ent?.unixTimes || [];
|
|
413
|
+
availableNexradTimestamps = this._getFilteredNexradTimestampsForVariable(raw);
|
|
414
|
+
nexradTimeToKeyMap = ent?.timeToKeyMap || {};
|
|
415
|
+
nexradLevel3MotionTimeToKeyMap = ent?.level3MotionTimeToKeyMap || {};
|
|
416
|
+
availableNexradTilts = getAvailableNexradTilts(
|
|
417
|
+
this.state.nexradSite,
|
|
418
|
+
this.state.nexradDataSource || 'level2',
|
|
419
|
+
this.state.nexradProduct || 'REF',
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const availableHours = this.getAvailableForecastHours();
|
|
424
|
+
|
|
425
|
+
const eventPayload = {
|
|
426
|
+
...this.state,
|
|
427
|
+
availableModels: this.modelStatus ? Object.keys(this.modelStatus).sort() : [],
|
|
428
|
+
availableRuns: this.modelStatus?.[this.state.model] || {},
|
|
429
|
+
availableHours: availableHours, // <-- Changed from inline calculation
|
|
430
|
+
availableVariables: this.getAvailableVariables(this.state.isMRMS ? 'mrms' : this.state.model),
|
|
431
|
+
// We need to confirm this line is working as expected.
|
|
432
|
+
availableMRMSVariables: this.getAvailableVariables('mrms'),
|
|
433
|
+
availableTimestamps: availableTimestamps,
|
|
434
|
+
availableSatelliteTimestamps,
|
|
435
|
+
satelliteTimeToFileMap,
|
|
436
|
+
availableNexradTimestamps,
|
|
437
|
+
nexradTimeToKeyMap,
|
|
438
|
+
nexradLevel3MotionTimeToKeyMap,
|
|
439
|
+
availableNexradTilts,
|
|
440
|
+
isPlaying: this.isPlaying,
|
|
441
|
+
colormap: displayColormap,
|
|
442
|
+
colormapBaseUnit: toUnit,
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
this.emit('state:change', eventPayload);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async initialize(options = {}) {
|
|
449
|
+
await this.fetchModelStatus(true);
|
|
450
|
+
await this.fetchMRMSStatus(true);
|
|
451
|
+
await this.fetchSatelliteListing(true);
|
|
452
|
+
|
|
453
|
+
let initialState = { ...this.state };
|
|
454
|
+
|
|
455
|
+
if (initialState.isSatellite && initialState.satelliteInstrumentId) {
|
|
456
|
+
const timeline = this._computeSatelliteTimeline();
|
|
457
|
+
const tsList = [...(timeline.unixTimes || [])].sort((a, b) => a - b);
|
|
458
|
+
if (initialState.satelliteTimestamp == null && tsList.length > 0) {
|
|
459
|
+
initialState.satelliteTimestamp = tsList[tsList.length - 1];
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ADD: Logic to handle an initial MRMS state
|
|
464
|
+
if (initialState.isMRMS) {
|
|
465
|
+
const variable = initialState.variable;
|
|
466
|
+
if (variable && this.mrmsStatus && this.mrmsStatus[variable]) {
|
|
467
|
+
const sortedTimestamps = this._getFilteredMrmsTimestampsForVariable(variable);
|
|
468
|
+
initialState.mrmsTimestamp =
|
|
469
|
+
sortedTimestamps.length > 0 ? sortedTimestamps[sortedTimestamps.length - 1] : null;
|
|
470
|
+
} else {
|
|
471
|
+
const availableMRMSVars = this.getAvailableVariables('mrms');
|
|
472
|
+
if (availableMRMSVars.length > 0) {
|
|
473
|
+
const firstVar = availableMRMSVars[0];
|
|
474
|
+
initialState.variable = firstVar;
|
|
475
|
+
const sortedTimestamps = this._getFilteredMrmsTimestampsForVariable(firstVar);
|
|
476
|
+
initialState.mrmsTimestamp =
|
|
477
|
+
sortedTimestamps.length > 0 ? sortedTimestamps[sortedTimestamps.length - 1] : null;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
} else if (!initialState.isSatellite && !initialState.isNexrad) {
|
|
481
|
+
// EDIT: This is the existing logic, now in an else block
|
|
482
|
+
const latestRun = findLatestModelRun(this.modelStatus, initialState.model);
|
|
483
|
+
if (latestRun) {
|
|
484
|
+
initialState = { ...initialState, ...latestRun, forecastHour: 0 };
|
|
485
|
+
if (!initialState.variable) {
|
|
486
|
+
const availableVariables = this.getAvailableVariables(initialState.model);
|
|
487
|
+
if (availableVariables && availableVariables.length > 0) {
|
|
488
|
+
initialState.variable = availableVariables[0];
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
await this.setState(initialState);
|
|
495
|
+
|
|
496
|
+
if (this.state.isNexrad) {
|
|
497
|
+
const manifest = await fetchRadarTiltsManifestFromNetwork();
|
|
498
|
+
if (manifest) setRadarTiltsManifest(manifest);
|
|
499
|
+
if (this.state.nexradSite) {
|
|
500
|
+
await this.refreshNexradTimes();
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (options.autoRefresh ?? this.autoRefreshEnabled) {
|
|
504
|
+
this.startAutoRefresh(options.refreshInterval ?? this.autoRefreshIntervalSeconds);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
destroy() {
|
|
509
|
+
this.pause();
|
|
510
|
+
this.stopAutoRefresh();
|
|
511
|
+
this.dataCache.clear();
|
|
512
|
+
this.callbacks = {};
|
|
513
|
+
if (this._gridDecodeWorker) {
|
|
514
|
+
this._gridDecodeWorker.terminate();
|
|
515
|
+
this._gridDecodeWorker = null;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// --- Public API Methods ---
|
|
520
|
+
|
|
521
|
+
play() {
|
|
522
|
+
if (this.isPlaying) return;
|
|
523
|
+
this.isPlaying = true;
|
|
524
|
+
clearInterval(this.playIntervalId);
|
|
525
|
+
this.playIntervalId = setInterval(() => { this.step(1); }, this.playbackSpeed);
|
|
526
|
+
this.emit('playback:start', { speed: this.playbackSpeed });
|
|
527
|
+
this._emitStateChange(); // Notify UI that isPlaying is now true
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
pause() {
|
|
531
|
+
if (!this.isPlaying) return;
|
|
532
|
+
this.isPlaying = false;
|
|
533
|
+
clearInterval(this.playIntervalId);
|
|
534
|
+
this.playIntervalId = null;
|
|
535
|
+
this.emit('playback:stop');
|
|
536
|
+
this._emitStateChange(); // Notify UI that isPlaying is now false
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
togglePlay() {
|
|
540
|
+
this.isPlaying ? this.pause() : this.play();
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
step(direction = 1) {
|
|
544
|
+
if (this.state.isSatellite) {
|
|
545
|
+
const timeline = this._computeSatelliteTimeline();
|
|
546
|
+
const availableTimestamps = [...(timeline.unixTimes || [])]
|
|
547
|
+
.sort((a, b) => a - b)
|
|
548
|
+
.map((t) => Number(t));
|
|
549
|
+
if (availableTimestamps.length === 0) return;
|
|
550
|
+
|
|
551
|
+
const ts = this.state.satelliteTimestamp == null ? null : Number(this.state.satelliteTimestamp);
|
|
552
|
+
const currentIndex = ts == null ? -1 : availableTimestamps.indexOf(ts);
|
|
553
|
+
|
|
554
|
+
let nextIndex = currentIndex === -1 ? availableTimestamps.length - 1 : currentIndex + direction;
|
|
555
|
+
const maxIndex = availableTimestamps.length - 1;
|
|
556
|
+
if (nextIndex > maxIndex) nextIndex = 0;
|
|
557
|
+
if (nextIndex < 0) nextIndex = maxIndex;
|
|
558
|
+
|
|
559
|
+
this.setState({ satelliteTimestamp: availableTimestamps[nextIndex] });
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
// --- THIS IS THE CORRECTED MRMS LOGIC ---
|
|
563
|
+
if (this.state.isMRMS) {
|
|
564
|
+
const { variable, mrmsTimestamp } = this.state;
|
|
565
|
+
if (!this.mrmsStatus || !this.mrmsStatus[variable]) {
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
const availableTimestamps = this._getFilteredMrmsTimestampsForVariable(variable);
|
|
570
|
+
if (availableTimestamps.length === 0) return;
|
|
571
|
+
|
|
572
|
+
const ts = mrmsTimestamp == null ? null : Number(mrmsTimestamp);
|
|
573
|
+
const currentIndex = ts == null ? -1 : availableTimestamps.indexOf(ts);
|
|
574
|
+
|
|
575
|
+
if (currentIndex === -1) {
|
|
576
|
+
// If not found, reset to the latest frame (end of ascending list)
|
|
577
|
+
this.setState({ mrmsTimestamp: availableTimestamps[availableTimestamps.length - 1] });
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
const maxIndex = availableTimestamps.length - 1;
|
|
582
|
+
let nextIndex = currentIndex + direction;
|
|
583
|
+
|
|
584
|
+
// Loop animation
|
|
585
|
+
if (nextIndex > maxIndex) nextIndex = 0;
|
|
586
|
+
if (nextIndex < 0) nextIndex = maxIndex;
|
|
587
|
+
|
|
588
|
+
const newTimestamp = availableTimestamps[nextIndex];
|
|
589
|
+
this.setState({ mrmsTimestamp: newTimestamp });
|
|
590
|
+
|
|
591
|
+
} else if (this.state.isNexrad) {
|
|
592
|
+
const nk = this._nexradTimesCacheKey();
|
|
593
|
+
const raw = nk ? this.nexradTimesByStation[nk]?.unixTimes : [];
|
|
594
|
+
const availableTimestamps = this._getFilteredNexradTimestampsForVariable(raw || []);
|
|
595
|
+
if (availableTimestamps.length === 0) return;
|
|
596
|
+
|
|
597
|
+
const ts = this.state.nexradTimestamp == null ? null : Number(this.state.nexradTimestamp);
|
|
598
|
+
const currentIndex = ts == null ? -1 : availableTimestamps.indexOf(ts);
|
|
599
|
+
|
|
600
|
+
let nextIndex = currentIndex === -1 ? availableTimestamps.length - 1 : currentIndex + direction;
|
|
601
|
+
const maxIndex = availableTimestamps.length - 1;
|
|
602
|
+
if (nextIndex > maxIndex) nextIndex = 0;
|
|
603
|
+
if (nextIndex < 0) nextIndex = maxIndex;
|
|
604
|
+
|
|
605
|
+
this.setState({ nexradTimestamp: availableTimestamps[nextIndex] });
|
|
606
|
+
} else {
|
|
607
|
+
const { forecastHour } = this.state;
|
|
608
|
+
const forecastHours = this.getAvailableForecastHours();
|
|
609
|
+
if (!forecastHours || forecastHours.length === 0) return;
|
|
610
|
+
|
|
611
|
+
const fh = Number(forecastHour);
|
|
612
|
+
const currentIndex = forecastHours.indexOf(fh);
|
|
613
|
+
if (currentIndex === -1) return;
|
|
614
|
+
|
|
615
|
+
const maxIndex = forecastHours.length - 1;
|
|
616
|
+
let nextIndex = currentIndex + direction;
|
|
617
|
+
if (nextIndex > maxIndex) nextIndex = 0;
|
|
618
|
+
if (nextIndex < 0) nextIndex = maxIndex;
|
|
619
|
+
|
|
620
|
+
const newHour = forecastHours[nextIndex];
|
|
621
|
+
this.setState({ forecastHour: newHour });
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
setPlaybackSpeed(speed) {
|
|
626
|
+
if (speed > 0) {
|
|
627
|
+
this.playbackSpeed = speed;
|
|
628
|
+
if (this.isPlaying) this.play();
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async setShaderSmoothing(enabled) {
|
|
633
|
+
if (typeof enabled !== 'boolean' || enabled === this.state.shaderSmoothingEnabled) return;
|
|
634
|
+
await this.setState({ shaderSmoothingEnabled: enabled });
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
async setOpacity(newOpacity) {
|
|
638
|
+
const clampedOpacity = Math.max(0, Math.min(1, newOpacity));
|
|
639
|
+
if (clampedOpacity === this.state.opacity) return;
|
|
640
|
+
await this.setState({ opacity: clampedOpacity });
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
async setVariable(variable) {
|
|
644
|
+
// --- NEW CODE: Handle switching TO ptypeRefl on HRRR ---
|
|
645
|
+
if (variable === 'ptypeRefl' && this.state.model === 'hrrr' && this.state.forecastHour === 0) {
|
|
646
|
+
const availableHours = resolveModelRunHours(
|
|
647
|
+
this.modelStatus,
|
|
648
|
+
this.state.model,
|
|
649
|
+
this.state.date,
|
|
650
|
+
this.state.run
|
|
651
|
+
).hours || [];
|
|
652
|
+
const firstValidHour = availableHours.find(hour => hour !== 0) || 0;
|
|
653
|
+
await this.setState({ variable, forecastHour: firstValidHour });
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
// --- END NEW CODE ---
|
|
657
|
+
|
|
658
|
+
await this.setState({ variable });
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async setModel(modelName) {
|
|
662
|
+
if (modelName === this.state.model || !this.modelStatus?.[modelName]) return;
|
|
663
|
+
if (this.state.isSatellite) {
|
|
664
|
+
await this.setState({
|
|
665
|
+
isSatellite: false,
|
|
666
|
+
satelliteInstrumentId: null,
|
|
667
|
+
satelliteSectorLabel: null,
|
|
668
|
+
satelliteChannel: null,
|
|
669
|
+
satelliteTimestamp: null,
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
const latestRun = findLatestModelRun(this.modelStatus, modelName);
|
|
673
|
+
if (latestRun) {
|
|
674
|
+
// --- NEW CODE: Determine initial forecast hour ---
|
|
675
|
+
let initialHour = 0;
|
|
676
|
+
|
|
677
|
+
// If switching to HRRR with ptypeRefl, start at hour 1 instead of 0
|
|
678
|
+
if (modelName === 'hrrr' && this.state.variable === 'ptypeRefl') {
|
|
679
|
+
const availableHours = resolveModelRunHours(
|
|
680
|
+
this.modelStatus,
|
|
681
|
+
modelName,
|
|
682
|
+
latestRun.date,
|
|
683
|
+
latestRun.run
|
|
684
|
+
).hours || [];
|
|
685
|
+
initialHour = availableHours.find(hour => hour !== 0) || 0;
|
|
686
|
+
}
|
|
687
|
+
// --- END NEW CODE ---
|
|
688
|
+
|
|
689
|
+
await this.setState({
|
|
690
|
+
model: modelName,
|
|
691
|
+
date: latestRun.date,
|
|
692
|
+
run: latestRun.run,
|
|
693
|
+
forecastHour: initialHour // <-- Changed from hardcoded 0
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async setRun(runString) {
|
|
699
|
+
const [date, run] = runString.split(':');
|
|
700
|
+
if (date !== this.state.date || run !== this.state.run) {
|
|
701
|
+
await this.setState({ date, run, forecastHour: 0 });
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async setUnits(newUnits) {
|
|
706
|
+
if (newUnits === this.state.units || !['metric', 'imperial'].includes(newUnits)) return;
|
|
707
|
+
await this.setState({ units: newUnits });
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
async setMRMSVariable(variable) {
|
|
711
|
+
const sortedTimestamps = this._getFilteredMrmsTimestampsForVariable(variable);
|
|
712
|
+
const initialTimestamp =
|
|
713
|
+
sortedTimestamps.length > 0 ? sortedTimestamps[sortedTimestamps.length - 1] : null;
|
|
714
|
+
|
|
715
|
+
await this.setState({
|
|
716
|
+
variable,
|
|
717
|
+
isMRMS: true,
|
|
718
|
+
isSatellite: false,
|
|
719
|
+
satelliteInstrumentId: null,
|
|
720
|
+
satelliteSectorLabel: null,
|
|
721
|
+
satelliteChannel: null,
|
|
722
|
+
satelliteTimestamp: null,
|
|
723
|
+
mrmsTimestamp: initialTimestamp,
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async setMRMSTimestamp(timestamp) {
|
|
728
|
+
if (!this.state.isMRMS) return;
|
|
729
|
+
await this.setState({ mrmsTimestamp: timestamp });
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async setSatelliteTimestamp(timestamp) {
|
|
733
|
+
if (!this.state.isSatellite) return;
|
|
734
|
+
await this.setState({ satelliteTimestamp: timestamp != null ? Number(timestamp) : null });
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* How many hours of satellite frames to include in the timeline (positive, at most 12 hours).
|
|
739
|
+
* API default: `layerOptions.satelliteDurationValue` on construction.
|
|
740
|
+
*/
|
|
741
|
+
async setSatelliteDurationValue(value) {
|
|
742
|
+
const v = formatTimelineDurationValue(value);
|
|
743
|
+
await this.setState({ satelliteDurationValue: v });
|
|
744
|
+
if (!this.state.isSatellite || !this.state.satelliteInstrumentId) return;
|
|
745
|
+
const timeline = this._computeSatelliteTimeline();
|
|
746
|
+
const tsList = [...(timeline.unixTimes || [])]
|
|
747
|
+
.map((t) => Number(t))
|
|
748
|
+
.filter((t) => !Number.isNaN(t))
|
|
749
|
+
.sort((a, b) => a - b);
|
|
750
|
+
if (tsList.length === 0) return;
|
|
751
|
+
const cur = this.state.satelliteTimestamp;
|
|
752
|
+
const curN = cur == null ? null : Number(cur);
|
|
753
|
+
if (curN == null || !tsList.includes(curN)) {
|
|
754
|
+
await this.setState({ satelliteTimestamp: tsList[tsList.length - 1] });
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Set satellite view using spacecraft id, sector, and channel/product.
|
|
760
|
+
* Omitted fields keep the current selection; when not in satellite mode, missing fields use GOES-19 East CONUS / C13 defaults.
|
|
761
|
+
* @param {{ satelliteId?: string, sector?: string, satelliteSector?: string, satelliteProduct?: string, satelliteChannel?: string, satelliteTimestamp?: number|null }} opts
|
|
762
|
+
*/
|
|
763
|
+
async setSatelliteSelection(opts = {}) {
|
|
764
|
+
const cur = this.state.isSatellite ? this.state : null;
|
|
765
|
+
const tsArg =
|
|
766
|
+
opts.satelliteTimestamp !== undefined
|
|
767
|
+
? opts.satelliteTimestamp
|
|
768
|
+
: this.state.isSatellite && this.state.satelliteTimestamp != null
|
|
769
|
+
? Number(this.state.satelliteTimestamp)
|
|
770
|
+
: undefined;
|
|
771
|
+
return this.switchMode({
|
|
772
|
+
mode: 'satellite',
|
|
773
|
+
satelliteId: opts.satelliteId ?? cur?.satelliteInstrumentId ?? 'GOES19-EAST',
|
|
774
|
+
satelliteSector: opts.satelliteSector ?? opts.sector ?? cur?.satelliteSectorLabel ?? 'conus',
|
|
775
|
+
satelliteProduct:
|
|
776
|
+
opts.satelliteProduct ?? opts.satelliteChannel ?? cur?.satelliteChannel ?? 'C13',
|
|
777
|
+
satelliteTimestamp: tsArg,
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
/**
|
|
782
|
+
* How many hours of MRMS frames to include in the timeline (positive, at most 12 hours).
|
|
783
|
+
* API default: `layerOptions.mrmsDurationValue` on construction.
|
|
784
|
+
*/
|
|
785
|
+
async setMRMSDurationValue(value) {
|
|
786
|
+
const v = formatTimelineDurationValue(value);
|
|
787
|
+
await this.setState({ mrmsDurationValue: v });
|
|
788
|
+
if (!this.state.isMRMS || !this.state.variable) return;
|
|
789
|
+
const filtered = this._getFilteredMrmsTimestampsForVariable(this.state.variable);
|
|
790
|
+
if (filtered.length === 0) return;
|
|
791
|
+
const cur = this.state.mrmsTimestamp;
|
|
792
|
+
const curN = cur == null ? null : Number(cur);
|
|
793
|
+
if (curN == null || !filtered.includes(curN)) {
|
|
794
|
+
await this.setState({ mrmsTimestamp: filtered[filtered.length - 1] });
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* NEXRAD sweep listing / scrub window in hours (independent of MRMS duration).
|
|
800
|
+
* API default: `layerOptions.nexradDurationValue` on construction.
|
|
801
|
+
*/
|
|
802
|
+
async setNexradDurationValue(value) {
|
|
803
|
+
const v = formatTimelineDurationValue(value);
|
|
804
|
+
await this.setState({ nexradDurationValue: v });
|
|
805
|
+
if (!this.state.isNexrad || !this.state.nexradSite) return;
|
|
806
|
+
await this.refreshNexradTimes();
|
|
807
|
+
const nk = this._nexradTimesCacheKey();
|
|
808
|
+
const filtered = this._getFilteredNexradTimestampsForVariable(
|
|
809
|
+
nk ? this.nexradTimesByStation[nk]?.unixTimes || [] : [],
|
|
810
|
+
);
|
|
811
|
+
if (filtered.length === 0) return;
|
|
812
|
+
const cur = this.state.nexradTimestamp;
|
|
813
|
+
const curN = cur == null ? null : Number(cur);
|
|
814
|
+
if (curN == null || !filtered.includes(curN)) {
|
|
815
|
+
await this.setState({ nexradTimestamp: filtered[filtered.length - 1] });
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
async switchMode(options) {
|
|
820
|
+
let { mode, variable, model, forecastHour, mrmsTimestamp, satelliteTimestamp } = options;
|
|
821
|
+
if (!mode) {
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
if (mode === 'model' && !model) {
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
if ((mode === 'mrms' || mode === 'model') && !variable) {
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
let targetState = {};
|
|
831
|
+
if (mode === 'satellite') {
|
|
832
|
+
const satelliteInstrumentId = options.satelliteId ?? 'GOES19-EAST';
|
|
833
|
+
const satelliteSectorLabel = resolveSatelliteSectorLabel(
|
|
834
|
+
options.satelliteSector ?? options.sector ?? 'conus',
|
|
835
|
+
);
|
|
836
|
+
const satelliteChannel =
|
|
837
|
+
options.satelliteProduct ?? options.satelliteChannel ?? variable ?? 'C13';
|
|
838
|
+
const channelToken = satelliteChannel;
|
|
839
|
+
// Emit satellite mode immediately so map layers (e.g. model grid) clear before listing fetch finishes.
|
|
840
|
+
await this.setState({
|
|
841
|
+
isSatellite: true,
|
|
842
|
+
isMRMS: false,
|
|
843
|
+
isNexrad: false,
|
|
844
|
+
satelliteInstrumentId,
|
|
845
|
+
satelliteSectorLabel,
|
|
846
|
+
satelliteChannel,
|
|
847
|
+
variable: channelToken,
|
|
848
|
+
satelliteTimestamp: null,
|
|
849
|
+
mrmsTimestamp: null,
|
|
850
|
+
date: null,
|
|
851
|
+
run: null,
|
|
852
|
+
forecastHour: 0,
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
await this.fetchSatelliteListing(true);
|
|
856
|
+
const allFiles = this.satelliteListing?.objects?.map((o) => o.key) || [];
|
|
857
|
+
const sectorName = satelliteSectorLabel;
|
|
858
|
+
const tier = this.state.satelliteTier || 'basic';
|
|
859
|
+
const durationOpt = resolveSatelliteDurationOption(sectorName, tier, this.state.satelliteDurationValue);
|
|
860
|
+
const timeline = buildSatelliteTimelineForSelection(
|
|
861
|
+
{ satelliteInstrumentId, satelliteSectorLabel, satelliteChannel },
|
|
862
|
+
allFiles,
|
|
863
|
+
durationOpt,
|
|
864
|
+
);
|
|
865
|
+
const tsList = [...(timeline.unixTimes || [])].sort((a, b) => a - b);
|
|
866
|
+
let finalTs = satelliteTimestamp !== undefined ? satelliteTimestamp : null;
|
|
867
|
+
if (finalTs == null && tsList.length > 0) {
|
|
868
|
+
finalTs = tsList[tsList.length - 1];
|
|
869
|
+
}
|
|
870
|
+
await this.setState({
|
|
871
|
+
satelliteTimestamp: finalTs != null ? Number(finalTs) : null,
|
|
872
|
+
});
|
|
873
|
+
return;
|
|
874
|
+
} else if (mode === 'mrms') {
|
|
875
|
+
const sortedTimestamps = this._getFilteredMrmsTimestampsForVariable(variable);
|
|
876
|
+
let finalTimestamp = mrmsTimestamp;
|
|
877
|
+
if (finalTimestamp === undefined) {
|
|
878
|
+
finalTimestamp =
|
|
879
|
+
sortedTimestamps.length > 0 ? sortedTimestamps[sortedTimestamps.length - 1] : null;
|
|
880
|
+
} else if (sortedTimestamps.length > 0) {
|
|
881
|
+
const n = Number(finalTimestamp);
|
|
882
|
+
if (!sortedTimestamps.includes(n)) {
|
|
883
|
+
finalTimestamp = sortedTimestamps[sortedTimestamps.length - 1];
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
targetState = {
|
|
887
|
+
isMRMS: true,
|
|
888
|
+
isSatellite: false,
|
|
889
|
+
isNexrad: false,
|
|
890
|
+
variable: variable,
|
|
891
|
+
mrmsTimestamp: finalTimestamp,
|
|
892
|
+
model: this.state.model, date: null, run: null, forecastHour: 0,
|
|
893
|
+
satelliteInstrumentId: null,
|
|
894
|
+
satelliteSectorLabel: null,
|
|
895
|
+
satelliteChannel: null,
|
|
896
|
+
satelliteTimestamp: null,
|
|
897
|
+
};
|
|
898
|
+
} else if (mode === 'model') {
|
|
899
|
+
const latestRun = findLatestModelRun(this.modelStatus, model);
|
|
900
|
+
if (!latestRun) {
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// --- NEW CODE: Determine initial forecast hour for switchMode ---
|
|
905
|
+
let initialHour = forecastHour !== undefined ? forecastHour : 0;
|
|
906
|
+
|
|
907
|
+
// If switching to HRRR with ptypeRefl and hour is 0, use hour 1
|
|
908
|
+
if (model === 'hrrr' && variable === 'ptypeRefl' && initialHour === 0) {
|
|
909
|
+
const availableHours = resolveModelRunHours(
|
|
910
|
+
this.modelStatus,
|
|
911
|
+
model,
|
|
912
|
+
latestRun.date,
|
|
913
|
+
latestRun.run
|
|
914
|
+
).hours || [];
|
|
915
|
+
initialHour = availableHours.find(hour => hour !== 0) || 0;
|
|
916
|
+
}
|
|
917
|
+
// --- END NEW CODE ---
|
|
918
|
+
|
|
919
|
+
targetState = {
|
|
920
|
+
isMRMS: false,
|
|
921
|
+
isSatellite: false,
|
|
922
|
+
isNexrad: false,
|
|
923
|
+
model: model,
|
|
924
|
+
variable: variable,
|
|
925
|
+
date: latestRun.date,
|
|
926
|
+
run: latestRun.run,
|
|
927
|
+
forecastHour: initialHour, // <-- Changed
|
|
928
|
+
mrmsTimestamp: null,
|
|
929
|
+
satelliteInstrumentId: null,
|
|
930
|
+
satelliteSectorLabel: null,
|
|
931
|
+
satelliteChannel: null,
|
|
932
|
+
satelliteTimestamp: null,
|
|
933
|
+
};
|
|
934
|
+
} else if (mode === 'nexrad') {
|
|
935
|
+
const nexradProduct = options.nexradProduct || 'REF';
|
|
936
|
+
const p = nexradProduct.toUpperCase();
|
|
937
|
+
const site = options.nexradSite ?? null;
|
|
938
|
+
let tilt =
|
|
939
|
+
options.nexradTilt != null
|
|
940
|
+
? Number(options.nexradTilt)
|
|
941
|
+
: site != null
|
|
942
|
+
? getDefaultRadarTilt(site)
|
|
943
|
+
: null;
|
|
944
|
+
await this.setState({
|
|
945
|
+
isNexrad: true,
|
|
946
|
+
isMRMS: false,
|
|
947
|
+
isSatellite: false,
|
|
948
|
+
nexradSite: site,
|
|
949
|
+
nexradProduct: p,
|
|
950
|
+
nexradTilt: tilt,
|
|
951
|
+
nexradTimestamp: options.nexradTimestamp != null ? Number(options.nexradTimestamp) : null,
|
|
952
|
+
nexradStormRelative: options.nexradStormRelative === true,
|
|
953
|
+
nexradShowSitesPicker: options.nexradShowSitesPicker !== false,
|
|
954
|
+
...(options.nexradDurationValue != null
|
|
955
|
+
? { nexradDurationValue: formatTimelineDurationValue(options.nexradDurationValue) }
|
|
956
|
+
: {}),
|
|
957
|
+
mrmsTimestamp: null,
|
|
958
|
+
satelliteInstrumentId: null,
|
|
959
|
+
satelliteSectorLabel: null,
|
|
960
|
+
satelliteChannel: null,
|
|
961
|
+
satelliteTimestamp: null,
|
|
962
|
+
date: null,
|
|
963
|
+
run: null,
|
|
964
|
+
forecastHour: 0,
|
|
965
|
+
});
|
|
966
|
+
const manifest = await fetchRadarTiltsManifestFromNetwork();
|
|
967
|
+
if (manifest) setRadarTiltsManifest(manifest);
|
|
968
|
+
if (site) {
|
|
969
|
+
await this.refreshNexradTimes();
|
|
970
|
+
const filtered = this._getFilteredNexradTimestampsForVariable(
|
|
971
|
+
this.nexradTimesByStation[this._nexradTimesCacheKey()]?.unixTimes || [],
|
|
972
|
+
);
|
|
973
|
+
let ts =
|
|
974
|
+
options.nexradTimestamp != null
|
|
975
|
+
? Number(options.nexradTimestamp)
|
|
976
|
+
: this.state.nexradTimestamp;
|
|
977
|
+
if (ts == null && filtered.length > 0) ts = filtered[filtered.length - 1];
|
|
978
|
+
else if (ts != null && filtered.length > 0 && !filtered.includes(ts)) {
|
|
979
|
+
ts = filtered[filtered.length - 1];
|
|
980
|
+
}
|
|
981
|
+
await this.setState({ nexradTimestamp: ts });
|
|
982
|
+
}
|
|
983
|
+
return;
|
|
984
|
+
} else {
|
|
985
|
+
return;
|
|
986
|
+
}
|
|
987
|
+
await this.setState(targetState);
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Keep `nexradTilt` on a coalesced elevation (same rules as aguacero-frontend tilt controls).
|
|
992
|
+
*/
|
|
993
|
+
async _snapNexradTiltToAvailableOptions() {
|
|
994
|
+
const s = this.state;
|
|
995
|
+
if (!s.isNexrad || !s.nexradSite) return;
|
|
996
|
+
const raw = getRawNexradTiltsForCoalesce(
|
|
997
|
+
s.nexradSite,
|
|
998
|
+
s.nexradDataSource || 'level2',
|
|
999
|
+
s.nexradProduct || 'REF',
|
|
1000
|
+
);
|
|
1001
|
+
if (!raw.length) return;
|
|
1002
|
+
const t = s.nexradTilt;
|
|
1003
|
+
const target = t != null && Number.isFinite(Number(t)) ? Number(t) : getDefaultRadarTilt(s.nexradSite);
|
|
1004
|
+
const canonical = nexradLayerTiltToDisplayOption(target, raw);
|
|
1005
|
+
const match = (a, b) => Math.abs(Number(a) - Number(b)) < 1e-4;
|
|
1006
|
+
if (match(s.nexradTilt, canonical)) return;
|
|
1007
|
+
await this.setState({ nexradTilt: canonical });
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
async refreshNexradTimes() {
|
|
1011
|
+
const s0 = this.state;
|
|
1012
|
+
if (!s0.isNexrad || !s0.nexradSite) return;
|
|
1013
|
+
await this._snapNexradTiltToAvailableOptions();
|
|
1014
|
+
const s = this.state;
|
|
1015
|
+
const listingHours = this._nexradListingWindowHours();
|
|
1016
|
+
const out = await fetchNexradTimesListing({
|
|
1017
|
+
stationId: s.nexradSite,
|
|
1018
|
+
variable: s.nexradProduct || 'REF',
|
|
1019
|
+
elev: s.nexradTilt,
|
|
1020
|
+
source: s.nexradDataSource || 'level2',
|
|
1021
|
+
level3StormRelative: s.nexradStormRelative,
|
|
1022
|
+
level3Product: getNexradLevel3EntryByRadarKey(s.nexradProduct)?.product,
|
|
1023
|
+
listingWindowHours: listingHours,
|
|
1024
|
+
});
|
|
1025
|
+
const nk = this._nexradTimesCacheKey();
|
|
1026
|
+
if (!nk) return;
|
|
1027
|
+
this.nexradTimesByStation[nk] = {
|
|
1028
|
+
unixTimes: out.unixTimes,
|
|
1029
|
+
timeToKeyMap: out.timeToKeyMap,
|
|
1030
|
+
level3MotionTimeToKeyMap: out.level3MotionTimeToKeyMap,
|
|
1031
|
+
listWindowHours: listingHours,
|
|
1032
|
+
};
|
|
1033
|
+
if (out.level3MotionKey && out.level3MotionUnixTimes?.length) {
|
|
1034
|
+
this.nexradTimesByStation[out.level3MotionKey] = {
|
|
1035
|
+
unixTimes: out.level3MotionUnixTimes,
|
|
1036
|
+
timeToKeyMap: out.level3MotionTimeToKeyMap,
|
|
1037
|
+
listWindowHours: listingHours,
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
if (out.l2StormMotionListKey && out.level3MotionUnixTimes?.length) {
|
|
1041
|
+
this.nexradTimesByStation[out.l2StormMotionListKey] = {
|
|
1042
|
+
unixTimes: out.level3MotionUnixTimes,
|
|
1043
|
+
timeToKeyMap: out.level3MotionTimeToKeyMap,
|
|
1044
|
+
listWindowHours: listingHours,
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
const filtered = this._getFilteredNexradTimestampsForVariable(out.unixTimes || []);
|
|
1048
|
+
const map = out.timeToKeyMap || {};
|
|
1049
|
+
if (filtered.length > 0) {
|
|
1050
|
+
const cur = this.state.nexradTimestamp != null ? Number(this.state.nexradTimestamp) : null;
|
|
1051
|
+
const hasKey = cur != null && Object.prototype.hasOwnProperty.call(map, String(cur));
|
|
1052
|
+
const inWindow = cur != null && filtered.includes(cur);
|
|
1053
|
+
if (cur == null || !inWindow || !hasKey) {
|
|
1054
|
+
this.state.nexradTimestamp = filtered[filtered.length - 1];
|
|
1055
|
+
}
|
|
1056
|
+
} else if (this.state.nexradTimestamp != null) {
|
|
1057
|
+
this.state.nexradTimestamp = null;
|
|
1058
|
+
}
|
|
1059
|
+
this._emitStateChange();
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
async setNexradSite(siteId) {
|
|
1063
|
+
if (!this.state.isNexrad) return;
|
|
1064
|
+
const tilt = siteId ? getDefaultRadarTilt(siteId) : null;
|
|
1065
|
+
await this.setState({
|
|
1066
|
+
nexradSite: siteId || null,
|
|
1067
|
+
nexradTilt: tilt,
|
|
1068
|
+
nexradTimestamp: null,
|
|
1069
|
+
});
|
|
1070
|
+
await this.refreshNexradTimes();
|
|
1071
|
+
const filtered = this._getFilteredNexradTimestampsForVariable(
|
|
1072
|
+
this.nexradTimesByStation[this._nexradTimesCacheKey()]?.unixTimes || [],
|
|
1073
|
+
);
|
|
1074
|
+
if (filtered.length > 0) {
|
|
1075
|
+
await this.setState({ nexradTimestamp: filtered[filtered.length - 1] });
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
async setNexradProduct(product) {
|
|
1080
|
+
if (!this.state.isNexrad) return;
|
|
1081
|
+
const p = (product || 'REF').toUpperCase();
|
|
1082
|
+
await this.setState({ nexradProduct: p });
|
|
1083
|
+
await this.refreshNexradTimes();
|
|
1084
|
+
const filtered = this._getFilteredNexradTimestampsForVariable(
|
|
1085
|
+
this.nexradTimesByStation[this._nexradTimesCacheKey()]?.unixTimes || [],
|
|
1086
|
+
);
|
|
1087
|
+
if (filtered.length > 0) {
|
|
1088
|
+
await this.setState({ nexradTimestamp: filtered[filtered.length - 1] });
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
async setNexradTilt(tilt) {
|
|
1093
|
+
if (!this.state.isNexrad || !this.state.nexradSite) return;
|
|
1094
|
+
await this.setState({ nexradTilt: tilt != null ? Number(tilt) : null });
|
|
1095
|
+
await this.refreshNexradTimes();
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Opt-in storm-relative velocity (L3: N0G + N0S). When off (default), only base radial velocity is used (faster).
|
|
1100
|
+
* @param {boolean} enabled
|
|
1101
|
+
*/
|
|
1102
|
+
async setNexradStormRelative(enabled) {
|
|
1103
|
+
if (!this.state.isNexrad) return;
|
|
1104
|
+
await this.setState({ nexradStormRelative: Boolean(enabled) });
|
|
1105
|
+
await this.refreshNexradTimes();
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
async setNexradTimestamp(ts) {
|
|
1109
|
+
if (!this.state.isNexrad) return;
|
|
1110
|
+
await this.setState({ nexradTimestamp: ts != null ? Number(ts) : null });
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
// --- Data and Calculation Methods ---
|
|
1114
|
+
|
|
1115
|
+
_ensureGridDecodeWorker() {
|
|
1116
|
+
if (this._gridDecodeWorkerDisabled) {
|
|
1117
|
+
return null;
|
|
1118
|
+
}
|
|
1119
|
+
if (this._gridDecodeWorker) {
|
|
1120
|
+
return this._gridDecodeWorker;
|
|
1121
|
+
}
|
|
1122
|
+
if (typeof Worker === 'undefined') {
|
|
1123
|
+
this._gridDecodeWorkerDisabled = true;
|
|
1124
|
+
return null;
|
|
1125
|
+
}
|
|
1126
|
+
try {
|
|
1127
|
+
this._gridDecodeWorker = new Worker(new URL('./gridDecodeWorker.js', import.meta.url), {
|
|
1128
|
+
type: 'module',
|
|
1129
|
+
});
|
|
1130
|
+
this._gridDecodeWorker.addEventListener('error', () => {
|
|
1131
|
+
this._gridDecodeWorkerDisabled = true;
|
|
1132
|
+
if (this._gridDecodeWorker) {
|
|
1133
|
+
this._gridDecodeWorker.terminate();
|
|
1134
|
+
this._gridDecodeWorker = null;
|
|
1135
|
+
}
|
|
1136
|
+
});
|
|
1137
|
+
} catch {
|
|
1138
|
+
this._gridDecodeWorkerDisabled = true;
|
|
1139
|
+
return null;
|
|
1140
|
+
}
|
|
1141
|
+
return this._gridDecodeWorker;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* Offloads zstd + delta decode + transform to a Worker when available; falls back to main-thread
|
|
1146
|
+
* {@link processCompressedGrid}. Uses a copy for postMessage transfer so the original `compressedData`
|
|
1147
|
+
* stays valid if the Worker path fails.
|
|
1148
|
+
*/
|
|
1149
|
+
_decodeGridPayload(compressedData, encoding) {
|
|
1150
|
+
const worker = this._ensureGridDecodeWorker();
|
|
1151
|
+
if (!worker) {
|
|
1152
|
+
return Promise.resolve(processCompressedGrid(compressedData, encoding));
|
|
1153
|
+
}
|
|
1154
|
+
const payload = compressedData.slice();
|
|
1155
|
+
return new Promise((resolve, reject) => {
|
|
1156
|
+
const id = ++this._gridDecodeMsgId;
|
|
1157
|
+
const onMsg = (e) => {
|
|
1158
|
+
if (!e.data || e.data.id !== id) {
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
worker.removeEventListener('message', onMsg);
|
|
1162
|
+
worker.removeEventListener('error', onErr);
|
|
1163
|
+
if (e.data.error) {
|
|
1164
|
+
reject(new Error(e.data.error));
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
const data = new Uint8Array(e.data.dataBuffer, e.data.dataByteOffset, e.data.dataByteLength);
|
|
1168
|
+
resolve({ data, encoding: e.data.encoding });
|
|
1169
|
+
};
|
|
1170
|
+
const onErr = (err) => {
|
|
1171
|
+
worker.removeEventListener('message', onMsg);
|
|
1172
|
+
worker.removeEventListener('error', onErr);
|
|
1173
|
+
reject(err);
|
|
1174
|
+
};
|
|
1175
|
+
worker.addEventListener('message', onMsg);
|
|
1176
|
+
worker.addEventListener('error', onErr);
|
|
1177
|
+
try {
|
|
1178
|
+
worker.postMessage(
|
|
1179
|
+
{
|
|
1180
|
+
id,
|
|
1181
|
+
encoding,
|
|
1182
|
+
compressedBuffer: payload.buffer,
|
|
1183
|
+
compressedByteOffset: payload.byteOffset,
|
|
1184
|
+
compressedByteLength: payload.byteLength,
|
|
1185
|
+
},
|
|
1186
|
+
[payload.buffer]
|
|
1187
|
+
);
|
|
1188
|
+
} catch (err) {
|
|
1189
|
+
worker.removeEventListener('message', onMsg);
|
|
1190
|
+
worker.removeEventListener('error', onErr);
|
|
1191
|
+
reject(err);
|
|
1192
|
+
}
|
|
1193
|
+
});
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
async _loadGridData(state) {
|
|
1197
|
+
if (this.isReactNative) {
|
|
1198
|
+
return null;
|
|
1199
|
+
}
|
|
1200
|
+
if (state.isNexrad) {
|
|
1201
|
+
return null;
|
|
1202
|
+
}
|
|
1203
|
+
const { model, date, run, forecastHour, variable, isMRMS, mrmsTimestamp } = state;
|
|
1204
|
+
let effectiveSmoothing = 0;
|
|
1205
|
+
const customVariableSettings = this.customColormaps[variable];
|
|
1206
|
+
if (customVariableSettings && typeof customVariableSettings.smoothing === 'number') {
|
|
1207
|
+
effectiveSmoothing = customVariableSettings.smoothing;
|
|
1208
|
+
}
|
|
1209
|
+
let resourcePath;
|
|
1210
|
+
let dataUrlIdentifier;
|
|
1211
|
+
if (isMRMS) {
|
|
1212
|
+
if (!mrmsTimestamp) return null;
|
|
1213
|
+
const mrmsDate = new Date(mrmsTimestamp * 1000);
|
|
1214
|
+
const y = mrmsDate.getUTCFullYear(), m = (mrmsDate.getUTCMonth() + 1).toString().padStart(2, '0'), d = mrmsDate.getUTCDate().toString().padStart(2, '0');
|
|
1215
|
+
dataUrlIdentifier = `mrms-${mrmsTimestamp}-${variable}-${effectiveSmoothing}`;
|
|
1216
|
+
resourcePath = `/grids/mrms/${y}${m}${d}/${mrmsTimestamp}/0/${variable}/${effectiveSmoothing}`;
|
|
1217
|
+
} else {
|
|
1218
|
+
dataUrlIdentifier = `${model}-${date}-${run}-${forecastHour}-${variable}-${effectiveSmoothing}`;
|
|
1219
|
+
resourcePath = `/grids/${model}/${date}/${run}/${forecastHour}/${variable}/${effectiveSmoothing}`;
|
|
1220
|
+
}
|
|
1221
|
+
if (this.dataCache.has(dataUrlIdentifier)) {
|
|
1222
|
+
return this.dataCache.get(dataUrlIdentifier);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
const abortController = new AbortController();
|
|
1226
|
+
this.abortControllers.set(dataUrlIdentifier, abortController);
|
|
1227
|
+
|
|
1228
|
+
const loadPromise = (async () => {
|
|
1229
|
+
if (!this.apiKey) {
|
|
1230
|
+
throw new Error('API key is not configured.');
|
|
1231
|
+
}
|
|
1232
|
+
try {
|
|
1233
|
+
const baseUrl = `${this.baseGridUrl}${resourcePath}`;
|
|
1234
|
+
const urlWithApiKeyParam = `${baseUrl}?apiKey=${this.apiKey}`;
|
|
1235
|
+
const headers = { 'x-api-key': this.apiKey };
|
|
1236
|
+
if (this.bundleId && this.isReactNative) {
|
|
1237
|
+
headers['x-app-identifier'] = this.bundleId;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
const response = await fetch(urlWithApiKeyParam, {
|
|
1241
|
+
headers: headers,
|
|
1242
|
+
signal: abortController.signal
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
if (!response.ok) {
|
|
1246
|
+
throw new Error(`Failed to fetch grid data: ${response.status} ${response.statusText}`);
|
|
1247
|
+
}
|
|
1248
|
+
const { data: b64Data, encoding } = await response.json();
|
|
1249
|
+
const compressedData = Uint8Array.from(atob(b64Data), c => c.charCodeAt(0));
|
|
1250
|
+
|
|
1251
|
+
let gridPayload;
|
|
1252
|
+
try {
|
|
1253
|
+
gridPayload = await this._decodeGridPayload(compressedData, encoding);
|
|
1254
|
+
} catch {
|
|
1255
|
+
gridPayload = processCompressedGrid(compressedData, encoding);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
this.abortControllers.delete(dataUrlIdentifier);
|
|
1259
|
+
|
|
1260
|
+
return { data: gridPayload.data, encoding: gridPayload.encoding };
|
|
1261
|
+
|
|
1262
|
+
} catch (error) {
|
|
1263
|
+
this.dataCache.delete(dataUrlIdentifier);
|
|
1264
|
+
this.abortControllers.delete(dataUrlIdentifier);
|
|
1265
|
+
return null;
|
|
1266
|
+
}
|
|
1267
|
+
})();
|
|
1268
|
+
this.dataCache.set(dataUrlIdentifier, loadPromise);
|
|
1269
|
+
return loadPromise;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
cancelAllRequests() {
|
|
1273
|
+
// Abort all in-flight requests
|
|
1274
|
+
this.abortControllers.forEach((controller, key) => {
|
|
1275
|
+
controller.abort();
|
|
1276
|
+
});
|
|
1277
|
+
|
|
1278
|
+
// Clear both maps
|
|
1279
|
+
this.abortControllers.clear();
|
|
1280
|
+
this.dataCache.clear();
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
async getValueAtLngLat(lng, lat) {
|
|
1284
|
+
if (this.state.isSatellite || this.state.isNexrad) {
|
|
1285
|
+
return null;
|
|
1286
|
+
}
|
|
1287
|
+
const { variable, isMRMS, mrmsTimestamp, model, date, run, forecastHour, units } = this.state;
|
|
1288
|
+
if (!variable) return null;
|
|
1289
|
+
|
|
1290
|
+
const gridIndices = this._getGridIndexFromLngLat(lng, lat);
|
|
1291
|
+
if (!gridIndices) return null;
|
|
1292
|
+
|
|
1293
|
+
const { i, j } = gridIndices;
|
|
1294
|
+
const gridModel = isMRMS ? 'mrms' : model;
|
|
1295
|
+
const normalizedGridModel = this._normalizeModelName(gridModel);
|
|
1296
|
+
const { nx } = COORDINATE_CONFIGS[normalizedGridModel].grid_params;
|
|
1297
|
+
|
|
1298
|
+
const customSettings = this.customColormaps[variable];
|
|
1299
|
+
const effectiveSmoothing = (customSettings && typeof customSettings.smoothing === 'number') ? customSettings.smoothing : 0;
|
|
1300
|
+
|
|
1301
|
+
const dataUrlIdentifier = isMRMS
|
|
1302
|
+
? `mrms-${mrmsTimestamp}-${variable}-${effectiveSmoothing}`
|
|
1303
|
+
: `${model}-${date}-${run}-${forecastHour}-${variable}-${effectiveSmoothing}`;
|
|
1304
|
+
|
|
1305
|
+
const gridDataPromise = this.dataCache.get(dataUrlIdentifier);
|
|
1306
|
+
if (!gridDataPromise) return null;
|
|
1307
|
+
|
|
1308
|
+
try {
|
|
1309
|
+
const gridData = await gridDataPromise;
|
|
1310
|
+
if (!gridData || !gridData.data) return null;
|
|
1311
|
+
|
|
1312
|
+
const index1D = j * nx + i;
|
|
1313
|
+
const byteValue = gridData.data[index1D];
|
|
1314
|
+
const signedQuantizedValue = byteValue - 128;
|
|
1315
|
+
|
|
1316
|
+
// --- START OF FIX ---
|
|
1317
|
+
// You were missing 'scale_type' in this destructuring assignment.
|
|
1318
|
+
const { scale, offset, missing_quantized, scale_type } = gridData.encoding;
|
|
1319
|
+
// --- END OF FIX ---
|
|
1320
|
+
|
|
1321
|
+
if (signedQuantizedValue === missing_quantized) return null;
|
|
1322
|
+
|
|
1323
|
+
const intermediateValue = signedQuantizedValue * scale + offset;
|
|
1324
|
+
|
|
1325
|
+
// Step 2: Apply non-linear scaling if specified
|
|
1326
|
+
let nativeValue = intermediateValue;
|
|
1327
|
+
if (scale_type === 'sqrt') {
|
|
1328
|
+
// Square the value while preserving its sign
|
|
1329
|
+
nativeValue = intermediateValue < 0 ? -(intermediateValue * intermediateValue) : (intermediateValue * intermediateValue);
|
|
1330
|
+
}
|
|
1331
|
+
const { colormap, baseUnit } = this._getColormapForVariable(variable);
|
|
1332
|
+
|
|
1333
|
+
// If the value is outside the colormap's bounds, return null.
|
|
1334
|
+
if (colormap && colormap.length >= 2) {
|
|
1335
|
+
const minBound = colormap[0];
|
|
1336
|
+
const maxBound = colormap[colormap.length - 2];
|
|
1337
|
+
if (nativeValue < minBound || nativeValue > maxBound) {
|
|
1338
|
+
return null;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
let dataNativeUnit = baseUnit || (DICTIONARIES.fld[variable] || {}).defaultUnit || 'none';
|
|
1343
|
+
|
|
1344
|
+
const displayUnit = this._getTargetUnit(dataNativeUnit, units);
|
|
1345
|
+
const conversionFunc = getUnitConversionFunction(dataNativeUnit, displayUnit);
|
|
1346
|
+
let displayValue = conversionFunc ? conversionFunc(nativeValue) : nativeValue;
|
|
1347
|
+
|
|
1348
|
+
return {
|
|
1349
|
+
lngLat: { lng, lat },
|
|
1350
|
+
variable: {
|
|
1351
|
+
code: variable,
|
|
1352
|
+
name: this.getVariableDisplayName(variable),
|
|
1353
|
+
},
|
|
1354
|
+
value: displayValue,
|
|
1355
|
+
unit: displayUnit,
|
|
1356
|
+
};
|
|
1357
|
+
} catch (error) {
|
|
1358
|
+
return null;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
getAvailableVariables(modelName = null) {
|
|
1363
|
+
const model = modelName || this.state.model;
|
|
1364
|
+
return MODEL_CONFIGS[model]?.vars || [];
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
getVariableDisplayName(variableCode) {
|
|
1368
|
+
const varInfo = DICTIONARIES.fld[variableCode];
|
|
1369
|
+
return varInfo?.displayName || varInfo?.name || variableCode;
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
_getColormapForVariable(variable) {
|
|
1373
|
+
if (!variable) return { colormap: [], baseUnit: '' };
|
|
1374
|
+
|
|
1375
|
+
if (this.customColormaps[variable] && this.customColormaps[variable].colormap) {
|
|
1376
|
+
return {
|
|
1377
|
+
colormap: this.customColormaps[variable].colormap,
|
|
1378
|
+
baseUnit: this.customColormaps[variable].baseUnit || ''
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
const colormapKey = DICTIONARIES.variable_cmap[variable] || variable;
|
|
1383
|
+
const customColormap = this.customColormaps[colormapKey];
|
|
1384
|
+
if (customColormap && customColormap.colormap) {
|
|
1385
|
+
return { colormap: customColormap.colormap, baseUnit: customColormap.baseUnit || '' };
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
const defaultColormapData = DEFAULT_COLORMAPS[colormapKey];
|
|
1389
|
+
if (defaultColormapData && defaultColormapData.units) {
|
|
1390
|
+
// ✅ Get defaultUnit from the field dictionary
|
|
1391
|
+
const fieldInfo = DICTIONARIES.fld[variable] || {};
|
|
1392
|
+
const baseUnit = fieldInfo.defaultUnit || Object.keys(defaultColormapData.units)[0];
|
|
1393
|
+
|
|
1394
|
+
const unitData = defaultColormapData.units[baseUnit];
|
|
1395
|
+
if (unitData && unitData.colormap) {
|
|
1396
|
+
return { colormap: unitData.colormap, baseUnit: baseUnit };
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
return { colormap: [], baseUnit: '' };
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
_convertColormapUnits(colormap, fromUnits, toUnits) {
|
|
1403
|
+
if (fromUnits === toUnits) return colormap;
|
|
1404
|
+
const conversionFunc = getUnitConversionFunction(fromUnits, toUnits);
|
|
1405
|
+
if (!conversionFunc) return colormap;
|
|
1406
|
+
const newColormap = [];
|
|
1407
|
+
for (let i = 0; i < colormap.length; i += 2) {
|
|
1408
|
+
newColormap.push(conversionFunc(colormap[i]), colormap[i + 1]);
|
|
1409
|
+
}
|
|
1410
|
+
return newColormap;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
_normalizeModelName(modelName) {
|
|
1414
|
+
const mapping = { 'hrrr': ['mpashn', 'mpasrt', 'mpasht', 'hrrrsub', 'rrfs', 'namnest', 'mpasrn', 'mpasrn3', 'mpasht2'], 'arw': ['arw2', 'fv3', 'href'], 'rtma': ['nbm'], 'ecmwf': ['ecmwfaifs'], 'gfs': ['arpege', 'graphcastgfs'] };
|
|
1415
|
+
for (const [normalized, aliases] of Object.entries(mapping)) { if (aliases.includes(modelName)) return normalized; }
|
|
1416
|
+
return modelName;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
_getGridCornersAndDef(model) {
|
|
1420
|
+
const normalizedModel = this._normalizeModelName(model);
|
|
1421
|
+
const gridDef = { ...COORDINATE_CONFIGS[normalizedModel], modelName: model };
|
|
1422
|
+
if (!gridDef) return null;
|
|
1423
|
+
const { nx, ny } = gridDef.grid_params;
|
|
1424
|
+
const gridType = gridDef.type;
|
|
1425
|
+
let corners;
|
|
1426
|
+
if (gridType === 'latlon') {
|
|
1427
|
+
let { lon_first, lat_first, lat_last, lon_last, dx_degrees, dy_degrees } = gridDef.grid_params;
|
|
1428
|
+
corners = {
|
|
1429
|
+
lon_tl: lon_first, lat_tl: lat_first,
|
|
1430
|
+
lon_tr: lon_last !== undefined ? lon_last : (lon_first + (nx - 1) * dx_degrees), lat_tr: lat_first,
|
|
1431
|
+
lon_bl: lon_first, lat_bl: lat_last !== undefined ? lat_last : (lat_first + (ny - 1) * dy_degrees),
|
|
1432
|
+
lon_br: lon_last !== undefined ? lon_last : (lon_first + (nx - 1) * dx_degrees), lat_br: lat_last !== undefined ? lat_last : (lat_first + (ny - 1) * dy_degrees),
|
|
1433
|
+
};
|
|
1434
|
+
} else if (gridType === 'rotated_latlon') {
|
|
1435
|
+
const [lon_tl, lat_tl] = hrdpsObliqueTransform(gridDef.grid_params.lon_first, gridDef.grid_params.lat_first);
|
|
1436
|
+
const [lon_tr, lat_tr] = hrdpsObliqueTransform(gridDef.grid_params.lon_first + (nx - 1) * gridDef.grid_params.dx_degrees, gridDef.grid_params.lat_first);
|
|
1437
|
+
const [lon_bl, lat_bl] = hrdpsObliqueTransform(gridDef.grid_params.lon_first, gridDef.grid_params.lat_first + (ny - 1) * gridDef.grid_params.dy_degrees);
|
|
1438
|
+
const [lon_br, lat_br] = hrdpsObliqueTransform(gridDef.grid_params.lon_first + (nx - 1) * gridDef.grid_params.dx_degrees, gridDef.grid_params.lat_first + (ny - 1) * gridDef.grid_params.dy_degrees);
|
|
1439
|
+
corners = { lon_tl, lat_tl, lon_tr, lat_tr, lon_bl, lat_bl, lon_br, lat_br };
|
|
1440
|
+
} else if (gridType === 'lambert_conformal_conic' || gridType === 'polar_ stereographic') {
|
|
1441
|
+
let projString = Object.entries(gridDef.proj_params).map(([k,v]) => `+${k}=${v}`).join(' ');
|
|
1442
|
+
if(gridType === 'polar_stereographic') projString += ' +lat_0=90';
|
|
1443
|
+
const { x_origin, y_origin, dx, dy } = gridDef.grid_params;
|
|
1444
|
+
const [lon_tl, lat_tl] = proj4(projString, 'EPSG:4326', [x_origin, y_origin]);
|
|
1445
|
+
const [lon_tr, lat_tr] = proj4(projString, 'EPSG:4326', [x_origin + (nx - 1) * dx, y_origin]);
|
|
1446
|
+
const [lon_bl, lat_bl] = proj4(projString, 'EPSG:4326', [x_origin, y_origin + (ny - 1) * dy]);
|
|
1447
|
+
const [lon_br, lat_br] = proj4(projString, 'EPSG:4326', [x_origin + (nx - 1) * dx, y_origin + (ny - 1) * dy]);
|
|
1448
|
+
corners = { lon_tl, lat_tl, lon_tr, lat_tr, lon_bl, lat_bl, lon_br, lat_br };
|
|
1449
|
+
} else {
|
|
1450
|
+
return null;
|
|
1451
|
+
}
|
|
1452
|
+
return { corners, gridDef };
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
_getTargetUnit(defaultUnit, system) {
|
|
1456
|
+
if (system === 'metric') {
|
|
1457
|
+
if (['°F', '°C', 'fahrenheit', 'celsius'].includes(defaultUnit)) return '°C';
|
|
1458
|
+
if (['kts', 'mph', 'm/s'].includes(defaultUnit)) return 'km/h';
|
|
1459
|
+
if (['in', 'mm', 'cm'].includes(defaultUnit)) return 'mm';
|
|
1460
|
+
}
|
|
1461
|
+
if (system === 'imperial') {
|
|
1462
|
+
if (['°F', '°C', 'fahrenheit', 'celsius'].includes(defaultUnit)) return '°F';
|
|
1463
|
+
if (['kts', 'mph', 'm/s'].includes(defaultUnit)) return 'mph';
|
|
1464
|
+
if (['in', 'mm', 'cm'].includes(defaultUnit)) return 'in';
|
|
1465
|
+
}
|
|
1466
|
+
return defaultUnit;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
_getGridIndexFromLngLat(lng, lat) {
|
|
1470
|
+
const gridModel = this.state.isMRMS ? 'mrms' : this.state.model;
|
|
1471
|
+
const normalizedGridModel = this._normalizeModelName(gridModel);
|
|
1472
|
+
const gridDef = COORDINATE_CONFIGS[normalizedGridModel];
|
|
1473
|
+
if (!gridDef) return null;
|
|
1474
|
+
const { nx, ny } = gridDef.grid_params;
|
|
1475
|
+
const pixelCoords = this.latLonToGridPixel(lat, lng, gridDef, gridModel);
|
|
1476
|
+
if (!pixelCoords || !isFinite(pixelCoords.x) || !isFinite(pixelCoords.y) || pixelCoords.x < 0 || pixelCoords.y < 0) return null;
|
|
1477
|
+
const i = Math.round(pixelCoords.x);
|
|
1478
|
+
const j = Math.round(pixelCoords.y);
|
|
1479
|
+
if (i >= 0 && i < nx && j >= 0 && j < ny) return { i, j };
|
|
1480
|
+
return null;
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
latLonToProjected(lat, lon, gridDef) {
|
|
1484
|
+
if (!isFinite(lat) || !isFinite(lon)) throw new Error(`Invalid coordinates: lat=${lat}, lon=${lon}`);
|
|
1485
|
+
const gridType = gridDef.type;
|
|
1486
|
+
if (gridType === 'latlon') return { x: lon, y: lat };
|
|
1487
|
+
let projString = Object.entries(gridDef.proj_params).map(([k,v]) => `+${k}=${v}`).join(' ');
|
|
1488
|
+
if(gridType === 'polar_stereographic') projString += ' +lat_0=90';
|
|
1489
|
+
const projected = proj4('EPSG:4326', projString, [lon, lat]);
|
|
1490
|
+
return { x: projected[0], y: projected[1] };
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
latLonToGridPixel(lat, lon, gridDef, modelName) {
|
|
1494
|
+
if (!gridDef) return null;
|
|
1495
|
+
if (modelName === 'rgem' && gridDef.type === 'polar_stereographic') return this.latLonToGridPixelPolarStereographic(lat, lon, gridDef);
|
|
1496
|
+
const projected = this.latLonToProjected(lat, lon, gridDef);
|
|
1497
|
+
let x, y;
|
|
1498
|
+
const gridOrigin = { x: gridDef.grid_params.lon_first, y: gridDef.grid_params.lat_first };
|
|
1499
|
+
const gridPixelSize = { x: gridDef.grid_params.dx_degrees, y: gridDef.grid_params.dy_degrees };
|
|
1500
|
+
if (gridDef.type === 'latlon' || gridDef.type === 'rotated_latlon') {
|
|
1501
|
+
let adjustedLon = projected.x;
|
|
1502
|
+
if (modelName === 'mrms') {
|
|
1503
|
+
if (adjustedLon < 0) adjustedLon += 360;
|
|
1504
|
+
x = (adjustedLon - gridOrigin.x) / gridPixelSize.x;
|
|
1505
|
+
y = (gridOrigin.y - projected.y) / gridPixelSize.y;
|
|
1506
|
+
} else {
|
|
1507
|
+
const isGFSType = gridDef.grid_params && gridDef.grid_params.lon_first === 0.0 && Math.abs(gridDef.grid_params.lat_first) === 90.0;
|
|
1508
|
+
const isECMWFType = gridDef.grid_params && gridDef.grid_params.lon_first === 180.0 && gridDef.grid_params.lat_first === 90.0;
|
|
1509
|
+
const isGEMType = modelName === 'gem' || (gridDef.grid_params.lon_first === 180.0 && gridDef.grid_params.lat_first === -90.0 && gridDef.grid_params.lon_last === 179.85);
|
|
1510
|
+
if (isGEMType) {
|
|
1511
|
+
while (adjustedLon < gridOrigin.x) adjustedLon += 360;
|
|
1512
|
+
x = (adjustedLon - gridOrigin.x) / gridPixelSize.x;
|
|
1513
|
+
y = (projected.y - gridOrigin.y) / gridPixelSize.y;
|
|
1514
|
+
return { x, y };
|
|
1515
|
+
}
|
|
1516
|
+
let isFlippedGrid = isECMWFType ? true : (gridDef.grid_params.lat_first < (gridDef.grid_params.lat_last || (gridDef.grid_params.ny - 1) * gridDef.grid_params.dy_degrees));
|
|
1517
|
+
if (isGFSType) adjustedLon = projected.x + 180;
|
|
1518
|
+
else if (isECMWFType) { if (adjustedLon < gridOrigin.x) adjustedLon += 360; }
|
|
1519
|
+
else if (['arome1', 'arome25', 'arpegeeu', 'iconeu', 'icond2'].includes(modelName)) {
|
|
1520
|
+
while (adjustedLon < 0) adjustedLon += 360;
|
|
1521
|
+
while (adjustedLon >= 360) adjustedLon -= 360;
|
|
1522
|
+
x = (adjustedLon >= gridOrigin.x) ? (adjustedLon - gridOrigin.x) / gridPixelSize.x : (adjustedLon + 360 - gridOrigin.x) / gridPixelSize.x;
|
|
1523
|
+
if (['arome1', 'arome25', 'arpegeeu'].includes(modelName)) y = (gridOrigin.y - projected.y) / Math.abs(gridPixelSize.y);
|
|
1524
|
+
else if (['iconeu', 'icond2'].includes(modelName)) y = (projected.y - gridOrigin.y) / gridPixelSize.y;
|
|
1525
|
+
return { x, y };
|
|
1526
|
+
} else {
|
|
1527
|
+
const lonFirst = gridOrigin.x;
|
|
1528
|
+
if (lonFirst > 180 && adjustedLon < 0) adjustedLon += 360;
|
|
1529
|
+
else if (lonFirst < 0 && adjustedLon > 180) adjustedLon -= 360;
|
|
1530
|
+
}
|
|
1531
|
+
x = (adjustedLon - gridOrigin.x) / gridPixelSize.x;
|
|
1532
|
+
if (isFlippedGrid) {
|
|
1533
|
+
if (isECMWFType) y = (gridOrigin.y - projected.y) / Math.abs(gridPixelSize.y);
|
|
1534
|
+
else {
|
|
1535
|
+
const maxLat = gridDef.grid_params.lat_last || (gridDef.grid_params.ny - 1) * gridDef.grid_params.dy_degrees;
|
|
1536
|
+
y = (maxLat - projected.y) / Math.abs(gridPixelSize.y);
|
|
1537
|
+
}
|
|
1538
|
+
} else y = (projected.y - gridOrigin.y) / gridPixelSize.y;
|
|
1539
|
+
}
|
|
1540
|
+
} else {
|
|
1541
|
+
const projOrigin = { x: gridDef.grid_params.x_origin, y: gridDef.grid_params.y_origin };
|
|
1542
|
+
const projPixelSize = { x: gridDef.grid_params.dx, y: gridDef.grid_params.dy };
|
|
1543
|
+
x = (projected.x - projOrigin.x) / projPixelSize.x;
|
|
1544
|
+
y = (projOrigin.y - projected.y) / Math.abs(projPixelSize.y);
|
|
1545
|
+
}
|
|
1546
|
+
return { x, y };
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
latLonToGridPixelPolarStereographic(lat, lon, gridDef) {
|
|
1550
|
+
try {
|
|
1551
|
+
const projParams = gridDef.proj_params;
|
|
1552
|
+
let projectionString = `+proj=${projParams.proj}`;
|
|
1553
|
+
Object.keys(projParams).forEach(key => { if (key !== 'proj') projectionString += ` +${key}=${projParams[key]}`; });
|
|
1554
|
+
projectionString += ' +lat_0=90 +no_defs';
|
|
1555
|
+
const { nx, ny, dx, dy, x_origin, y_origin } = gridDef.grid_params;
|
|
1556
|
+
const x_min = x_origin;
|
|
1557
|
+
const x_max = x_origin + (nx - 1) * dx;
|
|
1558
|
+
const y_max = y_origin;
|
|
1559
|
+
const y_min = y_origin + (ny - 1) * dy;
|
|
1560
|
+
const [proj_x, proj_y] = proj4('EPSG:4326', projectionString, [lon, lat]);
|
|
1561
|
+
if (!isFinite(proj_x) || !isFinite(proj_y)) return { x: -1, y: -1 };
|
|
1562
|
+
const t_x = (proj_x - x_min) / (x_max - x_min);
|
|
1563
|
+
const t_y = (proj_y - y_max) / (y_min - y_max);
|
|
1564
|
+
const x = t_x * (nx - 1);
|
|
1565
|
+
const y = t_y * (ny - 1);
|
|
1566
|
+
return { x, y };
|
|
1567
|
+
} catch (error) {
|
|
1568
|
+
return { x: -1, y: -1 };
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// --- Status Methods ---
|
|
1573
|
+
|
|
1574
|
+
async fetchModelStatus(force = false) {
|
|
1575
|
+
if (!this.modelStatus || force) {
|
|
1576
|
+
try {
|
|
1577
|
+
const response = await fetch(this.statusUrl);
|
|
1578
|
+
if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`);
|
|
1579
|
+
this.modelStatus = (await response.json()).models;
|
|
1580
|
+
} catch (error) { this.modelStatus = null; }
|
|
1581
|
+
}
|
|
1582
|
+
return this.modelStatus;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
async fetchMRMSStatus(force = false) {
|
|
1586
|
+
const mrmsStatusUrl = 'https://h3dfvh5pq6euq36ymlpz4zqiha0obqju.lambda-url.us-east-2.on.aws';
|
|
1587
|
+
if (!this.mrmsStatus || force) {
|
|
1588
|
+
try {
|
|
1589
|
+
const response = await fetch(mrmsStatusUrl);
|
|
1590
|
+
if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`);
|
|
1591
|
+
this.mrmsStatus = await response.json();
|
|
1592
|
+
} catch (error) { this.mrmsStatus = null; }
|
|
1593
|
+
}
|
|
1594
|
+
return this.mrmsStatus;
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
async fetchSatelliteListing(force = false) {
|
|
1598
|
+
if (!this.satelliteListing || force) {
|
|
1599
|
+
try {
|
|
1600
|
+
const response = await fetch(SATELLITE_FRAMES_URL);
|
|
1601
|
+
if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`);
|
|
1602
|
+
this.satelliteListing = await response.json();
|
|
1603
|
+
} catch (error) {
|
|
1604
|
+
this.satelliteListing = null;
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
return this.satelliteListing;
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
startAutoRefresh(intervalSeconds) {
|
|
1611
|
+
this.stopAutoRefresh();
|
|
1612
|
+
this.autoRefreshIntervalId = setInterval(async () => {
|
|
1613
|
+
await this.fetchModelStatus(true);
|
|
1614
|
+
this._emitStateChange();
|
|
1615
|
+
}, (intervalSeconds || 60) * 1000);
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
stopAutoRefresh() {
|
|
1619
|
+
if (this.autoRefreshIntervalId) {
|
|
1620
|
+
clearInterval(this.autoRefreshIntervalId);
|
|
1621
|
+
this.autoRefreshIntervalId = null;
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
1624
|
}
|