@aguacerowx/javascript-sdk 0.0.18 → 0.0.21

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.
@@ -7,7 +7,32 @@ import { getUnitConversionFunction } from './unitConversions.js';
7
7
  import { DICTIONARIES, MODEL_CONFIGS } from './dictionaries.js';
8
8
  import { DEFAULT_COLORMAPS } from './default-colormaps.js';
9
9
  import proj4 from 'proj4';
10
+ import { processCompressedGrid } from './gridDecodePipeline.js';
10
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
+ } from './nexrad_support.js';
27
+ import { NEXRAD_LEVEL3_ELEV, getNexradLevel3EntryByRadarKey } from './nexrad_level3_catalog.js';
28
+ import {
29
+ setRadarTiltsManifest,
30
+ fetchRadarTiltsManifestFromNetwork,
31
+ getDefaultRadarTilt,
32
+ formatTiltForApi,
33
+ clampNexradTiltForVariable,
34
+ getRadarTilts,
35
+ } from './nexradTilts.js';
11
36
 
12
37
  // --- Non-UI Helper Functions ---
13
38
  function hrdpsObliqueTransform(rotated_lon, rotated_lat) {
@@ -41,26 +66,67 @@ function findLatestModelRun(modelsData, modelName) {
41
66
  return null;
42
67
  }
43
68
 
69
+ /**
70
+ * model-status JSON uses string keys for runs (often zero-padded: "00", "06").
71
+ * Direct lookup modelStatus[model][date][run] fails if state.run is "6" but the key is "06".
72
+ * Returns the hour list and the run key that matched.
73
+ */
74
+ function resolveModelRunHours(modelStatus, model, date, run) {
75
+ const runs = modelStatus?.[model]?.[date];
76
+ if (!runs || run == null || run === '') {
77
+ return {
78
+ hours: [],
79
+ matchedRunKey: null,
80
+ availableRunKeys: runs ? Object.keys(runs) : [],
81
+ };
82
+ }
83
+ const runStr = String(run);
84
+ const candidates = new Set([runStr]);
85
+ const n = parseInt(runStr, 10);
86
+ if (!Number.isNaN(n)) {
87
+ candidates.add(String(n));
88
+ candidates.add(String(n).padStart(2, '0'));
89
+ candidates.add(String(n).padStart(3, '0'));
90
+ }
91
+ for (const key of candidates) {
92
+ const h = runs[key];
93
+ if (h && Array.isArray(h) && h.length > 0) {
94
+ return { hours: h, matchedRunKey: key, availableRunKeys: Object.keys(runs) };
95
+ }
96
+ }
97
+ if (!Number.isNaN(n)) {
98
+ for (const k of Object.keys(runs)) {
99
+ const kn = parseInt(k, 10);
100
+ if (!Number.isNaN(kn) && kn === n) {
101
+ const h = runs[k];
102
+ if (h && Array.isArray(h) && h.length > 0) {
103
+ return { hours: h, matchedRunKey: k, availableRunKeys: Object.keys(runs) };
104
+ }
105
+ }
106
+ }
107
+ }
108
+ return { hours: [], matchedRunKey: null, availableRunKeys: Object.keys(runs) };
109
+ }
110
+
44
111
  export class AguaceroCore extends EventEmitter {
45
112
  constructor(options = {}) {
46
113
  super();
47
114
  this.isReactNative = typeof navigator !== 'undefined' && navigator.product === 'ReactNative';
48
115
  this.apiKey = options.apiKey;
116
+ /** Passed as CloudFront `userId` for satellite KTX2 URLs (production uses the authenticated account id). */
117
+ this.userId = options.userId ?? 'sdk-user';
49
118
  this.bundleId = getBundleId();
50
119
  this.baseGridUrl = 'https://d3dc62msmxkrd7.cloudfront.net';
51
- if (!this.isReactNative) {
52
- this.worker = this.createWorker();
53
- this.workerRequestId = 0;
54
- this.workerResolvers = new Map();
55
- this.worker.addEventListener('message', this._handleWorkerMessage.bind(this));
56
- this.resultQueue = [];
57
- this.isProcessingQueue = false;
58
- } else {
59
- this.worker = null;
60
- }
120
+ /** @type {Worker | null} */
121
+ this._gridDecodeWorker = null;
122
+ /** When true, skip Worker and use {@link processCompressedGrid} on the main thread. */
123
+ this._gridDecodeWorkerDisabled = false;
124
+ this._gridDecodeMsgId = 0;
61
125
  this.statusUrl = 'https://d3dc62msmxkrd7.cloudfront.net/model-status';
62
126
  this.modelStatus = null;
63
127
  this.mrmsStatus = null;
128
+ /** @type {{ objects?: Array<{ key: string }> } | null} */
129
+ this.satelliteListing = null;
64
130
  this.dataCache = new Map();
65
131
  this.abortControllers = new Map();
66
132
  this.isPlaying = false;
@@ -73,31 +139,237 @@ export class AguaceroCore extends EventEmitter {
73
139
  const initialMode = userLayerOptions.mode || 'model';
74
140
  const initialVariable = userLayerOptions.variable || null;
75
141
 
142
+ const initialSatellite = initialMode === 'satellite';
143
+ const initialNexrad = initialMode === 'nexrad';
144
+ const initialNexradProd = userLayerOptions.nexradProduct || 'REF';
145
+ const initialNexradDs =
146
+ userLayerOptions.nexradDataSource != null
147
+ ? userLayerOptions.nexradDataSource === 'level3'
148
+ ? 'level3'
149
+ : 'level2'
150
+ : inferNexradDataSourceForProduct(initialNexradProd);
151
+ const initialNexradFld = initialNexrad
152
+ ? nexradColormapFldKey(initialNexradDs, initialNexradProd)
153
+ : initialVariable;
154
+ let initialSatelliteInstrumentId = null;
155
+ let initialSatelliteSectorLabel = null;
156
+ let initialSatelliteChannel = null;
157
+ if (initialSatellite) {
158
+ initialSatelliteInstrumentId = userLayerOptions.satelliteId ?? 'GOES19-EAST';
159
+ initialSatelliteSectorLabel = resolveSatelliteSectorLabel(
160
+ userLayerOptions.satelliteSector ?? userLayerOptions.sector ?? 'conus',
161
+ );
162
+ initialSatelliteChannel =
163
+ userLayerOptions.satelliteProduct ??
164
+ userLayerOptions.satelliteChannel ??
165
+ initialVariable ??
166
+ 'C13';
167
+ }
76
168
  this.state = {
77
169
  model: userLayerOptions.model || 'gfs',
78
170
  // EDIT: Set isMRMS based on the initial mode
79
- isMRMS: initialMode === 'mrms',
171
+ isMRMS: initialMode === 'mrms' && !initialSatellite && !initialNexrad,
80
172
  mrmsTimestamp: null,
81
- variable: initialVariable,
173
+ variable: initialNexrad
174
+ ? initialNexradFld
175
+ : initialSatellite && initialSatelliteInstrumentId
176
+ ? initialSatelliteChannel
177
+ : initialVariable,
82
178
  date: null,
83
179
  run: null,
84
180
  forecastHour: 0,
85
181
  visible: true,
86
182
  opacity: userLayerOptions.opacity ?? 1,
87
183
  units: options.initialUnit || 'imperial',
88
- shaderSmoothingEnabled: options.shaderSmoothingEnabled ?? true
184
+ shaderSmoothingEnabled: options.shaderSmoothingEnabled ?? true,
185
+ isSatellite: initialSatellite,
186
+ satelliteInstrumentId: initialSatelliteInstrumentId,
187
+ satelliteSectorLabel: initialSatelliteSectorLabel,
188
+ satelliteChannel: initialSatelliteChannel,
189
+ satelliteTimestamp: userLayerOptions.satelliteTimestamp != null ? Number(userLayerOptions.satelliteTimestamp) : null,
190
+ satelliteTier: userLayerOptions.satelliteTier || 'basic',
191
+ satelliteDurationValue: formatTimelineDurationValue(
192
+ userLayerOptions.satelliteDurationValue != null ? userLayerOptions.satelliteDurationValue : '1',
193
+ ),
194
+ mrmsDurationValue: formatTimelineDurationValue(
195
+ userLayerOptions.mrmsDurationValue != null ? userLayerOptions.mrmsDurationValue : '1',
196
+ ),
197
+ nexradDurationValue: formatTimelineDurationValue(
198
+ userLayerOptions.nexradDurationValue != null ? userLayerOptions.nexradDurationValue : '1',
199
+ ),
200
+ isNexrad: initialNexrad,
201
+ nexradSite: userLayerOptions.nexradSite ?? null,
202
+ nexradDataSource: initialNexradDs,
203
+ nexradProduct: initialNexradProd,
204
+ nexradTilt:
205
+ userLayerOptions.nexradTilt != null
206
+ ? Number(userLayerOptions.nexradTilt)
207
+ : userLayerOptions.nexradSite
208
+ ? getDefaultRadarTilt(userLayerOptions.nexradSite)
209
+ : null,
210
+ nexradTimestamp: userLayerOptions.nexradTimestamp != null ? Number(userLayerOptions.nexradTimestamp) : null,
211
+ nexradStormRelative: userLayerOptions.nexradStormRelative === true,
212
+ /** When true, mapsgl shows clickable NEXRAD site markers (independent of selected site). */
213
+ nexradShowSitesPicker: userLayerOptions.nexradShowSitesPicker !== false,
89
214
  };
90
215
 
91
216
  this.autoRefreshEnabled = options.autoRefresh ?? false;
92
217
  this.autoRefreshIntervalSeconds = options.autoRefreshInterval ?? 60;
93
218
  this.autoRefreshIntervalId = null;
219
+
220
+ /** @type {Record<string, { unixTimes?: number[]; timeToKeyMap?: Record<string, string>; listWindowHours?: number }>} */
221
+ this.nexradTimesByStation = {};
94
222
  }
95
223
 
96
224
  async setState(newState) {
97
- Object.assign(this.state, newState);
225
+ const patch = { ...newState };
226
+ if ('satelliteKey' in patch) delete patch.satelliteKey;
227
+ if ('forecastHour' in patch && patch.forecastHour != null) {
228
+ patch.forecastHour = Number(patch.forecastHour);
229
+ }
230
+ if ('mrmsTimestamp' in patch && patch.mrmsTimestamp != null) {
231
+ patch.mrmsTimestamp = Number(patch.mrmsTimestamp);
232
+ }
233
+ if ('satelliteTimestamp' in patch && patch.satelliteTimestamp != null) {
234
+ patch.satelliteTimestamp = Number(patch.satelliteTimestamp);
235
+ }
236
+ if ('satelliteDurationValue' in patch && patch.satelliteDurationValue != null) {
237
+ patch.satelliteDurationValue = formatTimelineDurationValue(patch.satelliteDurationValue);
238
+ }
239
+ if ('mrmsDurationValue' in patch && patch.mrmsDurationValue != null) {
240
+ patch.mrmsDurationValue = formatTimelineDurationValue(patch.mrmsDurationValue);
241
+ }
242
+ if ('nexradDurationValue' in patch && patch.nexradDurationValue != null) {
243
+ patch.nexradDurationValue = formatTimelineDurationValue(patch.nexradDurationValue);
244
+ }
245
+ if ('nexradTimestamp' in patch && patch.nexradTimestamp != null) {
246
+ patch.nexradTimestamp = Number(patch.nexradTimestamp);
247
+ }
248
+ if ('nexradTilt' in patch && patch.nexradTilt != null) {
249
+ patch.nexradTilt = Number(patch.nexradTilt);
250
+ }
251
+ Object.assign(this.state, patch);
98
252
  this._emitStateChange();
99
253
  }
100
254
 
255
+ /**
256
+ * Forecast hours for the current model/date/run (normalized numbers).
257
+ * Tolerates model-status run keys like "06" vs state.run "6" so preload and slider stay in sync.
258
+ */
259
+ getAvailableForecastHours() {
260
+ if (this.state.isMRMS || this.state.isSatellite || this.state.isNexrad) return [];
261
+ if (!this.state.model || this.state.date == null || this.state.run == null) return [];
262
+
263
+ const resolved = resolveModelRunHours(
264
+ this.modelStatus,
265
+ this.state.model,
266
+ this.state.date,
267
+ this.state.run
268
+ );
269
+ let hours = resolved.hours || [];
270
+
271
+ if (hours.length > 0) {
272
+ hours = hours.map(h => (typeof h === 'string' ? parseInt(h, 10) : Number(h))).filter(h => !Number.isNaN(h));
273
+ }
274
+ if (this.state.variable === 'ptypeRefl' && this.state.model === 'hrrr' && hours.length > 0) {
275
+ hours = hours.filter(hour => hour !== 0);
276
+ }
277
+ return hours;
278
+ }
279
+
280
+ _computeSatelliteTimeline() {
281
+ const { satelliteInstrumentId, satelliteSectorLabel, satelliteChannel } = this.state;
282
+ if (!satelliteInstrumentId || !satelliteSectorLabel || !satelliteChannel || !this.satelliteListing?.objects) {
283
+ return { unixTimes: [], timeToFileMap: {} };
284
+ }
285
+ const allFiles = this.satelliteListing.objects.map((o) => o.key);
286
+ const sectorName = satelliteSectorLabel;
287
+ const tier = this.state.satelliteTier || 'basic';
288
+ const durationOpt = resolveSatelliteDurationOption(sectorName, tier, this.state.satelliteDurationValue);
289
+ return buildSatelliteTimelineForSelection(
290
+ {
291
+ satelliteInstrumentId,
292
+ satelliteSectorLabel,
293
+ satelliteChannel,
294
+ },
295
+ allFiles,
296
+ durationOpt,
297
+ );
298
+ }
299
+
300
+ /**
301
+ * MRMS timestamps for a variable, oldest first (matches satellite timelines and left→right time sliders).
302
+ * Limited to the last N hours relative to the newest frame (see mrmsDurationValue).
303
+ * @param {string} variable
304
+ * @returns {number[]}
305
+ */
306
+ _getFilteredMrmsTimestampsForVariable(variable) {
307
+ const raw = this.mrmsStatus?.[variable];
308
+ if (!raw || !raw.length) return [];
309
+ const hours = parseTimelineDurationHours(this.state.mrmsDurationValue);
310
+ let list = [...raw]
311
+ .map((t) => Number(t))
312
+ .filter((t) => !Number.isNaN(t))
313
+ .sort((a, b) => a - b);
314
+ if (hours > 0 && list.length > 0) {
315
+ const latest = list[list.length - 1];
316
+ const cutoff = latest - hours * 3600;
317
+ list = list.filter((t) => t >= cutoff);
318
+ }
319
+ return list;
320
+ }
321
+
322
+ _nexradListingWindowHours() {
323
+ return parseTimelineDurationHours(this.state.nexradDurationValue);
324
+ }
325
+
326
+ _getFilteredNexradTimestampsForVariable(rawList) {
327
+ if (!rawList || !rawList.length) return [];
328
+ const hours = this._nexradListingWindowHours();
329
+ let list = [...rawList]
330
+ .map((t) => Number(t))
331
+ .filter((t) => !Number.isNaN(t))
332
+ .sort((a, b) => a - b);
333
+ if (hours > 0 && list.length > 0) {
334
+ const latest = list[list.length - 1];
335
+ const cutoff = latest - hours * 3600;
336
+ list = list.filter((t) => t >= cutoff);
337
+ }
338
+ return list;
339
+ }
340
+
341
+ /**
342
+ * Cache key for {@link this.nexradTimesByStation} — matches frontend composite keys (tilt + variable group/source).
343
+ */
344
+ _nexradTimesCacheKey() {
345
+ const s = this.state;
346
+ if (!s.isNexrad || !s.nexradSite) return null;
347
+ const site = s.nexradSite;
348
+ const variable = s.nexradProduct || 'REF';
349
+ const ds = s.nexradDataSource || 'level2';
350
+ const tiltNum = s.nexradTilt != null ? s.nexradTilt : getDefaultRadarTilt(site);
351
+ const elevNormUse =
352
+ ds === 'level3'
353
+ ? NEXRAD_LEVEL3_ELEV
354
+ : formatTiltForApi(clampNexradTiltForVariable(site, variable, tiltNum));
355
+ const group = ds === 'level3' ? 'l3' : variableToNexradGroup(variable);
356
+ const l3Product =
357
+ ds === 'level3'
358
+ ? getNexradLevel3EntryByRadarKey(variable)?.product ?? (variable === 'VEL' ? 'N0G' : variable)
359
+ : '';
360
+ if (ds === 'level3') {
361
+ return `${site}_l3_${l3Product}_${elevNormUse}`;
362
+ }
363
+ return `${site}_${group}_${elevNormUse}`;
364
+ }
365
+
366
+ /** Storm-relative Level-III velocity (N0G + N0S) — matches aguacero-frontend `level3StormRelative` for VEL. */
367
+ _nexradStormRelativeFor(nexradDataSource, nexradProduct) {
368
+ const ds = nexradDataSource === 'level3' ? 'level3' : 'level2';
369
+ const p = (nexradProduct || 'REF').toUpperCase();
370
+ return ds === 'level3' && p === 'VEL';
371
+ }
372
+
101
373
  _emitStateChange() {
102
374
  const { colormap, baseUnit } = this._getColormapForVariable(this.state.variable);
103
375
  const toUnit = this._getTargetUnit(baseUnit, this.state.units);
@@ -105,18 +377,40 @@ export class AguaceroCore extends EventEmitter {
105
377
 
106
378
  let availableTimestamps = [];
107
379
  if (this.state.isMRMS && this.state.variable && this.mrmsStatus) {
108
- const timestamps = this.mrmsStatus[this.state.variable] || [];
109
- availableTimestamps = [...timestamps].reverse();
380
+ availableTimestamps = this._getFilteredMrmsTimestampsForVariable(this.state.variable);
110
381
  }
111
382
 
112
- let availableHours = this.state.isMRMS
113
- ? []
114
- : (this.modelStatus?.[this.state.model]?.[this.state.date]?.[this.state.run] || []);
115
-
116
- if (!this.state.isMRMS && this.state.variable === 'ptypeRefl' && this.state.model === 'hrrr' && availableHours.length > 0) {
117
- availableHours = availableHours.filter(hour => hour !== 0);
383
+ let availableSatelliteTimestamps = [];
384
+ let satelliteTimeToFileMap = {};
385
+ if (this.state.isSatellite && this.state.satelliteInstrumentId) {
386
+ const timeline = this._computeSatelliteTimeline();
387
+ satelliteTimeToFileMap = timeline.timeToFileMap || {};
388
+ availableSatelliteTimestamps = [...(timeline.unixTimes || [])]
389
+ .map((t) => Number(t))
390
+ .filter((t) => !Number.isNaN(t))
391
+ .sort((a, b) => a - b);
118
392
  }
119
393
 
394
+ let availableNexradTimestamps = [];
395
+ let nexradTimeToKeyMap = {};
396
+ let nexradLevel3MotionTimeToKeyMap = {};
397
+ let availableNexradTilts = [];
398
+ if (this.state.isNexrad && this.state.nexradSite) {
399
+ const nk = this._nexradTimesCacheKey();
400
+ const ent = nk ? this.nexradTimesByStation[nk] : null;
401
+ const raw = ent?.unixTimes || [];
402
+ availableNexradTimestamps = this._getFilteredNexradTimestampsForVariable(raw);
403
+ nexradTimeToKeyMap = ent?.timeToKeyMap || {};
404
+ nexradLevel3MotionTimeToKeyMap = ent?.level3MotionTimeToKeyMap || {};
405
+ availableNexradTilts = getAvailableNexradTilts(
406
+ this.state.nexradSite,
407
+ this.state.nexradDataSource || 'level2',
408
+ this.state.nexradProduct || 'REF',
409
+ );
410
+ }
411
+
412
+ const availableHours = this.getAvailableForecastHours();
413
+
120
414
  const eventPayload = {
121
415
  ...this.state,
122
416
  availableModels: this.modelStatus ? Object.keys(this.modelStatus).sort() : [],
@@ -126,6 +420,12 @@ export class AguaceroCore extends EventEmitter {
126
420
  // We need to confirm this line is working as expected.
127
421
  availableMRMSVariables: this.getAvailableVariables('mrms'),
128
422
  availableTimestamps: availableTimestamps,
423
+ availableSatelliteTimestamps,
424
+ satelliteTimeToFileMap,
425
+ availableNexradTimestamps,
426
+ nexradTimeToKeyMap,
427
+ nexradLevel3MotionTimeToKeyMap,
428
+ availableNexradTilts,
129
429
  isPlaying: this.isPlaying,
130
430
  colormap: displayColormap,
131
431
  colormapBaseUnit: toUnit,
@@ -137,27 +437,36 @@ export class AguaceroCore extends EventEmitter {
137
437
  async initialize(options = {}) {
138
438
  await this.fetchModelStatus(true);
139
439
  await this.fetchMRMSStatus(true);
140
-
440
+ await this.fetchSatelliteListing(true);
441
+
141
442
  let initialState = { ...this.state };
142
443
 
444
+ if (initialState.isSatellite && initialState.satelliteInstrumentId) {
445
+ const timeline = this._computeSatelliteTimeline();
446
+ const tsList = [...(timeline.unixTimes || [])].sort((a, b) => a - b);
447
+ if (initialState.satelliteTimestamp == null && tsList.length > 0) {
448
+ initialState.satelliteTimestamp = tsList[tsList.length - 1];
449
+ }
450
+ }
451
+
143
452
  // ADD: Logic to handle an initial MRMS state
144
453
  if (initialState.isMRMS) {
145
454
  const variable = initialState.variable;
146
455
  if (variable && this.mrmsStatus && this.mrmsStatus[variable]) {
147
- const sortedTimestamps = [...(this.mrmsStatus[variable] || [])].sort((a, b) => b - a);
148
- initialState.mrmsTimestamp = sortedTimestamps.length > 0 ? sortedTimestamps[0] : null;
456
+ const sortedTimestamps = this._getFilteredMrmsTimestampsForVariable(variable);
457
+ initialState.mrmsTimestamp =
458
+ sortedTimestamps.length > 0 ? sortedTimestamps[sortedTimestamps.length - 1] : null;
149
459
  } else {
150
- // Fallback if the provided variable is not valid
151
- console.warn(`Initial MRMS variable '${variable}' not found. Using default.`);
152
460
  const availableMRMSVars = this.getAvailableVariables('mrms');
153
461
  if (availableMRMSVars.length > 0) {
154
462
  const firstVar = availableMRMSVars[0];
155
463
  initialState.variable = firstVar;
156
- const sortedTimestamps = [...(this.mrmsStatus[firstVar] || [])].sort((a, b) => b - a);
157
- initialState.mrmsTimestamp = sortedTimestamps.length > 0 ? sortedTimestamps[0] : null;
464
+ const sortedTimestamps = this._getFilteredMrmsTimestampsForVariable(firstVar);
465
+ initialState.mrmsTimestamp =
466
+ sortedTimestamps.length > 0 ? sortedTimestamps[sortedTimestamps.length - 1] : null;
158
467
  }
159
468
  }
160
- } else {
469
+ } else if (!initialState.isSatellite && !initialState.isNexrad) {
161
470
  // EDIT: This is the existing logic, now in an else block
162
471
  const latestRun = findLatestModelRun(this.modelStatus, initialState.model);
163
472
  if (latestRun) {
@@ -172,6 +481,14 @@ export class AguaceroCore extends EventEmitter {
172
481
  }
173
482
 
174
483
  await this.setState(initialState);
484
+
485
+ if (this.state.isNexrad) {
486
+ const manifest = await fetchRadarTiltsManifestFromNetwork();
487
+ if (manifest) setRadarTiltsManifest(manifest);
488
+ if (this.state.nexradSite) {
489
+ await this.refreshNexradTimes();
490
+ }
491
+ }
175
492
  if (options.autoRefresh ?? this.autoRefreshEnabled) {
176
493
  this.startAutoRefresh(options.refreshInterval ?? this.autoRefreshIntervalSeconds);
177
494
  }
@@ -181,11 +498,11 @@ export class AguaceroCore extends EventEmitter {
181
498
  this.pause();
182
499
  this.stopAutoRefresh();
183
500
  this.dataCache.clear();
184
- if (this.worker) {
185
- this.worker.terminate();
186
- }
187
501
  this.callbacks = {};
188
- console.log(`AguaceroCore has been destroyed.`);
502
+ if (this._gridDecodeWorker) {
503
+ this._gridDecodeWorker.terminate();
504
+ this._gridDecodeWorker = null;
505
+ }
189
506
  }
190
507
 
191
508
  // --- Public API Methods ---
@@ -213,24 +530,40 @@ export class AguaceroCore extends EventEmitter {
213
530
  }
214
531
 
215
532
  step(direction = 1) {
533
+ if (this.state.isSatellite) {
534
+ const timeline = this._computeSatelliteTimeline();
535
+ const availableTimestamps = [...(timeline.unixTimes || [])]
536
+ .sort((a, b) => a - b)
537
+ .map((t) => Number(t));
538
+ if (availableTimestamps.length === 0) return;
539
+
540
+ const ts = this.state.satelliteTimestamp == null ? null : Number(this.state.satelliteTimestamp);
541
+ const currentIndex = ts == null ? -1 : availableTimestamps.indexOf(ts);
542
+
543
+ let nextIndex = currentIndex === -1 ? availableTimestamps.length - 1 : currentIndex + direction;
544
+ const maxIndex = availableTimestamps.length - 1;
545
+ if (nextIndex > maxIndex) nextIndex = 0;
546
+ if (nextIndex < 0) nextIndex = maxIndex;
547
+
548
+ this.setState({ satelliteTimestamp: availableTimestamps[nextIndex] });
549
+ return;
550
+ }
216
551
  // --- THIS IS THE CORRECTED MRMS LOGIC ---
217
552
  if (this.state.isMRMS) {
218
553
  const { variable, mrmsTimestamp } = this.state;
219
554
  if (!this.mrmsStatus || !this.mrmsStatus[variable]) {
220
- console.warn('[Core.step] MRMS status or variable not available.');
221
555
  return;
222
556
  }
223
-
224
- // CRITICAL FIX: The UI and state emissions use a REVERSED array (newest first).
225
- // The step logic MUST use the same reversed array for indexes to match.
226
- const availableTimestamps = [...(this.mrmsStatus[variable] || [])].reverse();
557
+
558
+ const availableTimestamps = this._getFilteredMrmsTimestampsForVariable(variable);
227
559
  if (availableTimestamps.length === 0) return;
228
560
 
229
- const currentIndex = availableTimestamps.indexOf(mrmsTimestamp);
561
+ const ts = mrmsTimestamp == null ? null : Number(mrmsTimestamp);
562
+ const currentIndex = ts == null ? -1 : availableTimestamps.indexOf(ts);
230
563
 
231
564
  if (currentIndex === -1) {
232
- // If not found, reset to the first (newest) frame
233
- this.setState({ mrmsTimestamp: availableTimestamps[0] });
565
+ // If not found, reset to the latest frame (end of ascending list)
566
+ this.setState({ mrmsTimestamp: availableTimestamps[availableTimestamps.length - 1] });
234
567
  return;
235
568
  }
236
569
 
@@ -244,12 +577,28 @@ export class AguaceroCore extends EventEmitter {
244
577
  const newTimestamp = availableTimestamps[nextIndex];
245
578
  this.setState({ mrmsTimestamp: newTimestamp });
246
579
 
580
+ } else if (this.state.isNexrad) {
581
+ const nk = this._nexradTimesCacheKey();
582
+ const raw = nk ? this.nexradTimesByStation[nk]?.unixTimes : [];
583
+ const availableTimestamps = this._getFilteredNexradTimestampsForVariable(raw || []);
584
+ if (availableTimestamps.length === 0) return;
585
+
586
+ const ts = this.state.nexradTimestamp == null ? null : Number(this.state.nexradTimestamp);
587
+ const currentIndex = ts == null ? -1 : availableTimestamps.indexOf(ts);
588
+
589
+ let nextIndex = currentIndex === -1 ? availableTimestamps.length - 1 : currentIndex + direction;
590
+ const maxIndex = availableTimestamps.length - 1;
591
+ if (nextIndex > maxIndex) nextIndex = 0;
592
+ if (nextIndex < 0) nextIndex = maxIndex;
593
+
594
+ this.setState({ nexradTimestamp: availableTimestamps[nextIndex] });
247
595
  } else {
248
- const { model, date, run, forecastHour } = this.state;
249
- const forecastHours = this.modelStatus?.[model]?.[date]?.[run];
596
+ const { forecastHour } = this.state;
597
+ const forecastHours = this.getAvailableForecastHours();
250
598
  if (!forecastHours || forecastHours.length === 0) return;
251
599
 
252
- const currentIndex = forecastHours.indexOf(forecastHour);
600
+ const fh = Number(forecastHour);
601
+ const currentIndex = forecastHours.indexOf(fh);
253
602
  if (currentIndex === -1) return;
254
603
 
255
604
  const maxIndex = forecastHours.length - 1;
@@ -283,7 +632,12 @@ export class AguaceroCore extends EventEmitter {
283
632
  async setVariable(variable) {
284
633
  // --- NEW CODE: Handle switching TO ptypeRefl on HRRR ---
285
634
  if (variable === 'ptypeRefl' && this.state.model === 'hrrr' && this.state.forecastHour === 0) {
286
- const availableHours = this.modelStatus?.[this.state.model]?.[this.state.date]?.[this.state.run] || [];
635
+ const availableHours = resolveModelRunHours(
636
+ this.modelStatus,
637
+ this.state.model,
638
+ this.state.date,
639
+ this.state.run
640
+ ).hours || [];
287
641
  const firstValidHour = availableHours.find(hour => hour !== 0) || 0;
288
642
  await this.setState({ variable, forecastHour: firstValidHour });
289
643
  return;
@@ -295,6 +649,15 @@ export class AguaceroCore extends EventEmitter {
295
649
 
296
650
  async setModel(modelName) {
297
651
  if (modelName === this.state.model || !this.modelStatus?.[modelName]) return;
652
+ if (this.state.isSatellite) {
653
+ await this.setState({
654
+ isSatellite: false,
655
+ satelliteInstrumentId: null,
656
+ satelliteSectorLabel: null,
657
+ satelliteChannel: null,
658
+ satelliteTimestamp: null,
659
+ });
660
+ }
298
661
  const latestRun = findLatestModelRun(this.modelStatus, modelName);
299
662
  if (latestRun) {
300
663
  // --- NEW CODE: Determine initial forecast hour ---
@@ -302,8 +665,12 @@ export class AguaceroCore extends EventEmitter {
302
665
 
303
666
  // If switching to HRRR with ptypeRefl, start at hour 1 instead of 0
304
667
  if (modelName === 'hrrr' && this.state.variable === 'ptypeRefl') {
305
- const availableHours = this.modelStatus?.[modelName]?.[latestRun.date]?.[latestRun.run] || [];
306
- // Find the first valid hour (should be 1)
668
+ const availableHours = resolveModelRunHours(
669
+ this.modelStatus,
670
+ modelName,
671
+ latestRun.date,
672
+ latestRun.run
673
+ ).hours || [];
307
674
  initialHour = availableHours.find(hour => hour !== 0) || 0;
308
675
  }
309
676
  // --- END NEW CODE ---
@@ -330,12 +697,18 @@ export class AguaceroCore extends EventEmitter {
330
697
  }
331
698
 
332
699
  async setMRMSVariable(variable) {
333
- const sortedTimestamps = [...(this.mrmsStatus[variable] || [])].sort((a, b) => b - a);
334
- const initialTimestamp = sortedTimestamps.length > 0 ? sortedTimestamps[0] : null;
700
+ const sortedTimestamps = this._getFilteredMrmsTimestampsForVariable(variable);
701
+ const initialTimestamp =
702
+ sortedTimestamps.length > 0 ? sortedTimestamps[sortedTimestamps.length - 1] : null;
335
703
 
336
704
  await this.setState({
337
705
  variable,
338
706
  isMRMS: true,
707
+ isSatellite: false,
708
+ satelliteInstrumentId: null,
709
+ satelliteSectorLabel: null,
710
+ satelliteChannel: null,
711
+ satelliteTimestamp: null,
339
712
  mrmsTimestamp: initialTimestamp,
340
713
  });
341
714
  }
@@ -345,33 +718,175 @@ export class AguaceroCore extends EventEmitter {
345
718
  await this.setState({ mrmsTimestamp: timestamp });
346
719
  }
347
720
 
721
+ async setSatelliteTimestamp(timestamp) {
722
+ if (!this.state.isSatellite) return;
723
+ await this.setState({ satelliteTimestamp: timestamp != null ? Number(timestamp) : null });
724
+ }
725
+
726
+ /**
727
+ * How many hours of satellite frames to include in the timeline (positive, at most 12 hours).
728
+ * API default: `layerOptions.satelliteDurationValue` on construction.
729
+ */
730
+ async setSatelliteDurationValue(value) {
731
+ const v = formatTimelineDurationValue(value);
732
+ await this.setState({ satelliteDurationValue: v });
733
+ if (!this.state.isSatellite || !this.state.satelliteInstrumentId) return;
734
+ const timeline = this._computeSatelliteTimeline();
735
+ const tsList = [...(timeline.unixTimes || [])]
736
+ .map((t) => Number(t))
737
+ .filter((t) => !Number.isNaN(t))
738
+ .sort((a, b) => a - b);
739
+ if (tsList.length === 0) return;
740
+ const cur = this.state.satelliteTimestamp;
741
+ const curN = cur == null ? null : Number(cur);
742
+ if (curN == null || !tsList.includes(curN)) {
743
+ await this.setState({ satelliteTimestamp: tsList[tsList.length - 1] });
744
+ }
745
+ }
746
+
747
+ /**
748
+ * Set satellite view using spacecraft id, sector, and channel/product.
749
+ * Omitted fields keep the current selection; when not in satellite mode, missing fields use GOES-19 East CONUS / C13 defaults.
750
+ * @param {{ satelliteId?: string, sector?: string, satelliteSector?: string, satelliteProduct?: string, satelliteChannel?: string, satelliteTimestamp?: number|null }} opts
751
+ */
752
+ async setSatelliteSelection(opts = {}) {
753
+ const cur = this.state.isSatellite ? this.state : null;
754
+ const tsArg =
755
+ opts.satelliteTimestamp !== undefined
756
+ ? opts.satelliteTimestamp
757
+ : this.state.isSatellite && this.state.satelliteTimestamp != null
758
+ ? Number(this.state.satelliteTimestamp)
759
+ : undefined;
760
+ return this.switchMode({
761
+ mode: 'satellite',
762
+ satelliteId: opts.satelliteId ?? cur?.satelliteInstrumentId ?? 'GOES19-EAST',
763
+ satelliteSector: opts.satelliteSector ?? opts.sector ?? cur?.satelliteSectorLabel ?? 'conus',
764
+ satelliteProduct:
765
+ opts.satelliteProduct ?? opts.satelliteChannel ?? cur?.satelliteChannel ?? 'C13',
766
+ satelliteTimestamp: tsArg,
767
+ });
768
+ }
769
+
770
+ /**
771
+ * How many hours of MRMS frames to include in the timeline (positive, at most 12 hours).
772
+ * API default: `layerOptions.mrmsDurationValue` on construction.
773
+ */
774
+ async setMRMSDurationValue(value) {
775
+ const v = formatTimelineDurationValue(value);
776
+ await this.setState({ mrmsDurationValue: v });
777
+ if (!this.state.isMRMS || !this.state.variable) return;
778
+ const filtered = this._getFilteredMrmsTimestampsForVariable(this.state.variable);
779
+ if (filtered.length === 0) return;
780
+ const cur = this.state.mrmsTimestamp;
781
+ const curN = cur == null ? null : Number(cur);
782
+ if (curN == null || !filtered.includes(curN)) {
783
+ await this.setState({ mrmsTimestamp: filtered[filtered.length - 1] });
784
+ }
785
+ }
786
+
787
+ /**
788
+ * NEXRAD sweep listing / scrub window in hours (independent of MRMS duration).
789
+ * API default: `layerOptions.nexradDurationValue` on construction.
790
+ */
791
+ async setNexradDurationValue(value) {
792
+ const v = formatTimelineDurationValue(value);
793
+ await this.setState({ nexradDurationValue: v });
794
+ if (!this.state.isNexrad || !this.state.nexradSite) return;
795
+ await this.refreshNexradTimes();
796
+ const nk = this._nexradTimesCacheKey();
797
+ const filtered = this._getFilteredNexradTimestampsForVariable(
798
+ nk ? this.nexradTimesByStation[nk]?.unixTimes || [] : [],
799
+ );
800
+ if (filtered.length === 0) return;
801
+ const cur = this.state.nexradTimestamp;
802
+ const curN = cur == null ? null : Number(cur);
803
+ if (curN == null || !filtered.includes(curN)) {
804
+ await this.setState({ nexradTimestamp: filtered[filtered.length - 1] });
805
+ }
806
+ }
807
+
348
808
  async switchMode(options) {
349
- const { mode, variable, model, forecastHour, mrmsTimestamp } = options;
350
- if (!mode || !variable) {
351
- console.error("switchMode requires 'mode' ('mrms' | 'model') and 'variable' properties.");
809
+ let { mode, variable, model, forecastHour, mrmsTimestamp, satelliteTimestamp } = options;
810
+ if (!mode) {
352
811
  return;
353
812
  }
354
813
  if (mode === 'model' && !model) {
355
- console.error("switchMode with mode 'model' requires a 'model' property.");
814
+ return;
815
+ }
816
+ if ((mode === 'mrms' || mode === 'model') && !variable) {
356
817
  return;
357
818
  }
358
819
  let targetState = {};
359
- if (mode === 'mrms') {
820
+ if (mode === 'satellite') {
821
+ const satelliteInstrumentId = options.satelliteId ?? 'GOES19-EAST';
822
+ const satelliteSectorLabel = resolveSatelliteSectorLabel(
823
+ options.satelliteSector ?? options.sector ?? 'conus',
824
+ );
825
+ const satelliteChannel =
826
+ options.satelliteProduct ?? options.satelliteChannel ?? variable ?? 'C13';
827
+ const channelToken = satelliteChannel;
828
+ // Emit satellite mode immediately so map layers (e.g. model grid) clear before listing fetch finishes.
829
+ await this.setState({
830
+ isSatellite: true,
831
+ isMRMS: false,
832
+ isNexrad: false,
833
+ satelliteInstrumentId,
834
+ satelliteSectorLabel,
835
+ satelliteChannel,
836
+ variable: channelToken,
837
+ satelliteTimestamp: null,
838
+ mrmsTimestamp: null,
839
+ date: null,
840
+ run: null,
841
+ forecastHour: 0,
842
+ });
843
+
844
+ await this.fetchSatelliteListing(true);
845
+ const allFiles = this.satelliteListing?.objects?.map((o) => o.key) || [];
846
+ const sectorName = satelliteSectorLabel;
847
+ const tier = this.state.satelliteTier || 'basic';
848
+ const durationOpt = resolveSatelliteDurationOption(sectorName, tier, this.state.satelliteDurationValue);
849
+ const timeline = buildSatelliteTimelineForSelection(
850
+ { satelliteInstrumentId, satelliteSectorLabel, satelliteChannel },
851
+ allFiles,
852
+ durationOpt,
853
+ );
854
+ const tsList = [...(timeline.unixTimes || [])].sort((a, b) => a - b);
855
+ let finalTs = satelliteTimestamp !== undefined ? satelliteTimestamp : null;
856
+ if (finalTs == null && tsList.length > 0) {
857
+ finalTs = tsList[tsList.length - 1];
858
+ }
859
+ await this.setState({
860
+ satelliteTimestamp: finalTs != null ? Number(finalTs) : null,
861
+ });
862
+ return;
863
+ } else if (mode === 'mrms') {
864
+ const sortedTimestamps = this._getFilteredMrmsTimestampsForVariable(variable);
360
865
  let finalTimestamp = mrmsTimestamp;
361
866
  if (finalTimestamp === undefined) {
362
- const sortedTimestamps = [...(this.mrmsStatus[variable] || [])].sort((a, b) => b - a);
363
- finalTimestamp = sortedTimestamps.length > 0 ? sortedTimestamps[0] : null;
867
+ finalTimestamp =
868
+ sortedTimestamps.length > 0 ? sortedTimestamps[sortedTimestamps.length - 1] : null;
869
+ } else if (sortedTimestamps.length > 0) {
870
+ const n = Number(finalTimestamp);
871
+ if (!sortedTimestamps.includes(n)) {
872
+ finalTimestamp = sortedTimestamps[sortedTimestamps.length - 1];
873
+ }
364
874
  }
365
875
  targetState = {
366
876
  isMRMS: true,
877
+ isSatellite: false,
878
+ isNexrad: false,
367
879
  variable: variable,
368
880
  mrmsTimestamp: finalTimestamp,
369
881
  model: this.state.model, date: null, run: null, forecastHour: 0,
882
+ satelliteInstrumentId: null,
883
+ satelliteSectorLabel: null,
884
+ satelliteChannel: null,
885
+ satelliteTimestamp: null,
370
886
  };
371
887
  } else if (mode === 'model') {
372
888
  const latestRun = findLatestModelRun(this.modelStatus, model);
373
889
  if (!latestRun) {
374
- console.error(`Could not find a valid run for model: ${model}`);
375
890
  return;
376
891
  }
377
892
 
@@ -380,50 +895,329 @@ export class AguaceroCore extends EventEmitter {
380
895
 
381
896
  // If switching to HRRR with ptypeRefl and hour is 0, use hour 1
382
897
  if (model === 'hrrr' && variable === 'ptypeRefl' && initialHour === 0) {
383
- const availableHours = this.modelStatus?.[model]?.[latestRun.date]?.[latestRun.run] || [];
898
+ const availableHours = resolveModelRunHours(
899
+ this.modelStatus,
900
+ model,
901
+ latestRun.date,
902
+ latestRun.run
903
+ ).hours || [];
384
904
  initialHour = availableHours.find(hour => hour !== 0) || 0;
385
905
  }
386
906
  // --- END NEW CODE ---
387
907
 
388
908
  targetState = {
389
909
  isMRMS: false,
910
+ isSatellite: false,
911
+ isNexrad: false,
390
912
  model: model,
391
913
  variable: variable,
392
914
  date: latestRun.date,
393
915
  run: latestRun.run,
394
916
  forecastHour: initialHour, // <-- Changed
395
917
  mrmsTimestamp: null,
918
+ satelliteInstrumentId: null,
919
+ satelliteSectorLabel: null,
920
+ satelliteChannel: null,
921
+ satelliteTimestamp: null,
396
922
  };
923
+ } else if (mode === 'nexrad') {
924
+ const nexradProduct = options.nexradProduct || 'REF';
925
+ const nexradDataSource =
926
+ options.nexradDataSource != null
927
+ ? options.nexradDataSource === 'level3'
928
+ ? 'level3'
929
+ : 'level2'
930
+ : inferNexradDataSourceForProduct(nexradProduct);
931
+ const fld = nexradColormapFldKey(nexradDataSource, nexradProduct);
932
+ const site = options.nexradSite ?? null;
933
+ let tilt =
934
+ options.nexradTilt != null
935
+ ? Number(options.nexradTilt)
936
+ : site != null
937
+ ? getDefaultRadarTilt(site)
938
+ : null;
939
+ await this.setState({
940
+ isNexrad: true,
941
+ isMRMS: false,
942
+ isSatellite: false,
943
+ variable: fld,
944
+ nexradSite: site,
945
+ nexradDataSource,
946
+ nexradProduct,
947
+ nexradTilt: tilt,
948
+ nexradTimestamp: options.nexradTimestamp != null ? Number(options.nexradTimestamp) : null,
949
+ nexradStormRelative: options.nexradStormRelative === true,
950
+ nexradShowSitesPicker: options.nexradShowSitesPicker !== false,
951
+ ...(options.nexradDurationValue != null
952
+ ? { nexradDurationValue: formatTimelineDurationValue(options.nexradDurationValue) }
953
+ : {}),
954
+ mrmsTimestamp: null,
955
+ satelliteInstrumentId: null,
956
+ satelliteSectorLabel: null,
957
+ satelliteChannel: null,
958
+ satelliteTimestamp: null,
959
+ date: null,
960
+ run: null,
961
+ forecastHour: 0,
962
+ });
963
+ const manifest = await fetchRadarTiltsManifestFromNetwork();
964
+ if (manifest) setRadarTiltsManifest(manifest);
965
+ if (site) {
966
+ await this.refreshNexradTimes();
967
+ const filtered = this._getFilteredNexradTimestampsForVariable(
968
+ this.nexradTimesByStation[this._nexradTimesCacheKey()]?.unixTimes || [],
969
+ );
970
+ let ts =
971
+ options.nexradTimestamp != null
972
+ ? Number(options.nexradTimestamp)
973
+ : this.state.nexradTimestamp;
974
+ if (ts == null && filtered.length > 0) ts = filtered[filtered.length - 1];
975
+ else if (ts != null && filtered.length > 0 && !filtered.includes(ts)) {
976
+ ts = filtered[filtered.length - 1];
977
+ }
978
+ await this.setState({ nexradTimestamp: ts });
979
+ }
980
+ return;
397
981
  } else {
398
- console.error(`Invalid mode specified in switchMode: '${mode}'`);
399
982
  return;
400
983
  }
401
984
  await this.setState(targetState);
402
985
  }
403
986
 
404
- // --- Data and Calculation Methods ---
987
+ /**
988
+ * Keep `nexradTilt` on an angle returned by {@link getAvailableNexradTilts} (matches aguacero-frontend elevation buttons).
989
+ */
990
+ async _snapNexradTiltToAvailableOptions() {
991
+ const s = this.state;
992
+ if (!s.isNexrad || !s.nexradSite) return;
993
+ const tilts = getAvailableNexradTilts(
994
+ s.nexradSite,
995
+ s.nexradDataSource || 'level2',
996
+ s.nexradProduct || 'REF',
997
+ );
998
+ if (!tilts.length) return;
999
+ const t = s.nexradTilt;
1000
+ const match = (a, b) => Math.abs(Number(a) - Number(b)) < 1e-4;
1001
+ if (t != null && tilts.some((x) => match(x, t))) return;
1002
+ const target = t != null && Number.isFinite(Number(t)) ? Number(t) : getDefaultRadarTilt(s.nexradSite);
1003
+ let best = tilts[0];
1004
+ for (const x of tilts) {
1005
+ if (Math.abs(x - target) < Math.abs(best - target)) best = x;
1006
+ }
1007
+ await this.setState({ nexradTilt: best });
1008
+ }
405
1009
 
406
- _reconstructData(decompressedDeltas, encoding) {
407
- const expectedLength = encoding.length;
408
- const reconstructedData = new Int8Array(expectedLength);
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
+ this._emitStateChange();
1048
+ }
409
1049
 
410
- if (decompressedDeltas.length > 0 && expectedLength > 0) {
411
- // First value is absolute
412
- reconstructedData[0] = decompressedDeltas[0] > 127 ? decompressedDeltas[0] - 256 : decompressedDeltas[0];
1050
+ async setNexradSite(siteId) {
1051
+ if (!this.state.isNexrad) return;
1052
+ const tilt = siteId ? getDefaultRadarTilt(siteId) : null;
1053
+ await this.setState({
1054
+ nexradSite: siteId || null,
1055
+ nexradTilt: tilt,
1056
+ nexradTimestamp: null,
1057
+ });
1058
+ await this.refreshNexradTimes();
1059
+ const filtered = this._getFilteredNexradTimestampsForVariable(
1060
+ this.nexradTimesByStation[this._nexradTimesCacheKey()]?.unixTimes || [],
1061
+ );
1062
+ if (filtered.length > 0) {
1063
+ await this.setState({ nexradTimestamp: filtered[filtered.length - 1] });
1064
+ }
1065
+ }
413
1066
 
414
- // Subsequent values are deltas from the previous one
415
- for (let i = 1; i < expectedLength; i++) {
416
- const delta = decompressedDeltas[i] > 127 ? decompressedDeltas[i] - 256 : decompressedDeltas[i];
417
- reconstructedData[i] = reconstructedData[i - 1] + delta;
418
- }
1067
+ async setNexradProduct(product) {
1068
+ if (!this.state.isNexrad) return;
1069
+ const p = (product || 'REF').toUpperCase();
1070
+ const ds = inferNexradDataSourceForProduct(p);
1071
+ const fld = nexradColormapFldKey(ds, p);
1072
+ const nexradStormRelative = this._nexradStormRelativeFor(ds, p);
1073
+ await this.setState({ nexradProduct: p, nexradDataSource: ds, variable: fld, nexradStormRelative });
1074
+ await this.refreshNexradTimes();
1075
+ const filtered = this._getFilteredNexradTimestampsForVariable(
1076
+ this.nexradTimesByStation[this._nexradTimesCacheKey()]?.unixTimes || [],
1077
+ );
1078
+ if (filtered.length > 0) {
1079
+ await this.setState({ nexradTimestamp: filtered[filtered.length - 1] });
1080
+ }
1081
+ }
1082
+
1083
+ async setNexradDataSource(source) {
1084
+ if (!this.state.isNexrad) return;
1085
+ const ds = source === 'level3' ? 'level3' : 'level2';
1086
+ const p = (this.state.nexradProduct || 'REF').toUpperCase();
1087
+ const fld = nexradColormapFldKey(ds, p);
1088
+ const nexradStormRelative = this._nexradStormRelativeFor(ds, p);
1089
+ await this.setState({ nexradDataSource: ds, variable: fld, nexradStormRelative });
1090
+ await this.refreshNexradTimes();
1091
+ const filtered = this._getFilteredNexradTimestampsForVariable(
1092
+ this.nexradTimesByStation[this._nexradTimesCacheKey()]?.unixTimes || [],
1093
+ );
1094
+ if (filtered.length > 0) {
1095
+ await this.setState({ nexradTimestamp: filtered[filtered.length - 1] });
1096
+ }
1097
+ }
1098
+
1099
+ /**
1100
+ * Sets NEXRAD product and archive source together (single refresh). Prefer {@link setNexradProduct}
1101
+ * for product-only changes (Level II vs III is inferred). Use this when you must force a specific
1102
+ * archive source (rare).
1103
+ * @param {'level2'|'level3'} dataSource
1104
+ * @param {string} product - Level-II variable (REF, VEL, …) or Level-III radar key (N0H, HHC, …)
1105
+ */
1106
+ async setNexradProductMode(dataSource, product) {
1107
+ if (!this.state.isNexrad) return;
1108
+ const ds = dataSource === 'level3' ? 'level3' : 'level2';
1109
+ const p = (product || 'REF').toUpperCase();
1110
+ const fld = nexradColormapFldKey(ds, p);
1111
+ const nexradStormRelative = this._nexradStormRelativeFor(ds, p);
1112
+ await this.setState({ nexradDataSource: ds, nexradProduct: p, variable: fld, nexradStormRelative });
1113
+ await this.refreshNexradTimes();
1114
+ const filtered = this._getFilteredNexradTimestampsForVariable(
1115
+ this.nexradTimesByStation[this._nexradTimesCacheKey()]?.unixTimes || [],
1116
+ );
1117
+ if (filtered.length > 0) {
1118
+ await this.setState({ nexradTimestamp: filtered[filtered.length - 1] });
1119
+ }
1120
+ }
1121
+
1122
+ async setNexradTilt(tilt) {
1123
+ if (!this.state.isNexrad || !this.state.nexradSite) return;
1124
+ await this.setState({ nexradTilt: tilt != null ? Number(tilt) : null });
1125
+ await this.refreshNexradTimes();
1126
+ }
1127
+
1128
+ async setNexradTimestamp(ts) {
1129
+ if (!this.state.isNexrad) return;
1130
+ await this.setState({ nexradTimestamp: ts != null ? Number(ts) : null });
1131
+ }
1132
+
1133
+ // --- Data and Calculation Methods ---
1134
+
1135
+ _ensureGridDecodeWorker() {
1136
+ if (this._gridDecodeWorkerDisabled) {
1137
+ return null;
1138
+ }
1139
+ if (this._gridDecodeWorker) {
1140
+ return this._gridDecodeWorker;
1141
+ }
1142
+ if (typeof Worker === 'undefined') {
1143
+ this._gridDecodeWorkerDisabled = true;
1144
+ return null;
1145
+ }
1146
+ try {
1147
+ this._gridDecodeWorker = new Worker(new URL('./gridDecodeWorker.js', import.meta.url), {
1148
+ type: 'module',
1149
+ });
1150
+ this._gridDecodeWorker.addEventListener('error', () => {
1151
+ this._gridDecodeWorkerDisabled = true;
1152
+ if (this._gridDecodeWorker) {
1153
+ this._gridDecodeWorker.terminate();
1154
+ this._gridDecodeWorker = null;
1155
+ }
1156
+ });
1157
+ } catch {
1158
+ this._gridDecodeWorkerDisabled = true;
1159
+ return null;
419
1160
  }
420
- // Return as a Uint8Array, which is what the rest of the code expects
421
- return new Uint8Array(reconstructedData.buffer);
1161
+ return this._gridDecodeWorker;
1162
+ }
1163
+
1164
+ /**
1165
+ * Offloads zstd + delta decode + transform to a Worker when available; falls back to main-thread
1166
+ * {@link processCompressedGrid}. Uses a copy for postMessage transfer so the original `compressedData`
1167
+ * stays valid if the Worker path fails.
1168
+ */
1169
+ _decodeGridPayload(compressedData, encoding) {
1170
+ const worker = this._ensureGridDecodeWorker();
1171
+ if (!worker) {
1172
+ return Promise.resolve(processCompressedGrid(compressedData, encoding));
1173
+ }
1174
+ const payload = compressedData.slice();
1175
+ return new Promise((resolve, reject) => {
1176
+ const id = ++this._gridDecodeMsgId;
1177
+ const onMsg = (e) => {
1178
+ if (!e.data || e.data.id !== id) {
1179
+ return;
1180
+ }
1181
+ worker.removeEventListener('message', onMsg);
1182
+ worker.removeEventListener('error', onErr);
1183
+ if (e.data.error) {
1184
+ reject(new Error(e.data.error));
1185
+ return;
1186
+ }
1187
+ const data = new Uint8Array(e.data.dataBuffer, e.data.dataByteOffset, e.data.dataByteLength);
1188
+ resolve({ data, encoding: e.data.encoding });
1189
+ };
1190
+ const onErr = (err) => {
1191
+ worker.removeEventListener('message', onMsg);
1192
+ worker.removeEventListener('error', onErr);
1193
+ reject(err);
1194
+ };
1195
+ worker.addEventListener('message', onMsg);
1196
+ worker.addEventListener('error', onErr);
1197
+ try {
1198
+ worker.postMessage(
1199
+ {
1200
+ id,
1201
+ encoding,
1202
+ compressedBuffer: payload.buffer,
1203
+ compressedByteOffset: payload.byteOffset,
1204
+ compressedByteLength: payload.byteLength,
1205
+ },
1206
+ [payload.buffer]
1207
+ );
1208
+ } catch (err) {
1209
+ worker.removeEventListener('message', onMsg);
1210
+ worker.removeEventListener('error', onErr);
1211
+ reject(err);
1212
+ }
1213
+ });
422
1214
  }
423
1215
 
424
1216
  async _loadGridData(state) {
425
1217
  if (this.isReactNative) {
426
- console.warn(`[AguaceroCore] _loadGridData was called in React Native. This is a bypass. Data loading is handled natively.`);
1218
+ return null;
1219
+ }
1220
+ if (state.isNexrad) {
427
1221
  return null;
428
1222
  }
429
1223
  const { model, date, run, forecastHour, variable, isMRMS, mrmsTimestamp } = state;
@@ -447,18 +1241,7 @@ export class AguaceroCore extends EventEmitter {
447
1241
  if (this.dataCache.has(dataUrlIdentifier)) {
448
1242
  return this.dataCache.get(dataUrlIdentifier);
449
1243
  }
450
-
451
- // --- EDITED ---
452
- // If we are in React Native, this function should NOT do any work.
453
- // The native WeatherFrameProcessorModule is now responsible for all data loading.
454
- // This function might still be called by a "cache miss" fallback, but it
455
- // should not fetch data from JS anymore. We return null so the fallback knows
456
- // that the native module is the only source of truth for new data.
457
- if (this.isReactNative) {
458
- console.warn(`_loadGridData was called in React Native for ${dataUrlIdentifier}. This should be handled by the native module. Returning null.`);
459
- return null;
460
- }
461
-
1244
+
462
1245
  const abortController = new AbortController();
463
1246
  this.abortControllers.set(dataUrlIdentifier, abortController);
464
1247
 
@@ -485,31 +1268,18 @@ export class AguaceroCore extends EventEmitter {
485
1268
  const { data: b64Data, encoding } = await response.json();
486
1269
  const compressedData = Uint8Array.from(atob(b64Data), c => c.charCodeAt(0));
487
1270
 
488
- // This path is now ONLY for the web worker
489
- const requestId = this.workerRequestId++;
490
- const workerPromise = new Promise((resolve, reject) => {
491
- this.workerResolvers.set(requestId, { resolve, reject });
492
- });
493
- this.worker.postMessage({ requestId, compressedData, encoding }, [compressedData.buffer]);
494
- const result = await workerPromise;
495
- const finalData = result.data;
496
-
497
- const transformedData = new Uint8Array(finalData.length);
498
- for (let i = 0; i < finalData.length; i++) {
499
- const signedValue = finalData[i] > 127 ? finalData[i] - 256 : finalData[i];
500
- transformedData[i] = signedValue + 128;
1271
+ let gridPayload;
1272
+ try {
1273
+ gridPayload = await this._decodeGridPayload(compressedData, encoding);
1274
+ } catch {
1275
+ gridPayload = processCompressedGrid(compressedData, encoding);
501
1276
  }
502
1277
 
503
1278
  this.abortControllers.delete(dataUrlIdentifier);
504
1279
 
505
- return { data: transformedData, encoding };
1280
+ return { data: gridPayload.data, encoding: gridPayload.encoding };
506
1281
 
507
1282
  } catch (error) {
508
- if (error.name === 'AbortError') {
509
- console.log(`Request cancelled for ${resourcePath}`);
510
- } else {
511
- console.error(`Failed to load data for path ${resourcePath}:`, error);
512
- }
513
1283
  this.dataCache.delete(dataUrlIdentifier);
514
1284
  this.abortControllers.delete(dataUrlIdentifier);
515
1285
  return null;
@@ -528,11 +1298,12 @@ export class AguaceroCore extends EventEmitter {
528
1298
  // Clear both maps
529
1299
  this.abortControllers.clear();
530
1300
  this.dataCache.clear();
531
-
532
- console.log('All pending requests cancelled');
533
1301
  }
534
1302
 
535
1303
  async getValueAtLngLat(lng, lat) {
1304
+ if (this.state.isSatellite || this.state.isNexrad) {
1305
+ return null;
1306
+ }
536
1307
  const { variable, isMRMS, mrmsTimestamp, model, date, run, forecastHour, units } = this.state;
537
1308
  if (!variable) return null;
538
1309
 
@@ -814,74 +1585,11 @@ export class AguaceroCore extends EventEmitter {
814
1585
  const y = t_y * (ny - 1);
815
1586
  return { x, y };
816
1587
  } catch (error) {
817
- console.warn(`[GridAccessor] RGEM polar stereographic conversion failed for ${lat}, ${lon}:`, error);
818
1588
  return { x: -1, y: -1 };
819
1589
  }
820
1590
  }
821
1591
 
822
- // --- Worker and Status Methods ---
823
-
824
- createWorker() {
825
- if (this.isReactNative) return null;
826
-
827
- const workerCode = `
828
- import { decompress } from 'https://cdn.skypack.dev/fzstd@0.1.1';
829
-
830
- function _reconstructData(decompressedDeltas, encoding) {
831
- const expectedLength = encoding.length;
832
- const reconstructedData = new Int8Array(expectedLength);
833
- if (decompressedDeltas.length > 0 && expectedLength > 0) {
834
- reconstructedData[0] = decompressedDeltas[0] > 127 ? decompressedDeltas[0] - 256 : decompressedDeltas[0];
835
- for (let i = 1; i < expectedLength; i++) {
836
- const delta = decompressedDeltas[i] > 127 ? decompressedDeltas[i] - 256 : decompressedDeltas[i];
837
- reconstructedData[i] = reconstructedData[i - 1] + delta;
838
- }
839
- }
840
- return new Uint8Array(reconstructedData.buffer);
841
- }
842
-
843
- self.onmessage = async (e) => {
844
- const { requestId, compressedData, encoding } = e.data;
845
- try {
846
- const decompressedDeltas = await decompress(compressedData);
847
- const finalData = _reconstructData(decompressedDeltas, encoding);
848
- self.postMessage({ success: true, requestId: requestId, decompressedData: finalData, encoding: encoding }, [finalData.buffer]);
849
- } catch (error) {
850
- self.postMessage({ success: false, requestId: requestId, error: error.message });
851
- }
852
- };
853
- `;
854
- const blob = new Blob([workerCode], { type: 'application/javascript' });
855
- return new Worker(URL.createObjectURL(blob), { type: 'module' });
856
- }
857
-
858
- _processResultQueue() {
859
- while (this.resultQueue.length > 0) {
860
- const { success, requestId, decompressedData, encoding, error } = this.resultQueue.shift();
861
- if (this.workerResolvers.has(requestId)) {
862
- const { resolve, reject } = this.workerResolvers.get(requestId);
863
- if (success) {
864
- resolve({ data: decompressedData }); // Return as { data: ... }
865
- } else {
866
- reject(new Error(error));
867
- }
868
- this.workerResolvers.delete(requestId);
869
- }
870
- }
871
- this.isProcessingQueue = false;
872
- }
873
-
874
- _handleWorkerMessage(e) {
875
- if (this.isReactNative) return;
876
-
877
- const { success, requestId, decompressedData, encoding, error } = e.data;
878
-
879
- this.resultQueue.push({ success, requestId, decompressedData, encoding, error });
880
- if (!this.isProcessingQueue) {
881
- this.isProcessingQueue = true;
882
- requestAnimationFrame(() => this._processResultQueue());
883
- }
884
- }
1592
+ // --- Status Methods ---
885
1593
 
886
1594
  async fetchModelStatus(force = false) {
887
1595
  if (!this.modelStatus || force) {
@@ -906,6 +1614,19 @@ export class AguaceroCore extends EventEmitter {
906
1614
  return this.mrmsStatus;
907
1615
  }
908
1616
 
1617
+ async fetchSatelliteListing(force = false) {
1618
+ if (!this.satelliteListing || force) {
1619
+ try {
1620
+ const response = await fetch(SATELLITE_FRAMES_URL);
1621
+ if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`);
1622
+ this.satelliteListing = await response.json();
1623
+ } catch (error) {
1624
+ this.satelliteListing = null;
1625
+ }
1626
+ }
1627
+ return this.satelliteListing;
1628
+ }
1629
+
909
1630
  startAutoRefresh(intervalSeconds) {
910
1631
  this.stopAutoRefresh();
911
1632
  this.autoRefreshIntervalId = setInterval(async () => {