@aguacerowx/javascript-sdk 0.0.18 → 0.0.20

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,31 @@ 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
+ buildSatelliteTimelineForKey,
15
+ SATELLITE_DURATION_CONFIG,
16
+ TIMELINE_DURATION_HOUR_VALUES,
17
+ normalizeTimelineDurationValue,
18
+ parseTimelineDurationHours,
19
+ } from './satellite_support.js';
20
+ import {
21
+ fetchNexradTimesListing,
22
+ nexradColormapFldKey,
23
+ variableToNexradGroup,
24
+ getAvailableNexradTilts,
25
+ } from './nexrad_support.js';
26
+ import { NEXRAD_LEVEL3_ELEV, getNexradLevel3EntryByRadarKey } from './nexrad_level3_catalog.js';
27
+ import {
28
+ setRadarTiltsManifest,
29
+ fetchRadarTiltsManifestFromNetwork,
30
+ getDefaultRadarTilt,
31
+ formatTiltForApi,
32
+ clampNexradTiltForVariable,
33
+ getRadarTilts,
34
+ } from './nexradTilts.js';
11
35
 
12
36
  // --- Non-UI Helper Functions ---
13
37
  function hrdpsObliqueTransform(rotated_lon, rotated_lat) {
@@ -41,26 +65,67 @@ function findLatestModelRun(modelsData, modelName) {
41
65
  return null;
42
66
  }
43
67
 
68
+ /**
69
+ * model-status JSON uses string keys for runs (often zero-padded: "00", "06").
70
+ * Direct lookup modelStatus[model][date][run] fails if state.run is "6" but the key is "06".
71
+ * Returns the hour list and the run key that matched.
72
+ */
73
+ function resolveModelRunHours(modelStatus, model, date, run) {
74
+ const runs = modelStatus?.[model]?.[date];
75
+ if (!runs || run == null || run === '') {
76
+ return {
77
+ hours: [],
78
+ matchedRunKey: null,
79
+ availableRunKeys: runs ? Object.keys(runs) : [],
80
+ };
81
+ }
82
+ const runStr = String(run);
83
+ const candidates = new Set([runStr]);
84
+ const n = parseInt(runStr, 10);
85
+ if (!Number.isNaN(n)) {
86
+ candidates.add(String(n));
87
+ candidates.add(String(n).padStart(2, '0'));
88
+ candidates.add(String(n).padStart(3, '0'));
89
+ }
90
+ for (const key of candidates) {
91
+ const h = runs[key];
92
+ if (h && Array.isArray(h) && h.length > 0) {
93
+ return { hours: h, matchedRunKey: key, availableRunKeys: Object.keys(runs) };
94
+ }
95
+ }
96
+ if (!Number.isNaN(n)) {
97
+ for (const k of Object.keys(runs)) {
98
+ const kn = parseInt(k, 10);
99
+ if (!Number.isNaN(kn) && kn === n) {
100
+ const h = runs[k];
101
+ if (h && Array.isArray(h) && h.length > 0) {
102
+ return { hours: h, matchedRunKey: k, availableRunKeys: Object.keys(runs) };
103
+ }
104
+ }
105
+ }
106
+ }
107
+ return { hours: [], matchedRunKey: null, availableRunKeys: Object.keys(runs) };
108
+ }
109
+
44
110
  export class AguaceroCore extends EventEmitter {
45
111
  constructor(options = {}) {
46
112
  super();
47
113
  this.isReactNative = typeof navigator !== 'undefined' && navigator.product === 'ReactNative';
48
114
  this.apiKey = options.apiKey;
115
+ /** Passed as CloudFront `userId` for satellite KTX2 URLs (production uses the authenticated account id). */
116
+ this.userId = options.userId ?? 'sdk-user';
49
117
  this.bundleId = getBundleId();
50
118
  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
- }
119
+ /** @type {Worker | null} */
120
+ this._gridDecodeWorker = null;
121
+ /** When true, skip Worker and use {@link processCompressedGrid} on the main thread. */
122
+ this._gridDecodeWorkerDisabled = false;
123
+ this._gridDecodeMsgId = 0;
61
124
  this.statusUrl = 'https://d3dc62msmxkrd7.cloudfront.net/model-status';
62
125
  this.modelStatus = null;
63
126
  this.mrmsStatus = null;
127
+ /** @type {{ objects?: Array<{ key: string }> } | null} */
128
+ this.satelliteListing = null;
64
129
  this.dataCache = new Map();
65
130
  this.abortControllers = new Map();
66
131
  this.isPlaying = false;
@@ -73,31 +138,213 @@ export class AguaceroCore extends EventEmitter {
73
138
  const initialMode = userLayerOptions.mode || 'model';
74
139
  const initialVariable = userLayerOptions.variable || null;
75
140
 
141
+ const initialSatellite = initialMode === 'satellite';
142
+ const initialNexrad = initialMode === 'nexrad';
143
+ const initialNexradDs = userLayerOptions.nexradDataSource || 'level2';
144
+ const initialNexradProd = userLayerOptions.nexradProduct || 'REF';
145
+ const initialNexradFld = initialNexrad
146
+ ? nexradColormapFldKey(initialNexradDs, initialNexradProd)
147
+ : initialVariable;
76
148
  this.state = {
77
149
  model: userLayerOptions.model || 'gfs',
78
150
  // EDIT: Set isMRMS based on the initial mode
79
- isMRMS: initialMode === 'mrms',
151
+ isMRMS: initialMode === 'mrms' && !initialSatellite && !initialNexrad,
80
152
  mrmsTimestamp: null,
81
- variable: initialVariable,
153
+ variable: initialNexrad ? initialNexradFld : initialVariable,
82
154
  date: null,
83
155
  run: null,
84
156
  forecastHour: 0,
85
157
  visible: true,
86
158
  opacity: userLayerOptions.opacity ?? 1,
87
159
  units: options.initialUnit || 'imperial',
88
- shaderSmoothingEnabled: options.shaderSmoothingEnabled ?? true
160
+ shaderSmoothingEnabled: options.shaderSmoothingEnabled ?? true,
161
+ isSatellite: initialSatellite,
162
+ satelliteKey: userLayerOptions.satelliteKey || null,
163
+ satelliteTimestamp: userLayerOptions.satelliteTimestamp != null ? Number(userLayerOptions.satelliteTimestamp) : null,
164
+ satelliteTier: userLayerOptions.satelliteTier || 'basic',
165
+ satelliteDurationValue: (() => {
166
+ const n = normalizeTimelineDurationValue(
167
+ userLayerOptions.satelliteDurationValue != null ? userLayerOptions.satelliteDurationValue : '1'
168
+ );
169
+ return TIMELINE_DURATION_HOUR_VALUES.includes(n) ? n : '1';
170
+ })(),
171
+ mrmsDurationValue: (() => {
172
+ const n = normalizeTimelineDurationValue(
173
+ userLayerOptions.mrmsDurationValue != null ? userLayerOptions.mrmsDurationValue : '1'
174
+ );
175
+ return TIMELINE_DURATION_HOUR_VALUES.includes(n) ? n : '1';
176
+ })(),
177
+ isNexrad: initialNexrad,
178
+ nexradSite: userLayerOptions.nexradSite ?? null,
179
+ nexradDataSource: initialNexradDs,
180
+ nexradProduct: initialNexradProd,
181
+ nexradTilt:
182
+ userLayerOptions.nexradTilt != null
183
+ ? Number(userLayerOptions.nexradTilt)
184
+ : userLayerOptions.nexradSite
185
+ ? getDefaultRadarTilt(userLayerOptions.nexradSite)
186
+ : null,
187
+ nexradTimestamp: userLayerOptions.nexradTimestamp != null ? Number(userLayerOptions.nexradTimestamp) : null,
188
+ nexradStormRelative: userLayerOptions.nexradStormRelative === true,
189
+ /** When true, mapsgl shows clickable NEXRAD site markers (independent of selected site). */
190
+ nexradShowSitesPicker: userLayerOptions.nexradShowSitesPicker !== false,
89
191
  };
90
192
 
91
193
  this.autoRefreshEnabled = options.autoRefresh ?? false;
92
194
  this.autoRefreshIntervalSeconds = options.autoRefreshInterval ?? 60;
93
195
  this.autoRefreshIntervalId = null;
196
+
197
+ /** @type {Record<string, { unixTimes?: number[]; timeToKeyMap?: Record<string, string>; listWindowHours?: number }>} */
198
+ this.nexradTimesByStation = {};
94
199
  }
95
200
 
96
201
  async setState(newState) {
97
- Object.assign(this.state, newState);
202
+ const patch = { ...newState };
203
+ if ('forecastHour' in patch && patch.forecastHour != null) {
204
+ patch.forecastHour = Number(patch.forecastHour);
205
+ }
206
+ if ('mrmsTimestamp' in patch && patch.mrmsTimestamp != null) {
207
+ patch.mrmsTimestamp = Number(patch.mrmsTimestamp);
208
+ }
209
+ if ('satelliteTimestamp' in patch && patch.satelliteTimestamp != null) {
210
+ patch.satelliteTimestamp = Number(patch.satelliteTimestamp);
211
+ }
212
+ if ('satelliteDurationValue' in patch && patch.satelliteDurationValue != null) {
213
+ const n = normalizeTimelineDurationValue(patch.satelliteDurationValue);
214
+ patch.satelliteDurationValue = TIMELINE_DURATION_HOUR_VALUES.includes(n) ? n : '1';
215
+ }
216
+ if ('mrmsDurationValue' in patch && patch.mrmsDurationValue != null) {
217
+ const n = normalizeTimelineDurationValue(patch.mrmsDurationValue);
218
+ patch.mrmsDurationValue = TIMELINE_DURATION_HOUR_VALUES.includes(n) ? n : '1';
219
+ }
220
+ if ('nexradTimestamp' in patch && patch.nexradTimestamp != null) {
221
+ patch.nexradTimestamp = Number(patch.nexradTimestamp);
222
+ }
223
+ if ('nexradTilt' in patch && patch.nexradTilt != null) {
224
+ patch.nexradTilt = Number(patch.nexradTilt);
225
+ }
226
+ Object.assign(this.state, patch);
98
227
  this._emitStateChange();
99
228
  }
100
229
 
230
+ /**
231
+ * Forecast hours for the current model/date/run (normalized numbers).
232
+ * Tolerates model-status run keys like "06" vs state.run "6" so preload and slider stay in sync.
233
+ */
234
+ getAvailableForecastHours() {
235
+ if (this.state.isMRMS || this.state.isSatellite || this.state.isNexrad) return [];
236
+ if (!this.state.model || this.state.date == null || this.state.run == null) return [];
237
+
238
+ const resolved = resolveModelRunHours(
239
+ this.modelStatus,
240
+ this.state.model,
241
+ this.state.date,
242
+ this.state.run
243
+ );
244
+ let hours = resolved.hours || [];
245
+
246
+ if (hours.length > 0) {
247
+ hours = hours.map(h => (typeof h === 'string' ? parseInt(h, 10) : Number(h))).filter(h => !Number.isNaN(h));
248
+ }
249
+ if (this.state.variable === 'ptypeRefl' && this.state.model === 'hrrr' && hours.length > 0) {
250
+ hours = hours.filter(hour => hour !== 0);
251
+ }
252
+ return hours;
253
+ }
254
+
255
+ _computeSatelliteTimeline() {
256
+ if (!this.state.satelliteKey || !this.satelliteListing?.objects) {
257
+ return { unixTimes: [], timeToFileMap: {} };
258
+ }
259
+ const allFiles = this.satelliteListing.objects.map((o) => o.key);
260
+ const sectorName = this.state.satelliteKey.split('.')[1] || 'GOES-EAST CONUS';
261
+ let sectorType = 'CONUS';
262
+ if (sectorName.includes('FULL DISK')) sectorType = 'FULL_DISK';
263
+ else if (sectorName.includes('MESOSCALE')) sectorType = 'MESOSCALE';
264
+ const tier = this.state.satelliteTier || 'basic';
265
+ const tierConfig =
266
+ SATELLITE_DURATION_CONFIG[sectorType]?.[tier] || SATELLITE_DURATION_CONFIG.CONUS.basic;
267
+ const durationKey = normalizeTimelineDurationValue(this.state.satelliteDurationValue);
268
+ const durationOpt =
269
+ tierConfig.find((o) => String(o.value) === String(durationKey)) ||
270
+ tierConfig.find((o) => o.value === '1') ||
271
+ tierConfig[0];
272
+ return buildSatelliteTimelineForKey(this.state.satelliteKey, allFiles, durationOpt);
273
+ }
274
+
275
+ /**
276
+ * MRMS timestamps for a variable, oldest first (matches satellite timelines and left→right time sliders).
277
+ * Limited to the last N hours relative to the newest frame (see mrmsDurationValue).
278
+ * @param {string} variable
279
+ * @returns {number[]}
280
+ */
281
+ _getFilteredMrmsTimestampsForVariable(variable) {
282
+ const raw = this.mrmsStatus?.[variable];
283
+ if (!raw || !raw.length) return [];
284
+ const hours = parseTimelineDurationHours(this.state.mrmsDurationValue);
285
+ let list = [...raw]
286
+ .map((t) => Number(t))
287
+ .filter((t) => !Number.isNaN(t))
288
+ .sort((a, b) => a - b);
289
+ if (hours > 0 && list.length > 0) {
290
+ const latest = list[list.length - 1];
291
+ const cutoff = latest - hours * 3600;
292
+ list = list.filter((t) => t >= cutoff);
293
+ }
294
+ return list;
295
+ }
296
+
297
+ _nexradListingWindowHours() {
298
+ return parseTimelineDurationHours(this.state.mrmsDurationValue);
299
+ }
300
+
301
+ _getFilteredNexradTimestampsForVariable(rawList) {
302
+ if (!rawList || !rawList.length) return [];
303
+ const hours = this._nexradListingWindowHours();
304
+ let list = [...rawList]
305
+ .map((t) => Number(t))
306
+ .filter((t) => !Number.isNaN(t))
307
+ .sort((a, b) => a - b);
308
+ if (hours > 0 && list.length > 0) {
309
+ const latest = list[list.length - 1];
310
+ const cutoff = latest - hours * 3600;
311
+ list = list.filter((t) => t >= cutoff);
312
+ }
313
+ return list;
314
+ }
315
+
316
+ /**
317
+ * Cache key for {@link this.nexradTimesByStation} — matches frontend composite keys (tilt + variable group/source).
318
+ */
319
+ _nexradTimesCacheKey() {
320
+ const s = this.state;
321
+ if (!s.isNexrad || !s.nexradSite) return null;
322
+ const site = s.nexradSite;
323
+ const variable = s.nexradProduct || 'REF';
324
+ const ds = s.nexradDataSource || 'level2';
325
+ const tiltNum = s.nexradTilt != null ? s.nexradTilt : getDefaultRadarTilt(site);
326
+ const elevNormUse =
327
+ ds === 'level3'
328
+ ? NEXRAD_LEVEL3_ELEV
329
+ : formatTiltForApi(clampNexradTiltForVariable(site, variable, tiltNum));
330
+ const group = ds === 'level3' ? 'l3' : variableToNexradGroup(variable);
331
+ const l3Product =
332
+ ds === 'level3'
333
+ ? getNexradLevel3EntryByRadarKey(variable)?.product ?? (variable === 'VEL' ? 'N0G' : variable)
334
+ : '';
335
+ if (ds === 'level3') {
336
+ return `${site}_l3_${l3Product}_${elevNormUse}`;
337
+ }
338
+ return `${site}_${group}_${elevNormUse}`;
339
+ }
340
+
341
+ /** Storm-relative Level-III velocity (N0G + N0S) — matches aguacero-frontend `level3StormRelative` for VEL. */
342
+ _nexradStormRelativeFor(nexradDataSource, nexradProduct) {
343
+ const ds = nexradDataSource === 'level3' ? 'level3' : 'level2';
344
+ const p = (nexradProduct || 'REF').toUpperCase();
345
+ return ds === 'level3' && p === 'VEL';
346
+ }
347
+
101
348
  _emitStateChange() {
102
349
  const { colormap, baseUnit } = this._getColormapForVariable(this.state.variable);
103
350
  const toUnit = this._getTargetUnit(baseUnit, this.state.units);
@@ -105,18 +352,40 @@ export class AguaceroCore extends EventEmitter {
105
352
 
106
353
  let availableTimestamps = [];
107
354
  if (this.state.isMRMS && this.state.variable && this.mrmsStatus) {
108
- const timestamps = this.mrmsStatus[this.state.variable] || [];
109
- availableTimestamps = [...timestamps].reverse();
355
+ availableTimestamps = this._getFilteredMrmsTimestampsForVariable(this.state.variable);
110
356
  }
111
357
 
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);
358
+ let availableSatelliteTimestamps = [];
359
+ let satelliteTimeToFileMap = {};
360
+ if (this.state.isSatellite && this.state.satelliteKey) {
361
+ const timeline = this._computeSatelliteTimeline();
362
+ satelliteTimeToFileMap = timeline.timeToFileMap || {};
363
+ availableSatelliteTimestamps = [...(timeline.unixTimes || [])]
364
+ .map((t) => Number(t))
365
+ .filter((t) => !Number.isNaN(t))
366
+ .sort((a, b) => a - b);
367
+ }
368
+
369
+ let availableNexradTimestamps = [];
370
+ let nexradTimeToKeyMap = {};
371
+ let nexradLevel3MotionTimeToKeyMap = {};
372
+ let availableNexradTilts = [];
373
+ if (this.state.isNexrad && this.state.nexradSite) {
374
+ const nk = this._nexradTimesCacheKey();
375
+ const ent = nk ? this.nexradTimesByStation[nk] : null;
376
+ const raw = ent?.unixTimes || [];
377
+ availableNexradTimestamps = this._getFilteredNexradTimestampsForVariable(raw);
378
+ nexradTimeToKeyMap = ent?.timeToKeyMap || {};
379
+ nexradLevel3MotionTimeToKeyMap = ent?.level3MotionTimeToKeyMap || {};
380
+ availableNexradTilts = getAvailableNexradTilts(
381
+ this.state.nexradSite,
382
+ this.state.nexradDataSource || 'level2',
383
+ this.state.nexradProduct || 'REF',
384
+ );
118
385
  }
119
386
 
387
+ const availableHours = this.getAvailableForecastHours();
388
+
120
389
  const eventPayload = {
121
390
  ...this.state,
122
391
  availableModels: this.modelStatus ? Object.keys(this.modelStatus).sort() : [],
@@ -126,6 +395,12 @@ export class AguaceroCore extends EventEmitter {
126
395
  // We need to confirm this line is working as expected.
127
396
  availableMRMSVariables: this.getAvailableVariables('mrms'),
128
397
  availableTimestamps: availableTimestamps,
398
+ availableSatelliteTimestamps,
399
+ satelliteTimeToFileMap,
400
+ availableNexradTimestamps,
401
+ nexradTimeToKeyMap,
402
+ nexradLevel3MotionTimeToKeyMap,
403
+ availableNexradTilts,
129
404
  isPlaying: this.isPlaying,
130
405
  colormap: displayColormap,
131
406
  colormapBaseUnit: toUnit,
@@ -137,27 +412,36 @@ export class AguaceroCore extends EventEmitter {
137
412
  async initialize(options = {}) {
138
413
  await this.fetchModelStatus(true);
139
414
  await this.fetchMRMSStatus(true);
140
-
415
+ await this.fetchSatelliteListing(true);
416
+
141
417
  let initialState = { ...this.state };
142
418
 
419
+ if (initialState.isSatellite && initialState.satelliteKey) {
420
+ const timeline = this._computeSatelliteTimeline();
421
+ const tsList = [...(timeline.unixTimes || [])].sort((a, b) => a - b);
422
+ if (initialState.satelliteTimestamp == null && tsList.length > 0) {
423
+ initialState.satelliteTimestamp = tsList[tsList.length - 1];
424
+ }
425
+ }
426
+
143
427
  // ADD: Logic to handle an initial MRMS state
144
428
  if (initialState.isMRMS) {
145
429
  const variable = initialState.variable;
146
430
  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;
431
+ const sortedTimestamps = this._getFilteredMrmsTimestampsForVariable(variable);
432
+ initialState.mrmsTimestamp =
433
+ sortedTimestamps.length > 0 ? sortedTimestamps[sortedTimestamps.length - 1] : null;
149
434
  } else {
150
- // Fallback if the provided variable is not valid
151
- console.warn(`Initial MRMS variable '${variable}' not found. Using default.`);
152
435
  const availableMRMSVars = this.getAvailableVariables('mrms');
153
436
  if (availableMRMSVars.length > 0) {
154
437
  const firstVar = availableMRMSVars[0];
155
438
  initialState.variable = firstVar;
156
- const sortedTimestamps = [...(this.mrmsStatus[firstVar] || [])].sort((a, b) => b - a);
157
- initialState.mrmsTimestamp = sortedTimestamps.length > 0 ? sortedTimestamps[0] : null;
439
+ const sortedTimestamps = this._getFilteredMrmsTimestampsForVariable(firstVar);
440
+ initialState.mrmsTimestamp =
441
+ sortedTimestamps.length > 0 ? sortedTimestamps[sortedTimestamps.length - 1] : null;
158
442
  }
159
443
  }
160
- } else {
444
+ } else if (!initialState.isSatellite && !initialState.isNexrad) {
161
445
  // EDIT: This is the existing logic, now in an else block
162
446
  const latestRun = findLatestModelRun(this.modelStatus, initialState.model);
163
447
  if (latestRun) {
@@ -172,6 +456,14 @@ export class AguaceroCore extends EventEmitter {
172
456
  }
173
457
 
174
458
  await this.setState(initialState);
459
+
460
+ if (this.state.isNexrad) {
461
+ const manifest = await fetchRadarTiltsManifestFromNetwork();
462
+ if (manifest) setRadarTiltsManifest(manifest);
463
+ if (this.state.nexradSite) {
464
+ await this.refreshNexradTimes();
465
+ }
466
+ }
175
467
  if (options.autoRefresh ?? this.autoRefreshEnabled) {
176
468
  this.startAutoRefresh(options.refreshInterval ?? this.autoRefreshIntervalSeconds);
177
469
  }
@@ -181,11 +473,11 @@ export class AguaceroCore extends EventEmitter {
181
473
  this.pause();
182
474
  this.stopAutoRefresh();
183
475
  this.dataCache.clear();
184
- if (this.worker) {
185
- this.worker.terminate();
186
- }
187
476
  this.callbacks = {};
188
- console.log(`AguaceroCore has been destroyed.`);
477
+ if (this._gridDecodeWorker) {
478
+ this._gridDecodeWorker.terminate();
479
+ this._gridDecodeWorker = null;
480
+ }
189
481
  }
190
482
 
191
483
  // --- Public API Methods ---
@@ -213,24 +505,40 @@ export class AguaceroCore extends EventEmitter {
213
505
  }
214
506
 
215
507
  step(direction = 1) {
508
+ if (this.state.isSatellite) {
509
+ const timeline = this._computeSatelliteTimeline();
510
+ const availableTimestamps = [...(timeline.unixTimes || [])]
511
+ .sort((a, b) => a - b)
512
+ .map((t) => Number(t));
513
+ if (availableTimestamps.length === 0) return;
514
+
515
+ const ts = this.state.satelliteTimestamp == null ? null : Number(this.state.satelliteTimestamp);
516
+ const currentIndex = ts == null ? -1 : availableTimestamps.indexOf(ts);
517
+
518
+ let nextIndex = currentIndex === -1 ? availableTimestamps.length - 1 : currentIndex + direction;
519
+ const maxIndex = availableTimestamps.length - 1;
520
+ if (nextIndex > maxIndex) nextIndex = 0;
521
+ if (nextIndex < 0) nextIndex = maxIndex;
522
+
523
+ this.setState({ satelliteTimestamp: availableTimestamps[nextIndex] });
524
+ return;
525
+ }
216
526
  // --- THIS IS THE CORRECTED MRMS LOGIC ---
217
527
  if (this.state.isMRMS) {
218
528
  const { variable, mrmsTimestamp } = this.state;
219
529
  if (!this.mrmsStatus || !this.mrmsStatus[variable]) {
220
- console.warn('[Core.step] MRMS status or variable not available.');
221
530
  return;
222
531
  }
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();
532
+
533
+ const availableTimestamps = this._getFilteredMrmsTimestampsForVariable(variable);
227
534
  if (availableTimestamps.length === 0) return;
228
535
 
229
- const currentIndex = availableTimestamps.indexOf(mrmsTimestamp);
536
+ const ts = mrmsTimestamp == null ? null : Number(mrmsTimestamp);
537
+ const currentIndex = ts == null ? -1 : availableTimestamps.indexOf(ts);
230
538
 
231
539
  if (currentIndex === -1) {
232
- // If not found, reset to the first (newest) frame
233
- this.setState({ mrmsTimestamp: availableTimestamps[0] });
540
+ // If not found, reset to the latest frame (end of ascending list)
541
+ this.setState({ mrmsTimestamp: availableTimestamps[availableTimestamps.length - 1] });
234
542
  return;
235
543
  }
236
544
 
@@ -244,12 +552,28 @@ export class AguaceroCore extends EventEmitter {
244
552
  const newTimestamp = availableTimestamps[nextIndex];
245
553
  this.setState({ mrmsTimestamp: newTimestamp });
246
554
 
555
+ } else if (this.state.isNexrad) {
556
+ const nk = this._nexradTimesCacheKey();
557
+ const raw = nk ? this.nexradTimesByStation[nk]?.unixTimes : [];
558
+ const availableTimestamps = this._getFilteredNexradTimestampsForVariable(raw || []);
559
+ if (availableTimestamps.length === 0) return;
560
+
561
+ const ts = this.state.nexradTimestamp == null ? null : Number(this.state.nexradTimestamp);
562
+ const currentIndex = ts == null ? -1 : availableTimestamps.indexOf(ts);
563
+
564
+ let nextIndex = currentIndex === -1 ? availableTimestamps.length - 1 : currentIndex + direction;
565
+ const maxIndex = availableTimestamps.length - 1;
566
+ if (nextIndex > maxIndex) nextIndex = 0;
567
+ if (nextIndex < 0) nextIndex = maxIndex;
568
+
569
+ this.setState({ nexradTimestamp: availableTimestamps[nextIndex] });
247
570
  } else {
248
- const { model, date, run, forecastHour } = this.state;
249
- const forecastHours = this.modelStatus?.[model]?.[date]?.[run];
571
+ const { forecastHour } = this.state;
572
+ const forecastHours = this.getAvailableForecastHours();
250
573
  if (!forecastHours || forecastHours.length === 0) return;
251
574
 
252
- const currentIndex = forecastHours.indexOf(forecastHour);
575
+ const fh = Number(forecastHour);
576
+ const currentIndex = forecastHours.indexOf(fh);
253
577
  if (currentIndex === -1) return;
254
578
 
255
579
  const maxIndex = forecastHours.length - 1;
@@ -283,7 +607,12 @@ export class AguaceroCore extends EventEmitter {
283
607
  async setVariable(variable) {
284
608
  // --- NEW CODE: Handle switching TO ptypeRefl on HRRR ---
285
609
  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] || [];
610
+ const availableHours = resolveModelRunHours(
611
+ this.modelStatus,
612
+ this.state.model,
613
+ this.state.date,
614
+ this.state.run
615
+ ).hours || [];
287
616
  const firstValidHour = availableHours.find(hour => hour !== 0) || 0;
288
617
  await this.setState({ variable, forecastHour: firstValidHour });
289
618
  return;
@@ -295,6 +624,9 @@ export class AguaceroCore extends EventEmitter {
295
624
 
296
625
  async setModel(modelName) {
297
626
  if (modelName === this.state.model || !this.modelStatus?.[modelName]) return;
627
+ if (this.state.isSatellite) {
628
+ await this.setState({ isSatellite: false, satelliteKey: null, satelliteTimestamp: null });
629
+ }
298
630
  const latestRun = findLatestModelRun(this.modelStatus, modelName);
299
631
  if (latestRun) {
300
632
  // --- NEW CODE: Determine initial forecast hour ---
@@ -302,8 +634,12 @@ export class AguaceroCore extends EventEmitter {
302
634
 
303
635
  // If switching to HRRR with ptypeRefl, start at hour 1 instead of 0
304
636
  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)
637
+ const availableHours = resolveModelRunHours(
638
+ this.modelStatus,
639
+ modelName,
640
+ latestRun.date,
641
+ latestRun.run
642
+ ).hours || [];
307
643
  initialHour = availableHours.find(hour => hour !== 0) || 0;
308
644
  }
309
645
  // --- END NEW CODE ---
@@ -330,12 +666,16 @@ export class AguaceroCore extends EventEmitter {
330
666
  }
331
667
 
332
668
  async setMRMSVariable(variable) {
333
- const sortedTimestamps = [...(this.mrmsStatus[variable] || [])].sort((a, b) => b - a);
334
- const initialTimestamp = sortedTimestamps.length > 0 ? sortedTimestamps[0] : null;
669
+ const sortedTimestamps = this._getFilteredMrmsTimestampsForVariable(variable);
670
+ const initialTimestamp =
671
+ sortedTimestamps.length > 0 ? sortedTimestamps[sortedTimestamps.length - 1] : null;
335
672
 
336
673
  await this.setState({
337
674
  variable,
338
675
  isMRMS: true,
676
+ isSatellite: false,
677
+ satelliteKey: null,
678
+ satelliteTimestamp: null,
339
679
  mrmsTimestamp: initialTimestamp,
340
680
  });
341
681
  }
@@ -345,33 +685,148 @@ export class AguaceroCore extends EventEmitter {
345
685
  await this.setState({ mrmsTimestamp: timestamp });
346
686
  }
347
687
 
688
+ async setSatelliteTimestamp(timestamp) {
689
+ if (!this.state.isSatellite) return;
690
+ await this.setState({ satelliteTimestamp: timestamp != null ? Number(timestamp) : null });
691
+ }
692
+
693
+ /**
694
+ * How many hours of satellite frames to include in the timeline (1, 4, 6, or 12).
695
+ * API default: `layerOptions.satelliteDurationValue` on construction.
696
+ */
697
+ async setSatelliteDurationValue(value) {
698
+ const v = normalizeTimelineDurationValue(value);
699
+ if (!TIMELINE_DURATION_HOUR_VALUES.includes(v)) return;
700
+ await this.setState({ satelliteDurationValue: v });
701
+ if (!this.state.isSatellite || !this.state.satelliteKey) return;
702
+ const timeline = this._computeSatelliteTimeline();
703
+ const tsList = [...(timeline.unixTimes || [])]
704
+ .map((t) => Number(t))
705
+ .filter((t) => !Number.isNaN(t))
706
+ .sort((a, b) => a - b);
707
+ if (tsList.length === 0) return;
708
+ const cur = this.state.satelliteTimestamp;
709
+ const curN = cur == null ? null : Number(cur);
710
+ if (curN == null || !tsList.includes(curN)) {
711
+ await this.setState({ satelliteTimestamp: tsList[tsList.length - 1] });
712
+ }
713
+ }
714
+
715
+ /**
716
+ * How many hours of MRMS frames to include in the timeline (1, 4, 6, or 12).
717
+ * API default: `layerOptions.mrmsDurationValue` on construction.
718
+ */
719
+ async setMRMSDurationValue(value) {
720
+ const v = normalizeTimelineDurationValue(value);
721
+ if (!TIMELINE_DURATION_HOUR_VALUES.includes(v)) return;
722
+ await this.setState({ mrmsDurationValue: v });
723
+ // NEXRAD listings are fetched with `hours` from this value (see _nexradListingWindowHours). Without a
724
+ // refresh, expanding 1h→4h only re-filters the old in-memory sweep list — new frames never appear until
725
+ // site/product changes trigger refreshNexradTimes.
726
+ if (this.state.isNexrad && this.state.nexradSite) {
727
+ await this.refreshNexradTimes();
728
+ const nk = this._nexradTimesCacheKey();
729
+ const filtered = this._getFilteredNexradTimestampsForVariable(
730
+ nk ? this.nexradTimesByStation[nk]?.unixTimes || [] : [],
731
+ );
732
+ if (filtered.length === 0) return;
733
+ const cur = this.state.nexradTimestamp;
734
+ const curN = cur == null ? null : Number(cur);
735
+ if (curN == null || !filtered.includes(curN)) {
736
+ await this.setState({ nexradTimestamp: filtered[filtered.length - 1] });
737
+ }
738
+ return;
739
+ }
740
+ if (!this.state.isMRMS || !this.state.variable) return;
741
+ const filtered = this._getFilteredMrmsTimestampsForVariable(this.state.variable);
742
+ if (filtered.length === 0) return;
743
+ const cur = this.state.mrmsTimestamp;
744
+ const curN = cur == null ? null : Number(cur);
745
+ if (curN == null || !filtered.includes(curN)) {
746
+ await this.setState({ mrmsTimestamp: filtered[filtered.length - 1] });
747
+ }
748
+ }
749
+
348
750
  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.");
751
+ const { mode, variable, model, forecastHour, mrmsTimestamp, satelliteKey, satelliteTimestamp } = options;
752
+ if (!mode) {
352
753
  return;
353
754
  }
354
755
  if (mode === 'model' && !model) {
355
- console.error("switchMode with mode 'model' requires a 'model' property.");
756
+ return;
757
+ }
758
+ if ((mode === 'mrms' || mode === 'model') && !variable) {
759
+ return;
760
+ }
761
+ if (mode === 'satellite' && !satelliteKey) {
356
762
  return;
357
763
  }
358
764
  let targetState = {};
359
- if (mode === 'mrms') {
765
+ if (mode === 'satellite') {
766
+ const channelToken = satelliteKey.split('.').pop() || variable || 'C13';
767
+ // Emit satellite mode immediately so map layers (e.g. model grid) clear before listing fetch finishes.
768
+ await this.setState({
769
+ isSatellite: true,
770
+ isMRMS: false,
771
+ isNexrad: false,
772
+ satelliteKey,
773
+ variable: channelToken,
774
+ satelliteTimestamp: null,
775
+ mrmsTimestamp: null,
776
+ date: null,
777
+ run: null,
778
+ forecastHour: 0,
779
+ });
780
+
781
+ await this.fetchSatelliteListing(true);
782
+ const allFiles = this.satelliteListing?.objects?.map((o) => o.key) || [];
783
+ const sectorName = satelliteKey.split('.')[1] || 'GOES-EAST CONUS';
784
+ let sectorType = 'CONUS';
785
+ if (sectorName.includes('FULL DISK')) sectorType = 'FULL_DISK';
786
+ else if (sectorName.includes('MESOSCALE')) sectorType = 'MESOSCALE';
787
+ const tier = this.state.satelliteTier || 'basic';
788
+ const tierConfig =
789
+ SATELLITE_DURATION_CONFIG[sectorType]?.[tier] || SATELLITE_DURATION_CONFIG.CONUS.basic;
790
+ const durationKey = normalizeTimelineDurationValue(this.state.satelliteDurationValue);
791
+ const durationOpt =
792
+ tierConfig.find((o) => String(o.value) === String(durationKey)) ||
793
+ tierConfig.find((o) => o.value === '1') ||
794
+ tierConfig[0];
795
+ const timeline = buildSatelliteTimelineForKey(satelliteKey, allFiles, durationOpt);
796
+ const tsList = [...(timeline.unixTimes || [])].sort((a, b) => a - b);
797
+ let finalTs = satelliteTimestamp !== undefined ? satelliteTimestamp : null;
798
+ if (finalTs == null && tsList.length > 0) {
799
+ finalTs = tsList[tsList.length - 1];
800
+ }
801
+ await this.setState({
802
+ satelliteTimestamp: finalTs != null ? Number(finalTs) : null,
803
+ });
804
+ return;
805
+ } else if (mode === 'mrms') {
806
+ const sortedTimestamps = this._getFilteredMrmsTimestampsForVariable(variable);
360
807
  let finalTimestamp = mrmsTimestamp;
361
808
  if (finalTimestamp === undefined) {
362
- const sortedTimestamps = [...(this.mrmsStatus[variable] || [])].sort((a, b) => b - a);
363
- finalTimestamp = sortedTimestamps.length > 0 ? sortedTimestamps[0] : null;
809
+ finalTimestamp =
810
+ sortedTimestamps.length > 0 ? sortedTimestamps[sortedTimestamps.length - 1] : null;
811
+ } else if (sortedTimestamps.length > 0) {
812
+ const n = Number(finalTimestamp);
813
+ if (!sortedTimestamps.includes(n)) {
814
+ finalTimestamp = sortedTimestamps[sortedTimestamps.length - 1];
815
+ }
364
816
  }
365
817
  targetState = {
366
818
  isMRMS: true,
819
+ isSatellite: false,
820
+ isNexrad: false,
367
821
  variable: variable,
368
822
  mrmsTimestamp: finalTimestamp,
369
823
  model: this.state.model, date: null, run: null, forecastHour: 0,
824
+ satelliteKey: null,
825
+ satelliteTimestamp: null,
370
826
  };
371
827
  } else if (mode === 'model') {
372
828
  const latestRun = findLatestModelRun(this.modelStatus, model);
373
829
  if (!latestRun) {
374
- console.error(`Could not find a valid run for model: ${model}`);
375
830
  return;
376
831
  }
377
832
 
@@ -380,50 +835,316 @@ export class AguaceroCore extends EventEmitter {
380
835
 
381
836
  // If switching to HRRR with ptypeRefl and hour is 0, use hour 1
382
837
  if (model === 'hrrr' && variable === 'ptypeRefl' && initialHour === 0) {
383
- const availableHours = this.modelStatus?.[model]?.[latestRun.date]?.[latestRun.run] || [];
838
+ const availableHours = resolveModelRunHours(
839
+ this.modelStatus,
840
+ model,
841
+ latestRun.date,
842
+ latestRun.run
843
+ ).hours || [];
384
844
  initialHour = availableHours.find(hour => hour !== 0) || 0;
385
845
  }
386
846
  // --- END NEW CODE ---
387
847
 
388
848
  targetState = {
389
849
  isMRMS: false,
850
+ isSatellite: false,
851
+ isNexrad: false,
390
852
  model: model,
391
853
  variable: variable,
392
854
  date: latestRun.date,
393
855
  run: latestRun.run,
394
856
  forecastHour: initialHour, // <-- Changed
395
857
  mrmsTimestamp: null,
858
+ satelliteKey: null,
859
+ satelliteTimestamp: null,
396
860
  };
861
+ } else if (mode === 'nexrad') {
862
+ const nexradDataSource = options.nexradDataSource || 'level2';
863
+ const nexradProduct = options.nexradProduct || 'REF';
864
+ const fld = nexradColormapFldKey(nexradDataSource, nexradProduct);
865
+ const site = options.nexradSite ?? null;
866
+ let tilt =
867
+ options.nexradTilt != null
868
+ ? Number(options.nexradTilt)
869
+ : site != null
870
+ ? getDefaultRadarTilt(site)
871
+ : null;
872
+ await this.setState({
873
+ isNexrad: true,
874
+ isMRMS: false,
875
+ isSatellite: false,
876
+ variable: fld,
877
+ nexradSite: site,
878
+ nexradDataSource,
879
+ nexradProduct,
880
+ nexradTilt: tilt,
881
+ nexradTimestamp: options.nexradTimestamp != null ? Number(options.nexradTimestamp) : null,
882
+ nexradStormRelative: options.nexradStormRelative === true,
883
+ nexradShowSitesPicker: options.nexradShowSitesPicker !== false,
884
+ mrmsTimestamp: null,
885
+ satelliteKey: null,
886
+ satelliteTimestamp: null,
887
+ date: null,
888
+ run: null,
889
+ forecastHour: 0,
890
+ });
891
+ const manifest = await fetchRadarTiltsManifestFromNetwork();
892
+ if (manifest) setRadarTiltsManifest(manifest);
893
+ if (site) {
894
+ await this.refreshNexradTimes();
895
+ const filtered = this._getFilteredNexradTimestampsForVariable(
896
+ this.nexradTimesByStation[this._nexradTimesCacheKey()]?.unixTimes || [],
897
+ );
898
+ let ts =
899
+ options.nexradTimestamp != null
900
+ ? Number(options.nexradTimestamp)
901
+ : this.state.nexradTimestamp;
902
+ if (ts == null && filtered.length > 0) ts = filtered[filtered.length - 1];
903
+ else if (ts != null && filtered.length > 0 && !filtered.includes(ts)) {
904
+ ts = filtered[filtered.length - 1];
905
+ }
906
+ await this.setState({ nexradTimestamp: ts });
907
+ }
908
+ return;
397
909
  } else {
398
- console.error(`Invalid mode specified in switchMode: '${mode}'`);
399
910
  return;
400
911
  }
401
912
  await this.setState(targetState);
402
913
  }
403
914
 
404
- // --- Data and Calculation Methods ---
915
+ /**
916
+ * Keep `nexradTilt` on an angle returned by {@link getAvailableNexradTilts} (matches aguacero-frontend elevation buttons).
917
+ */
918
+ async _snapNexradTiltToAvailableOptions() {
919
+ const s = this.state;
920
+ if (!s.isNexrad || !s.nexradSite) return;
921
+ const tilts = getAvailableNexradTilts(
922
+ s.nexradSite,
923
+ s.nexradDataSource || 'level2',
924
+ s.nexradProduct || 'REF',
925
+ );
926
+ if (!tilts.length) return;
927
+ const t = s.nexradTilt;
928
+ const match = (a, b) => Math.abs(Number(a) - Number(b)) < 1e-4;
929
+ if (t != null && tilts.some((x) => match(x, t))) return;
930
+ const target = t != null && Number.isFinite(Number(t)) ? Number(t) : getDefaultRadarTilt(s.nexradSite);
931
+ let best = tilts[0];
932
+ for (const x of tilts) {
933
+ if (Math.abs(x - target) < Math.abs(best - target)) best = x;
934
+ }
935
+ await this.setState({ nexradTilt: best });
936
+ }
405
937
 
406
- _reconstructData(decompressedDeltas, encoding) {
407
- const expectedLength = encoding.length;
408
- const reconstructedData = new Int8Array(expectedLength);
938
+ async refreshNexradTimes() {
939
+ const s0 = this.state;
940
+ if (!s0.isNexrad || !s0.nexradSite) return;
941
+ await this._snapNexradTiltToAvailableOptions();
942
+ const s = this.state;
943
+ const listingHours = this._nexradListingWindowHours();
944
+ const out = await fetchNexradTimesListing({
945
+ stationId: s.nexradSite,
946
+ variable: s.nexradProduct || 'REF',
947
+ elev: s.nexradTilt,
948
+ source: s.nexradDataSource || 'level2',
949
+ level3StormRelative: s.nexradStormRelative,
950
+ level3Product: getNexradLevel3EntryByRadarKey(s.nexradProduct)?.product,
951
+ listingWindowHours: listingHours,
952
+ });
953
+ const nk = this._nexradTimesCacheKey();
954
+ if (!nk) return;
955
+ this.nexradTimesByStation[nk] = {
956
+ unixTimes: out.unixTimes,
957
+ timeToKeyMap: out.timeToKeyMap,
958
+ level3MotionTimeToKeyMap: out.level3MotionTimeToKeyMap,
959
+ listWindowHours: listingHours,
960
+ };
961
+ if (out.level3MotionKey && out.level3MotionUnixTimes?.length) {
962
+ this.nexradTimesByStation[out.level3MotionKey] = {
963
+ unixTimes: out.level3MotionUnixTimes,
964
+ timeToKeyMap: out.level3MotionTimeToKeyMap,
965
+ listWindowHours: listingHours,
966
+ };
967
+ }
968
+ if (out.l2StormMotionListKey && out.level3MotionUnixTimes?.length) {
969
+ this.nexradTimesByStation[out.l2StormMotionListKey] = {
970
+ unixTimes: out.level3MotionUnixTimes,
971
+ timeToKeyMap: out.level3MotionTimeToKeyMap,
972
+ listWindowHours: listingHours,
973
+ };
974
+ }
975
+ this._emitStateChange();
976
+ }
409
977
 
410
- if (decompressedDeltas.length > 0 && expectedLength > 0) {
411
- // First value is absolute
412
- reconstructedData[0] = decompressedDeltas[0] > 127 ? decompressedDeltas[0] - 256 : decompressedDeltas[0];
978
+ async setNexradSite(siteId) {
979
+ if (!this.state.isNexrad) return;
980
+ const tilt = siteId ? getDefaultRadarTilt(siteId) : null;
981
+ await this.setState({
982
+ nexradSite: siteId || null,
983
+ nexradTilt: tilt,
984
+ nexradTimestamp: null,
985
+ });
986
+ await this.refreshNexradTimes();
987
+ const filtered = this._getFilteredNexradTimestampsForVariable(
988
+ this.nexradTimesByStation[this._nexradTimesCacheKey()]?.unixTimes || [],
989
+ );
990
+ if (filtered.length > 0) {
991
+ await this.setState({ nexradTimestamp: filtered[filtered.length - 1] });
992
+ }
993
+ }
413
994
 
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
- }
995
+ async setNexradProduct(product) {
996
+ if (!this.state.isNexrad) return;
997
+ const p = (product || 'REF').toUpperCase();
998
+ const ds = this.state.nexradDataSource || 'level2';
999
+ const fld = nexradColormapFldKey(ds, p);
1000
+ const nexradStormRelative = this._nexradStormRelativeFor(ds, p);
1001
+ await this.setState({ nexradProduct: p, variable: fld, nexradStormRelative });
1002
+ await this.refreshNexradTimes();
1003
+ const filtered = this._getFilteredNexradTimestampsForVariable(
1004
+ this.nexradTimesByStation[this._nexradTimesCacheKey()]?.unixTimes || [],
1005
+ );
1006
+ if (filtered.length > 0) {
1007
+ await this.setState({ nexradTimestamp: filtered[filtered.length - 1] });
419
1008
  }
420
- // Return as a Uint8Array, which is what the rest of the code expects
421
- return new Uint8Array(reconstructedData.buffer);
1009
+ }
1010
+
1011
+ async setNexradDataSource(source) {
1012
+ if (!this.state.isNexrad) return;
1013
+ const ds = source === 'level3' ? 'level3' : 'level2';
1014
+ const p = (this.state.nexradProduct || 'REF').toUpperCase();
1015
+ const fld = nexradColormapFldKey(ds, p);
1016
+ const nexradStormRelative = this._nexradStormRelativeFor(ds, p);
1017
+ await this.setState({ nexradDataSource: ds, variable: fld, nexradStormRelative });
1018
+ await this.refreshNexradTimes();
1019
+ const filtered = this._getFilteredNexradTimestampsForVariable(
1020
+ this.nexradTimesByStation[this._nexradTimesCacheKey()]?.unixTimes || [],
1021
+ );
1022
+ if (filtered.length > 0) {
1023
+ await this.setState({ nexradTimestamp: filtered[filtered.length - 1] });
1024
+ }
1025
+ }
1026
+
1027
+ /**
1028
+ * Sets NEXRAD product and archive source together (single refresh). Used when the UI exposes one
1029
+ * combined menu instead of separate “Level II / Level III” controls.
1030
+ * @param {'level2'|'level3'} dataSource
1031
+ * @param {string} product - Level-II variable (REF, VEL, …) or Level-III radar key (N0H, HHC, …)
1032
+ */
1033
+ async setNexradProductMode(dataSource, product) {
1034
+ if (!this.state.isNexrad) return;
1035
+ const ds = dataSource === 'level3' ? 'level3' : 'level2';
1036
+ const p = (product || 'REF').toUpperCase();
1037
+ const fld = nexradColormapFldKey(ds, p);
1038
+ const nexradStormRelative = this._nexradStormRelativeFor(ds, p);
1039
+ await this.setState({ nexradDataSource: ds, nexradProduct: p, variable: fld, nexradStormRelative });
1040
+ await this.refreshNexradTimes();
1041
+ const filtered = this._getFilteredNexradTimestampsForVariable(
1042
+ this.nexradTimesByStation[this._nexradTimesCacheKey()]?.unixTimes || [],
1043
+ );
1044
+ if (filtered.length > 0) {
1045
+ await this.setState({ nexradTimestamp: filtered[filtered.length - 1] });
1046
+ }
1047
+ }
1048
+
1049
+ async setNexradTilt(tilt) {
1050
+ if (!this.state.isNexrad || !this.state.nexradSite) return;
1051
+ await this.setState({ nexradTilt: tilt != null ? Number(tilt) : null });
1052
+ await this.refreshNexradTimes();
1053
+ }
1054
+
1055
+ async setNexradTimestamp(ts) {
1056
+ if (!this.state.isNexrad) return;
1057
+ await this.setState({ nexradTimestamp: ts != null ? Number(ts) : null });
1058
+ }
1059
+
1060
+ // --- Data and Calculation Methods ---
1061
+
1062
+ _ensureGridDecodeWorker() {
1063
+ if (this._gridDecodeWorkerDisabled) {
1064
+ return null;
1065
+ }
1066
+ if (this._gridDecodeWorker) {
1067
+ return this._gridDecodeWorker;
1068
+ }
1069
+ if (typeof Worker === 'undefined') {
1070
+ this._gridDecodeWorkerDisabled = true;
1071
+ return null;
1072
+ }
1073
+ try {
1074
+ this._gridDecodeWorker = new Worker(new URL('./gridDecodeWorker.js', import.meta.url), {
1075
+ type: 'module',
1076
+ });
1077
+ this._gridDecodeWorker.addEventListener('error', () => {
1078
+ this._gridDecodeWorkerDisabled = true;
1079
+ if (this._gridDecodeWorker) {
1080
+ this._gridDecodeWorker.terminate();
1081
+ this._gridDecodeWorker = null;
1082
+ }
1083
+ });
1084
+ } catch {
1085
+ this._gridDecodeWorkerDisabled = true;
1086
+ return null;
1087
+ }
1088
+ return this._gridDecodeWorker;
1089
+ }
1090
+
1091
+ /**
1092
+ * Offloads zstd + delta decode + transform to a Worker when available; falls back to main-thread
1093
+ * {@link processCompressedGrid}. Uses a copy for postMessage transfer so the original `compressedData`
1094
+ * stays valid if the Worker path fails.
1095
+ */
1096
+ _decodeGridPayload(compressedData, encoding) {
1097
+ const worker = this._ensureGridDecodeWorker();
1098
+ if (!worker) {
1099
+ return Promise.resolve(processCompressedGrid(compressedData, encoding));
1100
+ }
1101
+ const payload = compressedData.slice();
1102
+ return new Promise((resolve, reject) => {
1103
+ const id = ++this._gridDecodeMsgId;
1104
+ const onMsg = (e) => {
1105
+ if (!e.data || e.data.id !== id) {
1106
+ return;
1107
+ }
1108
+ worker.removeEventListener('message', onMsg);
1109
+ worker.removeEventListener('error', onErr);
1110
+ if (e.data.error) {
1111
+ reject(new Error(e.data.error));
1112
+ return;
1113
+ }
1114
+ const data = new Uint8Array(e.data.dataBuffer, e.data.dataByteOffset, e.data.dataByteLength);
1115
+ resolve({ data, encoding: e.data.encoding });
1116
+ };
1117
+ const onErr = (err) => {
1118
+ worker.removeEventListener('message', onMsg);
1119
+ worker.removeEventListener('error', onErr);
1120
+ reject(err);
1121
+ };
1122
+ worker.addEventListener('message', onMsg);
1123
+ worker.addEventListener('error', onErr);
1124
+ try {
1125
+ worker.postMessage(
1126
+ {
1127
+ id,
1128
+ encoding,
1129
+ compressedBuffer: payload.buffer,
1130
+ compressedByteOffset: payload.byteOffset,
1131
+ compressedByteLength: payload.byteLength,
1132
+ },
1133
+ [payload.buffer]
1134
+ );
1135
+ } catch (err) {
1136
+ worker.removeEventListener('message', onMsg);
1137
+ worker.removeEventListener('error', onErr);
1138
+ reject(err);
1139
+ }
1140
+ });
422
1141
  }
423
1142
 
424
1143
  async _loadGridData(state) {
425
1144
  if (this.isReactNative) {
426
- console.warn(`[AguaceroCore] _loadGridData was called in React Native. This is a bypass. Data loading is handled natively.`);
1145
+ return null;
1146
+ }
1147
+ if (state.isNexrad) {
427
1148
  return null;
428
1149
  }
429
1150
  const { model, date, run, forecastHour, variable, isMRMS, mrmsTimestamp } = state;
@@ -447,18 +1168,7 @@ export class AguaceroCore extends EventEmitter {
447
1168
  if (this.dataCache.has(dataUrlIdentifier)) {
448
1169
  return this.dataCache.get(dataUrlIdentifier);
449
1170
  }
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
-
1171
+
462
1172
  const abortController = new AbortController();
463
1173
  this.abortControllers.set(dataUrlIdentifier, abortController);
464
1174
 
@@ -485,31 +1195,18 @@ export class AguaceroCore extends EventEmitter {
485
1195
  const { data: b64Data, encoding } = await response.json();
486
1196
  const compressedData = Uint8Array.from(atob(b64Data), c => c.charCodeAt(0));
487
1197
 
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;
1198
+ let gridPayload;
1199
+ try {
1200
+ gridPayload = await this._decodeGridPayload(compressedData, encoding);
1201
+ } catch {
1202
+ gridPayload = processCompressedGrid(compressedData, encoding);
501
1203
  }
502
1204
 
503
1205
  this.abortControllers.delete(dataUrlIdentifier);
504
1206
 
505
- return { data: transformedData, encoding };
1207
+ return { data: gridPayload.data, encoding: gridPayload.encoding };
506
1208
 
507
1209
  } 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
1210
  this.dataCache.delete(dataUrlIdentifier);
514
1211
  this.abortControllers.delete(dataUrlIdentifier);
515
1212
  return null;
@@ -528,11 +1225,12 @@ export class AguaceroCore extends EventEmitter {
528
1225
  // Clear both maps
529
1226
  this.abortControllers.clear();
530
1227
  this.dataCache.clear();
531
-
532
- console.log('All pending requests cancelled');
533
1228
  }
534
1229
 
535
1230
  async getValueAtLngLat(lng, lat) {
1231
+ if (this.state.isSatellite || this.state.isNexrad) {
1232
+ return null;
1233
+ }
536
1234
  const { variable, isMRMS, mrmsTimestamp, model, date, run, forecastHour, units } = this.state;
537
1235
  if (!variable) return null;
538
1236
 
@@ -814,74 +1512,11 @@ export class AguaceroCore extends EventEmitter {
814
1512
  const y = t_y * (ny - 1);
815
1513
  return { x, y };
816
1514
  } catch (error) {
817
- console.warn(`[GridAccessor] RGEM polar stereographic conversion failed for ${lat}, ${lon}:`, error);
818
1515
  return { x: -1, y: -1 };
819
1516
  }
820
1517
  }
821
1518
 
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
- }
1519
+ // --- Status Methods ---
885
1520
 
886
1521
  async fetchModelStatus(force = false) {
887
1522
  if (!this.modelStatus || force) {
@@ -906,6 +1541,19 @@ export class AguaceroCore extends EventEmitter {
906
1541
  return this.mrmsStatus;
907
1542
  }
908
1543
 
1544
+ async fetchSatelliteListing(force = false) {
1545
+ if (!this.satelliteListing || force) {
1546
+ try {
1547
+ const response = await fetch(SATELLITE_FRAMES_URL);
1548
+ if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`);
1549
+ this.satelliteListing = await response.json();
1550
+ } catch (error) {
1551
+ this.satelliteListing = null;
1552
+ }
1553
+ }
1554
+ return this.satelliteListing;
1555
+ }
1556
+
909
1557
  startAutoRefresh(intervalSeconds) {
910
1558
  this.stopAutoRefresh();
911
1559
  this.autoRefreshIntervalId = setInterval(async () => {