@aguacerowx/javascript-sdk 0.0.24 → 0.0.26

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.
@@ -10,7 +10,13 @@ var _unitConversions = require("./unitConversions.js");
10
10
  var _dictionaries = require("./dictionaries.js");
11
11
  var _defaultColormaps = require("./default-colormaps.js");
12
12
  var _proj = _interopRequireDefault(require("proj4"));
13
+ var _gridDecodePipeline = require("./gridDecodePipeline.js");
13
14
  var _getBundleId = require("./getBundleId");
15
+ var _satellite_support = require("./satellite_support.js");
16
+ var _nexrad_support = require("./nexrad_support.js");
17
+ var _nexradTiltCoalesce = require("./nexradTiltCoalesce.js");
18
+ var _nexrad_level3_catalog = require("./nexrad_level3_catalog.js");
19
+ var _nexradTilts = require("./nexradTilts.js");
14
20
  function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
15
21
  // AguaceroCore.js - The Headless "Engine"
16
22
 
@@ -54,26 +60,80 @@ function findLatestModelRun(modelsData, modelName) {
54
60
  }
55
61
  return null;
56
62
  }
63
+
64
+ /**
65
+ * model-status JSON uses string keys for runs (often zero-padded: "00", "06").
66
+ * Direct lookup modelStatus[model][date][run] fails if state.run is "6" but the key is "06".
67
+ * Returns the hour list and the run key that matched.
68
+ */
69
+ function resolveModelRunHours(modelStatus, model, date, run) {
70
+ var _modelStatus$model;
71
+ const runs = modelStatus === null || modelStatus === void 0 || (_modelStatus$model = modelStatus[model]) === null || _modelStatus$model === void 0 ? void 0 : _modelStatus$model[date];
72
+ if (!runs || run == null || run === '') {
73
+ return {
74
+ hours: [],
75
+ matchedRunKey: null,
76
+ availableRunKeys: runs ? Object.keys(runs) : []
77
+ };
78
+ }
79
+ const runStr = String(run);
80
+ const candidates = new Set([runStr]);
81
+ const n = parseInt(runStr, 10);
82
+ if (!Number.isNaN(n)) {
83
+ candidates.add(String(n));
84
+ candidates.add(String(n).padStart(2, '0'));
85
+ candidates.add(String(n).padStart(3, '0'));
86
+ }
87
+ for (const key of candidates) {
88
+ const h = runs[key];
89
+ if (h && Array.isArray(h) && h.length > 0) {
90
+ return {
91
+ hours: h,
92
+ matchedRunKey: key,
93
+ availableRunKeys: Object.keys(runs)
94
+ };
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 {
104
+ hours: h,
105
+ matchedRunKey: k,
106
+ availableRunKeys: Object.keys(runs)
107
+ };
108
+ }
109
+ }
110
+ }
111
+ }
112
+ return {
113
+ hours: [],
114
+ matchedRunKey: null,
115
+ availableRunKeys: Object.keys(runs)
116
+ };
117
+ }
57
118
  class AguaceroCore extends _events.EventEmitter {
58
119
  constructor(options = {}) {
59
120
  super();
60
121
  this.isReactNative = typeof navigator !== 'undefined' && navigator.product === 'ReactNative';
61
122
  this.apiKey = options.apiKey;
123
+ /** Passed as CloudFront `userId` for satellite KTX2 URLs (production uses the authenticated account id). */
124
+ this.userId = options.userId ?? 'sdk-user';
62
125
  this.bundleId = (0, _getBundleId.getBundleId)();
63
126
  this.baseGridUrl = 'https://d3dc62msmxkrd7.cloudfront.net';
64
- if (!this.isReactNative) {
65
- this.worker = this.createWorker();
66
- this.workerRequestId = 0;
67
- this.workerResolvers = new Map();
68
- this.worker.addEventListener('message', this._handleWorkerMessage.bind(this));
69
- this.resultQueue = [];
70
- this.isProcessingQueue = false;
71
- } else {
72
- this.worker = null;
73
- }
127
+ /** @type {Worker | null} */
128
+ this._gridDecodeWorker = null;
129
+ /** When true, skip Worker and use {@link processCompressedGrid} on the main thread. */
130
+ this._gridDecodeWorkerDisabled = false;
131
+ this._gridDecodeMsgId = 0;
74
132
  this.statusUrl = 'https://d3dc62msmxkrd7.cloudfront.net/model-status';
75
133
  this.modelStatus = null;
76
134
  this.mrmsStatus = null;
135
+ /** @type {{ objects?: Array<{ key: string }> } | null} */
136
+ this.satelliteListing = null;
77
137
  this.dataCache = new Map();
78
138
  this.abortControllers = new Map();
79
139
  this.isPlaying = false;
@@ -84,30 +144,207 @@ class AguaceroCore extends _events.EventEmitter {
84
144
  // EDIT: Determine initial mode from options
85
145
  const initialMode = userLayerOptions.mode || 'model';
86
146
  const initialVariable = userLayerOptions.variable || null;
147
+ const initialSatellite = initialMode === 'satellite';
148
+ const initialNexrad = initialMode === 'nexrad';
149
+ const initialNexradProd = userLayerOptions.nexradProduct || 'REF';
150
+ const initialNexradDs = (0, _nexrad_support.inferNexradDataSourceForProduct)(initialNexradProd);
151
+ const initialNexradFld = initialNexrad ? (0, _nexrad_support.nexradColormapFldKey)(initialNexradDs, initialNexradProd) : initialVariable;
152
+ let initialSatelliteInstrumentId = null;
153
+ let initialSatelliteSectorLabel = null;
154
+ let initialSatelliteChannel = null;
155
+ if (initialSatellite) {
156
+ initialSatelliteInstrumentId = userLayerOptions.satelliteId ?? 'GOES19-EAST';
157
+ initialSatelliteSectorLabel = (0, _satellite_support.resolveSatelliteSectorLabel)(userLayerOptions.satelliteSector ?? userLayerOptions.sector ?? 'conus');
158
+ initialSatelliteChannel = userLayerOptions.satelliteProduct ?? userLayerOptions.satelliteChannel ?? initialVariable ?? 'C13';
159
+ }
87
160
  this.state = {
88
161
  model: userLayerOptions.model || 'gfs',
89
162
  // EDIT: Set isMRMS based on the initial mode
90
- isMRMS: initialMode === 'mrms',
163
+ isMRMS: initialMode === 'mrms' && !initialSatellite && !initialNexrad,
91
164
  mrmsTimestamp: null,
92
- variable: initialVariable,
165
+ variable: initialNexrad ? initialNexradFld : initialSatellite && initialSatelliteInstrumentId ? initialSatelliteChannel : initialVariable,
93
166
  date: null,
94
167
  run: null,
95
168
  forecastHour: 0,
96
169
  visible: true,
97
170
  opacity: userLayerOptions.opacity ?? 1,
98
171
  units: options.initialUnit || 'imperial',
99
- shaderSmoothingEnabled: options.shaderSmoothingEnabled ?? true
172
+ shaderSmoothingEnabled: options.shaderSmoothingEnabled ?? true,
173
+ isSatellite: initialSatellite,
174
+ satelliteInstrumentId: initialSatelliteInstrumentId,
175
+ satelliteSectorLabel: initialSatelliteSectorLabel,
176
+ satelliteChannel: initialSatelliteChannel,
177
+ satelliteTimestamp: userLayerOptions.satelliteTimestamp != null ? Number(userLayerOptions.satelliteTimestamp) : null,
178
+ satelliteTier: userLayerOptions.satelliteTier || 'basic',
179
+ satelliteDurationValue: (0, _satellite_support.formatTimelineDurationValue)(userLayerOptions.satelliteDurationValue != null ? userLayerOptions.satelliteDurationValue : '1'),
180
+ mrmsDurationValue: (0, _satellite_support.formatTimelineDurationValue)(userLayerOptions.mrmsDurationValue != null ? userLayerOptions.mrmsDurationValue : '1'),
181
+ nexradDurationValue: (0, _satellite_support.formatTimelineDurationValue)(userLayerOptions.nexradDurationValue != null ? userLayerOptions.nexradDurationValue : '1'),
182
+ isNexrad: initialNexrad,
183
+ nexradSite: userLayerOptions.nexradSite ?? null,
184
+ nexradDataSource: initialNexradDs,
185
+ nexradProduct: initialNexradProd,
186
+ nexradTilt: userLayerOptions.nexradTilt != null ? Number(userLayerOptions.nexradTilt) : userLayerOptions.nexradSite ? (0, _nexradTilts.getDefaultRadarTilt)(userLayerOptions.nexradSite) : 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
100
191
  };
101
192
  this.autoRefreshEnabled = options.autoRefresh ?? false;
102
193
  this.autoRefreshIntervalSeconds = options.autoRefreshInterval ?? 60;
103
194
  this.autoRefreshIntervalId = null;
195
+
196
+ /** @type {Record<string, { unixTimes?: number[]; timeToKeyMap?: Record<string, string>; listWindowHours?: number }>} */
197
+ this.nexradTimesByStation = {};
104
198
  }
105
199
  async setState(newState) {
106
- Object.assign(this.state, newState);
200
+ const patch = {
201
+ ...newState
202
+ };
203
+ if ('satelliteKey' in patch) delete patch.satelliteKey;
204
+ if ('nexradDataSource' in patch && !('nexradProduct' in patch)) {
205
+ delete patch.nexradDataSource;
206
+ }
207
+ const willBeNexrad = patch.isNexrad !== undefined ? Boolean(patch.isNexrad) : this.state.isNexrad;
208
+ if (willBeNexrad && 'nexradProduct' in patch && patch.nexradProduct != null) {
209
+ const p = (patch.nexradProduct || 'REF').toUpperCase();
210
+ const prevP = (this.state.nexradProduct || 'REF').toUpperCase();
211
+ const ds = (0, _nexrad_support.inferNexradDataSourceForProduct)(p);
212
+ patch.nexradProduct = p;
213
+ patch.nexradDataSource = ds;
214
+ patch.variable = (0, _nexrad_support.nexradColormapFldKey)(ds, p);
215
+ if (!('nexradStormRelative' in newState)) {
216
+ // Default: base radial velocity (L3 N0G only) — one fetch per time; fast scrub.
217
+ // SRV (N0G + N0S) is opt-in: setNexradStormRelative(true) or pass nexradStormRelative in setState.
218
+ // Re-selecting the same product (e.g. VEL → VEL) leaves the current SRV flag alone.
219
+ if (p !== 'VEL' || prevP !== 'VEL') {
220
+ patch.nexradStormRelative = false;
221
+ }
222
+ }
223
+ }
224
+ if ('forecastHour' in patch && patch.forecastHour != null) {
225
+ patch.forecastHour = Number(patch.forecastHour);
226
+ }
227
+ if ('mrmsTimestamp' in patch && patch.mrmsTimestamp != null) {
228
+ patch.mrmsTimestamp = Number(patch.mrmsTimestamp);
229
+ }
230
+ if ('satelliteTimestamp' in patch && patch.satelliteTimestamp != null) {
231
+ patch.satelliteTimestamp = Number(patch.satelliteTimestamp);
232
+ }
233
+ if ('satelliteDurationValue' in patch && patch.satelliteDurationValue != null) {
234
+ patch.satelliteDurationValue = (0, _satellite_support.formatTimelineDurationValue)(patch.satelliteDurationValue);
235
+ }
236
+ if ('mrmsDurationValue' in patch && patch.mrmsDurationValue != null) {
237
+ patch.mrmsDurationValue = (0, _satellite_support.formatTimelineDurationValue)(patch.mrmsDurationValue);
238
+ }
239
+ if ('nexradDurationValue' in patch && patch.nexradDurationValue != null) {
240
+ patch.nexradDurationValue = (0, _satellite_support.formatTimelineDurationValue)(patch.nexradDurationValue);
241
+ }
242
+ if ('nexradTimestamp' in patch && patch.nexradTimestamp != null) {
243
+ patch.nexradTimestamp = Number(patch.nexradTimestamp);
244
+ }
245
+ if ('nexradTilt' in patch && patch.nexradTilt != null) {
246
+ patch.nexradTilt = Number(patch.nexradTilt);
247
+ }
248
+ Object.assign(this.state, patch);
107
249
  this._emitStateChange();
108
250
  }
251
+
252
+ /**
253
+ * Forecast hours for the current model/date/run (normalized numbers).
254
+ * Tolerates model-status run keys like "06" vs state.run "6" so preload and slider stay in sync.
255
+ */
256
+ getAvailableForecastHours() {
257
+ if (this.state.isMRMS || this.state.isSatellite || this.state.isNexrad) return [];
258
+ if (!this.state.model || this.state.date == null || this.state.run == null) return [];
259
+ const resolved = resolveModelRunHours(this.modelStatus, this.state.model, this.state.date, this.state.run);
260
+ let hours = resolved.hours || [];
261
+ if (hours.length > 0) {
262
+ hours = hours.map(h => typeof h === 'string' ? parseInt(h, 10) : Number(h)).filter(h => !Number.isNaN(h));
263
+ }
264
+ if (this.state.variable === 'ptypeRefl' && this.state.model === 'hrrr' && hours.length > 0) {
265
+ hours = hours.filter(hour => hour !== 0);
266
+ }
267
+ return hours;
268
+ }
269
+ _computeSatelliteTimeline() {
270
+ var _this$satelliteListin;
271
+ const {
272
+ satelliteInstrumentId,
273
+ satelliteSectorLabel,
274
+ satelliteChannel
275
+ } = this.state;
276
+ if (!satelliteInstrumentId || !satelliteSectorLabel || !satelliteChannel || !((_this$satelliteListin = this.satelliteListing) !== null && _this$satelliteListin !== void 0 && _this$satelliteListin.objects)) {
277
+ return {
278
+ unixTimes: [],
279
+ timeToFileMap: {}
280
+ };
281
+ }
282
+ const allFiles = this.satelliteListing.objects.map(o => o.key);
283
+ const sectorName = satelliteSectorLabel;
284
+ const tier = this.state.satelliteTier || 'basic';
285
+ const durationOpt = (0, _satellite_support.resolveSatelliteDurationOption)(sectorName, tier, this.state.satelliteDurationValue);
286
+ return (0, _satellite_support.buildSatelliteTimelineForSelection)({
287
+ satelliteInstrumentId,
288
+ satelliteSectorLabel,
289
+ satelliteChannel
290
+ }, allFiles, durationOpt);
291
+ }
292
+
293
+ /**
294
+ * MRMS timestamps for a variable, oldest first (matches satellite timelines and left→right time sliders).
295
+ * Limited to the last N hours relative to the newest frame (see mrmsDurationValue).
296
+ * @param {string} variable
297
+ * @returns {number[]}
298
+ */
299
+ _getFilteredMrmsTimestampsForVariable(variable) {
300
+ var _this$mrmsStatus;
301
+ const raw = (_this$mrmsStatus = this.mrmsStatus) === null || _this$mrmsStatus === void 0 ? void 0 : _this$mrmsStatus[variable];
302
+ if (!raw || !raw.length) return [];
303
+ const hours = (0, _satellite_support.parseTimelineDurationHours)(this.state.mrmsDurationValue);
304
+ let list = [...raw].map(t => Number(t)).filter(t => !Number.isNaN(t)).sort((a, b) => a - b);
305
+ if (hours > 0 && list.length > 0) {
306
+ const latest = list[list.length - 1];
307
+ const cutoff = latest - hours * 3600;
308
+ list = list.filter(t => t >= cutoff);
309
+ }
310
+ return list;
311
+ }
312
+ _nexradListingWindowHours() {
313
+ return (0, _satellite_support.parseTimelineDurationHours)(this.state.nexradDurationValue);
314
+ }
315
+ _getFilteredNexradTimestampsForVariable(rawList) {
316
+ if (!rawList || !rawList.length) return [];
317
+ const hours = this._nexradListingWindowHours();
318
+ let list = [...rawList].map(t => Number(t)).filter(t => !Number.isNaN(t)).sort((a, b) => a - b);
319
+ if (hours > 0 && list.length > 0) {
320
+ const latest = list[list.length - 1];
321
+ const cutoff = latest - hours * 3600;
322
+ list = list.filter(t => t >= cutoff);
323
+ }
324
+ return list;
325
+ }
326
+
327
+ /**
328
+ * Cache key for {@link this.nexradTimesByStation} — matches frontend composite keys (tilt + variable group/source).
329
+ */
330
+ _nexradTimesCacheKey() {
331
+ var _getNexradLevel3Entry;
332
+ const s = this.state;
333
+ if (!s.isNexrad || !s.nexradSite) return null;
334
+ const site = s.nexradSite;
335
+ const variable = s.nexradProduct || 'REF';
336
+ const ds = s.nexradDataSource || 'level2';
337
+ const tiltNum = s.nexradTilt != null ? s.nexradTilt : (0, _nexradTilts.getDefaultRadarTilt)(site);
338
+ const elevNormUse = ds === 'level3' ? _nexrad_level3_catalog.NEXRAD_LEVEL3_ELEV : (0, _nexradTilts.formatTiltForApi)((0, _nexradTilts.clampNexradTiltForVariable)(site, variable, tiltNum));
339
+ const group = ds === 'level3' ? 'l3' : (0, _nexrad_support.variableToNexradGroup)(variable);
340
+ const l3Product = ds === 'level3' ? ((_getNexradLevel3Entry = (0, _nexrad_level3_catalog.getNexradLevel3EntryByRadarKey)(variable)) === null || _getNexradLevel3Entry === void 0 ? void 0 : _getNexradLevel3Entry.product) ?? (variable === 'VEL' ? 'N0G' : variable) : '';
341
+ if (ds === 'level3') {
342
+ return `${site}_l3_${l3Product}_${elevNormUse}`;
343
+ }
344
+ return `${site}_${group}_${elevNormUse}`;
345
+ }
109
346
  _emitStateChange() {
110
- var _this$modelStatus, _this$modelStatus2;
347
+ var _this$modelStatus;
111
348
  const {
112
349
  colormap,
113
350
  baseUnit
@@ -116,23 +353,45 @@ class AguaceroCore extends _events.EventEmitter {
116
353
  const displayColormap = this._convertColormapUnits(colormap, baseUnit, toUnit);
117
354
  let availableTimestamps = [];
118
355
  if (this.state.isMRMS && this.state.variable && this.mrmsStatus) {
119
- const timestamps = this.mrmsStatus[this.state.variable] || [];
120
- availableTimestamps = [...timestamps].reverse();
356
+ availableTimestamps = this._getFilteredMrmsTimestampsForVariable(this.state.variable);
121
357
  }
122
- let availableHours = this.state.isMRMS ? [] : ((_this$modelStatus = this.modelStatus) === null || _this$modelStatus === void 0 || (_this$modelStatus = _this$modelStatus[this.state.model]) === null || _this$modelStatus === void 0 || (_this$modelStatus = _this$modelStatus[this.state.date]) === null || _this$modelStatus === void 0 ? void 0 : _this$modelStatus[this.state.run]) || [];
123
- if (!this.state.isMRMS && this.state.variable === 'ptypeRefl' && this.state.model === 'hrrr' && availableHours.length > 0) {
124
- availableHours = availableHours.filter(hour => hour !== 0);
358
+ let availableSatelliteTimestamps = [];
359
+ let satelliteTimeToFileMap = {};
360
+ if (this.state.isSatellite && this.state.satelliteInstrumentId) {
361
+ const timeline = this._computeSatelliteTimeline();
362
+ satelliteTimeToFileMap = timeline.timeToFileMap || {};
363
+ availableSatelliteTimestamps = [...(timeline.unixTimes || [])].map(t => Number(t)).filter(t => !Number.isNaN(t)).sort((a, b) => a - b);
125
364
  }
365
+ let availableNexradTimestamps = [];
366
+ let nexradTimeToKeyMap = {};
367
+ let nexradLevel3MotionTimeToKeyMap = {};
368
+ let availableNexradTilts = [];
369
+ if (this.state.isNexrad && this.state.nexradSite) {
370
+ const nk = this._nexradTimesCacheKey();
371
+ const ent = nk ? this.nexradTimesByStation[nk] : null;
372
+ const raw = (ent === null || ent === void 0 ? void 0 : ent.unixTimes) || [];
373
+ availableNexradTimestamps = this._getFilteredNexradTimestampsForVariable(raw);
374
+ nexradTimeToKeyMap = (ent === null || ent === void 0 ? void 0 : ent.timeToKeyMap) || {};
375
+ nexradLevel3MotionTimeToKeyMap = (ent === null || ent === void 0 ? void 0 : ent.level3MotionTimeToKeyMap) || {};
376
+ availableNexradTilts = (0, _nexrad_support.getAvailableNexradTilts)(this.state.nexradSite, this.state.nexradDataSource || 'level2', this.state.nexradProduct || 'REF');
377
+ }
378
+ const availableHours = this.getAvailableForecastHours();
126
379
  const eventPayload = {
127
380
  ...this.state,
128
381
  availableModels: this.modelStatus ? Object.keys(this.modelStatus).sort() : [],
129
- availableRuns: ((_this$modelStatus2 = this.modelStatus) === null || _this$modelStatus2 === void 0 ? void 0 : _this$modelStatus2[this.state.model]) || {},
382
+ availableRuns: ((_this$modelStatus = this.modelStatus) === null || _this$modelStatus === void 0 ? void 0 : _this$modelStatus[this.state.model]) || {},
130
383
  availableHours: availableHours,
131
384
  // <-- Changed from inline calculation
132
385
  availableVariables: this.getAvailableVariables(this.state.isMRMS ? 'mrms' : this.state.model),
133
386
  // We need to confirm this line is working as expected.
134
387
  availableMRMSVariables: this.getAvailableVariables('mrms'),
135
388
  availableTimestamps: availableTimestamps,
389
+ availableSatelliteTimestamps,
390
+ satelliteTimeToFileMap,
391
+ availableNexradTimestamps,
392
+ nexradTimeToKeyMap,
393
+ nexradLevel3MotionTimeToKeyMap,
394
+ availableNexradTilts,
136
395
  isPlaying: this.isPlaying,
137
396
  colormap: displayColormap,
138
397
  colormapBaseUnit: toUnit
@@ -142,28 +401,34 @@ class AguaceroCore extends _events.EventEmitter {
142
401
  async initialize(options = {}) {
143
402
  await this.fetchModelStatus(true);
144
403
  await this.fetchMRMSStatus(true);
404
+ await this.fetchSatelliteListing(true);
145
405
  let initialState = {
146
406
  ...this.state
147
407
  };
408
+ if (initialState.isSatellite && initialState.satelliteInstrumentId) {
409
+ const timeline = this._computeSatelliteTimeline();
410
+ const tsList = [...(timeline.unixTimes || [])].sort((a, b) => a - b);
411
+ if (initialState.satelliteTimestamp == null && tsList.length > 0) {
412
+ initialState.satelliteTimestamp = tsList[tsList.length - 1];
413
+ }
414
+ }
148
415
 
149
416
  // ADD: Logic to handle an initial MRMS state
150
417
  if (initialState.isMRMS) {
151
418
  const variable = initialState.variable;
152
419
  if (variable && this.mrmsStatus && this.mrmsStatus[variable]) {
153
- const sortedTimestamps = [...(this.mrmsStatus[variable] || [])].sort((a, b) => b - a);
154
- initialState.mrmsTimestamp = sortedTimestamps.length > 0 ? sortedTimestamps[0] : null;
420
+ const sortedTimestamps = this._getFilteredMrmsTimestampsForVariable(variable);
421
+ initialState.mrmsTimestamp = sortedTimestamps.length > 0 ? sortedTimestamps[sortedTimestamps.length - 1] : null;
155
422
  } else {
156
- // Fallback if the provided variable is not valid
157
- console.warn(`Initial MRMS variable '${variable}' not found. Using default.`);
158
423
  const availableMRMSVars = this.getAvailableVariables('mrms');
159
424
  if (availableMRMSVars.length > 0) {
160
425
  const firstVar = availableMRMSVars[0];
161
426
  initialState.variable = firstVar;
162
- const sortedTimestamps = [...(this.mrmsStatus[firstVar] || [])].sort((a, b) => b - a);
163
- initialState.mrmsTimestamp = sortedTimestamps.length > 0 ? sortedTimestamps[0] : null;
427
+ const sortedTimestamps = this._getFilteredMrmsTimestampsForVariable(firstVar);
428
+ initialState.mrmsTimestamp = sortedTimestamps.length > 0 ? sortedTimestamps[sortedTimestamps.length - 1] : null;
164
429
  }
165
430
  }
166
- } else {
431
+ } else if (!initialState.isSatellite && !initialState.isNexrad) {
167
432
  // EDIT: This is the existing logic, now in an else block
168
433
  const latestRun = findLatestModelRun(this.modelStatus, initialState.model);
169
434
  if (latestRun) {
@@ -181,6 +446,13 @@ class AguaceroCore extends _events.EventEmitter {
181
446
  }
182
447
  }
183
448
  await this.setState(initialState);
449
+ if (this.state.isNexrad) {
450
+ const manifest = await (0, _nexradTilts.fetchRadarTiltsManifestFromNetwork)();
451
+ if (manifest) (0, _nexradTilts.setRadarTiltsManifest)(manifest);
452
+ if (this.state.nexradSite) {
453
+ await this.refreshNexradTimes();
454
+ }
455
+ }
184
456
  if (options.autoRefresh ?? this.autoRefreshEnabled) {
185
457
  this.startAutoRefresh(options.refreshInterval ?? this.autoRefreshIntervalSeconds);
186
458
  }
@@ -189,11 +461,11 @@ class AguaceroCore extends _events.EventEmitter {
189
461
  this.pause();
190
462
  this.stopAutoRefresh();
191
463
  this.dataCache.clear();
192
- if (this.worker) {
193
- this.worker.terminate();
194
- }
195
464
  this.callbacks = {};
196
- console.log(`AguaceroCore has been destroyed.`);
465
+ if (this._gridDecodeWorker) {
466
+ this._gridDecodeWorker.terminate();
467
+ this._gridDecodeWorker = null;
468
+ }
197
469
  }
198
470
 
199
471
  // --- Public API Methods ---
@@ -222,6 +494,21 @@ class AguaceroCore extends _events.EventEmitter {
222
494
  this.isPlaying ? this.pause() : this.play();
223
495
  }
224
496
  step(direction = 1) {
497
+ if (this.state.isSatellite) {
498
+ const timeline = this._computeSatelliteTimeline();
499
+ const availableTimestamps = [...(timeline.unixTimes || [])].sort((a, b) => a - b).map(t => Number(t));
500
+ if (availableTimestamps.length === 0) return;
501
+ const ts = this.state.satelliteTimestamp == null ? null : Number(this.state.satelliteTimestamp);
502
+ const currentIndex = ts == null ? -1 : availableTimestamps.indexOf(ts);
503
+ let nextIndex = currentIndex === -1 ? availableTimestamps.length - 1 : currentIndex + direction;
504
+ const maxIndex = availableTimestamps.length - 1;
505
+ if (nextIndex > maxIndex) nextIndex = 0;
506
+ if (nextIndex < 0) nextIndex = maxIndex;
507
+ this.setState({
508
+ satelliteTimestamp: availableTimestamps[nextIndex]
509
+ });
510
+ return;
511
+ }
225
512
  // --- THIS IS THE CORRECTED MRMS LOGIC ---
226
513
  if (this.state.isMRMS) {
227
514
  const {
@@ -229,19 +516,16 @@ class AguaceroCore extends _events.EventEmitter {
229
516
  mrmsTimestamp
230
517
  } = this.state;
231
518
  if (!this.mrmsStatus || !this.mrmsStatus[variable]) {
232
- console.warn('[Core.step] MRMS status or variable not available.');
233
519
  return;
234
520
  }
235
-
236
- // CRITICAL FIX: The UI and state emissions use a REVERSED array (newest first).
237
- // The step logic MUST use the same reversed array for indexes to match.
238
- const availableTimestamps = [...(this.mrmsStatus[variable] || [])].reverse();
521
+ const availableTimestamps = this._getFilteredMrmsTimestampsForVariable(variable);
239
522
  if (availableTimestamps.length === 0) return;
240
- const currentIndex = availableTimestamps.indexOf(mrmsTimestamp);
523
+ const ts = mrmsTimestamp == null ? null : Number(mrmsTimestamp);
524
+ const currentIndex = ts == null ? -1 : availableTimestamps.indexOf(ts);
241
525
  if (currentIndex === -1) {
242
- // If not found, reset to the first (newest) frame
526
+ // If not found, reset to the latest frame (end of ascending list)
243
527
  this.setState({
244
- mrmsTimestamp: availableTimestamps[0]
528
+ mrmsTimestamp: availableTimestamps[availableTimestamps.length - 1]
245
529
  });
246
530
  return;
247
531
  }
@@ -255,17 +539,29 @@ class AguaceroCore extends _events.EventEmitter {
255
539
  this.setState({
256
540
  mrmsTimestamp: newTimestamp
257
541
  });
542
+ } else if (this.state.isNexrad) {
543
+ var _this$nexradTimesBySt;
544
+ const nk = this._nexradTimesCacheKey();
545
+ const raw = nk ? (_this$nexradTimesBySt = this.nexradTimesByStation[nk]) === null || _this$nexradTimesBySt === void 0 ? void 0 : _this$nexradTimesBySt.unixTimes : [];
546
+ const availableTimestamps = this._getFilteredNexradTimestampsForVariable(raw || []);
547
+ if (availableTimestamps.length === 0) return;
548
+ const ts = this.state.nexradTimestamp == null ? null : Number(this.state.nexradTimestamp);
549
+ const currentIndex = ts == null ? -1 : availableTimestamps.indexOf(ts);
550
+ let nextIndex = currentIndex === -1 ? availableTimestamps.length - 1 : currentIndex + direction;
551
+ const maxIndex = availableTimestamps.length - 1;
552
+ if (nextIndex > maxIndex) nextIndex = 0;
553
+ if (nextIndex < 0) nextIndex = maxIndex;
554
+ this.setState({
555
+ nexradTimestamp: availableTimestamps[nextIndex]
556
+ });
258
557
  } else {
259
- var _this$modelStatus3;
260
558
  const {
261
- model,
262
- date,
263
- run,
264
559
  forecastHour
265
560
  } = this.state;
266
- const forecastHours = (_this$modelStatus3 = this.modelStatus) === null || _this$modelStatus3 === void 0 || (_this$modelStatus3 = _this$modelStatus3[model]) === null || _this$modelStatus3 === void 0 || (_this$modelStatus3 = _this$modelStatus3[date]) === null || _this$modelStatus3 === void 0 ? void 0 : _this$modelStatus3[run];
561
+ const forecastHours = this.getAvailableForecastHours();
267
562
  if (!forecastHours || forecastHours.length === 0) return;
268
- const currentIndex = forecastHours.indexOf(forecastHour);
563
+ const fh = Number(forecastHour);
564
+ const currentIndex = forecastHours.indexOf(fh);
269
565
  if (currentIndex === -1) return;
270
566
  const maxIndex = forecastHours.length - 1;
271
567
  let nextIndex = currentIndex + direction;
@@ -299,8 +595,7 @@ class AguaceroCore extends _events.EventEmitter {
299
595
  async setVariable(variable) {
300
596
  // --- NEW CODE: Handle switching TO ptypeRefl on HRRR ---
301
597
  if (variable === 'ptypeRefl' && this.state.model === 'hrrr' && this.state.forecastHour === 0) {
302
- var _this$modelStatus4;
303
- const availableHours = ((_this$modelStatus4 = this.modelStatus) === null || _this$modelStatus4 === void 0 || (_this$modelStatus4 = _this$modelStatus4[this.state.model]) === null || _this$modelStatus4 === void 0 || (_this$modelStatus4 = _this$modelStatus4[this.state.date]) === null || _this$modelStatus4 === void 0 ? void 0 : _this$modelStatus4[this.state.run]) || [];
598
+ const availableHours = resolveModelRunHours(this.modelStatus, this.state.model, this.state.date, this.state.run).hours || [];
304
599
  const firstValidHour = availableHours.find(hour => hour !== 0) || 0;
305
600
  await this.setState({
306
601
  variable,
@@ -315,8 +610,17 @@ class AguaceroCore extends _events.EventEmitter {
315
610
  });
316
611
  }
317
612
  async setModel(modelName) {
318
- var _this$modelStatus5;
319
- if (modelName === this.state.model || !((_this$modelStatus5 = this.modelStatus) !== null && _this$modelStatus5 !== void 0 && _this$modelStatus5[modelName])) return;
613
+ var _this$modelStatus2;
614
+ if (modelName === this.state.model || !((_this$modelStatus2 = this.modelStatus) !== null && _this$modelStatus2 !== void 0 && _this$modelStatus2[modelName])) return;
615
+ if (this.state.isSatellite) {
616
+ await this.setState({
617
+ isSatellite: false,
618
+ satelliteInstrumentId: null,
619
+ satelliteSectorLabel: null,
620
+ satelliteChannel: null,
621
+ satelliteTimestamp: null
622
+ });
623
+ }
320
624
  const latestRun = findLatestModelRun(this.modelStatus, modelName);
321
625
  if (latestRun) {
322
626
  // --- NEW CODE: Determine initial forecast hour ---
@@ -324,9 +628,7 @@ class AguaceroCore extends _events.EventEmitter {
324
628
 
325
629
  // If switching to HRRR with ptypeRefl, start at hour 1 instead of 0
326
630
  if (modelName === 'hrrr' && this.state.variable === 'ptypeRefl') {
327
- var _this$modelStatus6;
328
- const availableHours = ((_this$modelStatus6 = this.modelStatus) === null || _this$modelStatus6 === void 0 || (_this$modelStatus6 = _this$modelStatus6[modelName]) === null || _this$modelStatus6 === void 0 || (_this$modelStatus6 = _this$modelStatus6[latestRun.date]) === null || _this$modelStatus6 === void 0 ? void 0 : _this$modelStatus6[latestRun.run]) || [];
329
- // Find the first valid hour (should be 1)
631
+ const availableHours = resolveModelRunHours(this.modelStatus, modelName, latestRun.date, latestRun.run).hours || [];
330
632
  initialHour = availableHours.find(hour => hour !== 0) || 0;
331
633
  }
332
634
  // --- END NEW CODE ---
@@ -356,11 +658,16 @@ class AguaceroCore extends _events.EventEmitter {
356
658
  });
357
659
  }
358
660
  async setMRMSVariable(variable) {
359
- const sortedTimestamps = [...(this.mrmsStatus[variable] || [])].sort((a, b) => b - a);
360
- const initialTimestamp = sortedTimestamps.length > 0 ? sortedTimestamps[0] : null;
661
+ const sortedTimestamps = this._getFilteredMrmsTimestampsForVariable(variable);
662
+ const initialTimestamp = sortedTimestamps.length > 0 ? sortedTimestamps[sortedTimestamps.length - 1] : null;
361
663
  await this.setState({
362
664
  variable,
363
665
  isMRMS: true,
666
+ isSatellite: false,
667
+ satelliteInstrumentId: null,
668
+ satelliteSectorLabel: null,
669
+ satelliteChannel: null,
670
+ satelliteTimestamp: null,
364
671
  mrmsTimestamp: initialTimestamp
365
672
  });
366
673
  }
@@ -370,42 +677,184 @@ class AguaceroCore extends _events.EventEmitter {
370
677
  mrmsTimestamp: timestamp
371
678
  });
372
679
  }
680
+ async setSatelliteTimestamp(timestamp) {
681
+ if (!this.state.isSatellite) return;
682
+ await this.setState({
683
+ satelliteTimestamp: timestamp != null ? Number(timestamp) : null
684
+ });
685
+ }
686
+
687
+ /**
688
+ * How many hours of satellite frames to include in the timeline (positive, at most 12 hours).
689
+ * API default: `layerOptions.satelliteDurationValue` on construction.
690
+ */
691
+ async setSatelliteDurationValue(value) {
692
+ const v = (0, _satellite_support.formatTimelineDurationValue)(value);
693
+ await this.setState({
694
+ satelliteDurationValue: v
695
+ });
696
+ if (!this.state.isSatellite || !this.state.satelliteInstrumentId) return;
697
+ const timeline = this._computeSatelliteTimeline();
698
+ const tsList = [...(timeline.unixTimes || [])].map(t => Number(t)).filter(t => !Number.isNaN(t)).sort((a, b) => a - b);
699
+ if (tsList.length === 0) return;
700
+ const cur = this.state.satelliteTimestamp;
701
+ const curN = cur == null ? null : Number(cur);
702
+ if (curN == null || !tsList.includes(curN)) {
703
+ await this.setState({
704
+ satelliteTimestamp: tsList[tsList.length - 1]
705
+ });
706
+ }
707
+ }
708
+
709
+ /**
710
+ * Set satellite view using spacecraft id, sector, and channel/product.
711
+ * Omitted fields keep the current selection; when not in satellite mode, missing fields use GOES-19 East CONUS / C13 defaults.
712
+ * @param {{ satelliteId?: string, sector?: string, satelliteSector?: string, satelliteProduct?: string, satelliteChannel?: string, satelliteTimestamp?: number|null }} opts
713
+ */
714
+ async setSatelliteSelection(opts = {}) {
715
+ const cur = this.state.isSatellite ? this.state : null;
716
+ const tsArg = opts.satelliteTimestamp !== undefined ? opts.satelliteTimestamp : this.state.isSatellite && this.state.satelliteTimestamp != null ? Number(this.state.satelliteTimestamp) : undefined;
717
+ return this.switchMode({
718
+ mode: 'satellite',
719
+ satelliteId: opts.satelliteId ?? (cur === null || cur === void 0 ? void 0 : cur.satelliteInstrumentId) ?? 'GOES19-EAST',
720
+ satelliteSector: opts.satelliteSector ?? opts.sector ?? (cur === null || cur === void 0 ? void 0 : cur.satelliteSectorLabel) ?? 'conus',
721
+ satelliteProduct: opts.satelliteProduct ?? opts.satelliteChannel ?? (cur === null || cur === void 0 ? void 0 : cur.satelliteChannel) ?? 'C13',
722
+ satelliteTimestamp: tsArg
723
+ });
724
+ }
725
+
726
+ /**
727
+ * How many hours of MRMS frames to include in the timeline (positive, at most 12 hours).
728
+ * API default: `layerOptions.mrmsDurationValue` on construction.
729
+ */
730
+ async setMRMSDurationValue(value) {
731
+ const v = (0, _satellite_support.formatTimelineDurationValue)(value);
732
+ await this.setState({
733
+ mrmsDurationValue: v
734
+ });
735
+ if (!this.state.isMRMS || !this.state.variable) return;
736
+ const filtered = this._getFilteredMrmsTimestampsForVariable(this.state.variable);
737
+ if (filtered.length === 0) return;
738
+ const cur = this.state.mrmsTimestamp;
739
+ const curN = cur == null ? null : Number(cur);
740
+ if (curN == null || !filtered.includes(curN)) {
741
+ await this.setState({
742
+ mrmsTimestamp: filtered[filtered.length - 1]
743
+ });
744
+ }
745
+ }
746
+
747
+ /**
748
+ * NEXRAD sweep listing / scrub window in hours (independent of MRMS duration).
749
+ * API default: `layerOptions.nexradDurationValue` on construction.
750
+ */
751
+ async setNexradDurationValue(value) {
752
+ var _this$nexradTimesBySt2;
753
+ const v = (0, _satellite_support.formatTimelineDurationValue)(value);
754
+ await this.setState({
755
+ nexradDurationValue: v
756
+ });
757
+ if (!this.state.isNexrad || !this.state.nexradSite) return;
758
+ await this.refreshNexradTimes();
759
+ const nk = this._nexradTimesCacheKey();
760
+ const filtered = this._getFilteredNexradTimestampsForVariable(nk ? ((_this$nexradTimesBySt2 = this.nexradTimesByStation[nk]) === null || _this$nexradTimesBySt2 === void 0 ? void 0 : _this$nexradTimesBySt2.unixTimes) || [] : []);
761
+ if (filtered.length === 0) return;
762
+ const cur = this.state.nexradTimestamp;
763
+ const curN = cur == null ? null : Number(cur);
764
+ if (curN == null || !filtered.includes(curN)) {
765
+ await this.setState({
766
+ nexradTimestamp: filtered[filtered.length - 1]
767
+ });
768
+ }
769
+ }
373
770
  async switchMode(options) {
374
- const {
771
+ let {
375
772
  mode,
376
773
  variable,
377
774
  model,
378
775
  forecastHour,
379
- mrmsTimestamp
776
+ mrmsTimestamp,
777
+ satelliteTimestamp
380
778
  } = options;
381
- if (!mode || !variable) {
382
- console.error("switchMode requires 'mode' ('mrms' | 'model') and 'variable' properties.");
779
+ if (!mode) {
383
780
  return;
384
781
  }
385
782
  if (mode === 'model' && !model) {
386
- console.error("switchMode with mode 'model' requires a 'model' property.");
783
+ return;
784
+ }
785
+ if ((mode === 'mrms' || mode === 'model') && !variable) {
387
786
  return;
388
787
  }
389
788
  let targetState = {};
390
- if (mode === 'mrms') {
789
+ if (mode === 'satellite') {
790
+ var _this$satelliteListin2;
791
+ const satelliteInstrumentId = options.satelliteId ?? 'GOES19-EAST';
792
+ const satelliteSectorLabel = (0, _satellite_support.resolveSatelliteSectorLabel)(options.satelliteSector ?? options.sector ?? 'conus');
793
+ const satelliteChannel = options.satelliteProduct ?? options.satelliteChannel ?? variable ?? 'C13';
794
+ const channelToken = satelliteChannel;
795
+ // Emit satellite mode immediately so map layers (e.g. model grid) clear before listing fetch finishes.
796
+ await this.setState({
797
+ isSatellite: true,
798
+ isMRMS: false,
799
+ isNexrad: false,
800
+ satelliteInstrumentId,
801
+ satelliteSectorLabel,
802
+ satelliteChannel,
803
+ variable: channelToken,
804
+ satelliteTimestamp: null,
805
+ mrmsTimestamp: null,
806
+ date: null,
807
+ run: null,
808
+ forecastHour: 0
809
+ });
810
+ await this.fetchSatelliteListing(true);
811
+ const allFiles = ((_this$satelliteListin2 = this.satelliteListing) === null || _this$satelliteListin2 === void 0 || (_this$satelliteListin2 = _this$satelliteListin2.objects) === null || _this$satelliteListin2 === void 0 ? void 0 : _this$satelliteListin2.map(o => o.key)) || [];
812
+ const sectorName = satelliteSectorLabel;
813
+ const tier = this.state.satelliteTier || 'basic';
814
+ const durationOpt = (0, _satellite_support.resolveSatelliteDurationOption)(sectorName, tier, this.state.satelliteDurationValue);
815
+ const timeline = (0, _satellite_support.buildSatelliteTimelineForSelection)({
816
+ satelliteInstrumentId,
817
+ satelliteSectorLabel,
818
+ satelliteChannel
819
+ }, allFiles, durationOpt);
820
+ const tsList = [...(timeline.unixTimes || [])].sort((a, b) => a - b);
821
+ let finalTs = satelliteTimestamp !== undefined ? satelliteTimestamp : null;
822
+ if (finalTs == null && tsList.length > 0) {
823
+ finalTs = tsList[tsList.length - 1];
824
+ }
825
+ await this.setState({
826
+ satelliteTimestamp: finalTs != null ? Number(finalTs) : null
827
+ });
828
+ return;
829
+ } else if (mode === 'mrms') {
830
+ const sortedTimestamps = this._getFilteredMrmsTimestampsForVariable(variable);
391
831
  let finalTimestamp = mrmsTimestamp;
392
832
  if (finalTimestamp === undefined) {
393
- const sortedTimestamps = [...(this.mrmsStatus[variable] || [])].sort((a, b) => b - a);
394
- finalTimestamp = sortedTimestamps.length > 0 ? sortedTimestamps[0] : null;
833
+ finalTimestamp = sortedTimestamps.length > 0 ? sortedTimestamps[sortedTimestamps.length - 1] : null;
834
+ } else if (sortedTimestamps.length > 0) {
835
+ const n = Number(finalTimestamp);
836
+ if (!sortedTimestamps.includes(n)) {
837
+ finalTimestamp = sortedTimestamps[sortedTimestamps.length - 1];
838
+ }
395
839
  }
396
840
  targetState = {
397
841
  isMRMS: true,
842
+ isSatellite: false,
843
+ isNexrad: false,
398
844
  variable: variable,
399
845
  mrmsTimestamp: finalTimestamp,
400
846
  model: this.state.model,
401
847
  date: null,
402
848
  run: null,
403
- forecastHour: 0
849
+ forecastHour: 0,
850
+ satelliteInstrumentId: null,
851
+ satelliteSectorLabel: null,
852
+ satelliteChannel: null,
853
+ satelliteTimestamp: null
404
854
  };
405
855
  } else if (mode === 'model') {
406
856
  const latestRun = findLatestModelRun(this.modelStatus, model);
407
857
  if (!latestRun) {
408
- console.error(`Could not find a valid run for model: ${model}`);
409
858
  return;
410
859
  }
411
860
 
@@ -414,50 +863,289 @@ class AguaceroCore extends _events.EventEmitter {
414
863
 
415
864
  // If switching to HRRR with ptypeRefl and hour is 0, use hour 1
416
865
  if (model === 'hrrr' && variable === 'ptypeRefl' && initialHour === 0) {
417
- var _this$modelStatus7;
418
- const availableHours = ((_this$modelStatus7 = this.modelStatus) === null || _this$modelStatus7 === void 0 || (_this$modelStatus7 = _this$modelStatus7[model]) === null || _this$modelStatus7 === void 0 || (_this$modelStatus7 = _this$modelStatus7[latestRun.date]) === null || _this$modelStatus7 === void 0 ? void 0 : _this$modelStatus7[latestRun.run]) || [];
866
+ const availableHours = resolveModelRunHours(this.modelStatus, model, latestRun.date, latestRun.run).hours || [];
419
867
  initialHour = availableHours.find(hour => hour !== 0) || 0;
420
868
  }
421
869
  // --- END NEW CODE ---
422
870
 
423
871
  targetState = {
424
872
  isMRMS: false,
873
+ isSatellite: false,
874
+ isNexrad: false,
425
875
  model: model,
426
876
  variable: variable,
427
877
  date: latestRun.date,
428
878
  run: latestRun.run,
429
879
  forecastHour: initialHour,
430
880
  // <-- Changed
431
- mrmsTimestamp: null
881
+ mrmsTimestamp: null,
882
+ satelliteInstrumentId: null,
883
+ satelliteSectorLabel: null,
884
+ satelliteChannel: null,
885
+ satelliteTimestamp: null
432
886
  };
887
+ } else if (mode === 'nexrad') {
888
+ const nexradProduct = options.nexradProduct || 'REF';
889
+ const p = nexradProduct.toUpperCase();
890
+ const site = options.nexradSite ?? null;
891
+ let tilt = options.nexradTilt != null ? Number(options.nexradTilt) : site != null ? (0, _nexradTilts.getDefaultRadarTilt)(site) : null;
892
+ await this.setState({
893
+ isNexrad: true,
894
+ isMRMS: false,
895
+ isSatellite: false,
896
+ nexradSite: site,
897
+ nexradProduct: p,
898
+ nexradTilt: tilt,
899
+ nexradTimestamp: options.nexradTimestamp != null ? Number(options.nexradTimestamp) : null,
900
+ nexradStormRelative: options.nexradStormRelative === true,
901
+ nexradShowSitesPicker: options.nexradShowSitesPicker !== false,
902
+ ...(options.nexradDurationValue != null ? {
903
+ nexradDurationValue: (0, _satellite_support.formatTimelineDurationValue)(options.nexradDurationValue)
904
+ } : {}),
905
+ mrmsTimestamp: null,
906
+ satelliteInstrumentId: null,
907
+ satelliteSectorLabel: null,
908
+ satelliteChannel: null,
909
+ satelliteTimestamp: null,
910
+ date: null,
911
+ run: null,
912
+ forecastHour: 0
913
+ });
914
+ const manifest = await (0, _nexradTilts.fetchRadarTiltsManifestFromNetwork)();
915
+ if (manifest) (0, _nexradTilts.setRadarTiltsManifest)(manifest);
916
+ if (site) {
917
+ var _this$nexradTimesBySt3;
918
+ await this.refreshNexradTimes();
919
+ const filtered = this._getFilteredNexradTimestampsForVariable(((_this$nexradTimesBySt3 = this.nexradTimesByStation[this._nexradTimesCacheKey()]) === null || _this$nexradTimesBySt3 === void 0 ? void 0 : _this$nexradTimesBySt3.unixTimes) || []);
920
+ let ts = options.nexradTimestamp != null ? Number(options.nexradTimestamp) : this.state.nexradTimestamp;
921
+ if (ts == null && filtered.length > 0) ts = filtered[filtered.length - 1];else if (ts != null && filtered.length > 0 && !filtered.includes(ts)) {
922
+ ts = filtered[filtered.length - 1];
923
+ }
924
+ await this.setState({
925
+ nexradTimestamp: ts
926
+ });
927
+ }
928
+ return;
433
929
  } else {
434
- console.error(`Invalid mode specified in switchMode: '${mode}'`);
435
930
  return;
436
931
  }
437
932
  await this.setState(targetState);
438
933
  }
439
934
 
935
+ /**
936
+ * Keep `nexradTilt` on a coalesced elevation (same rules as aguacero-frontend tilt controls).
937
+ */
938
+ async _snapNexradTiltToAvailableOptions() {
939
+ const s = this.state;
940
+ if (!s.isNexrad || !s.nexradSite) return;
941
+ const raw = (0, _nexrad_support.getRawNexradTiltsForCoalesce)(s.nexradSite, s.nexradDataSource || 'level2', s.nexradProduct || 'REF');
942
+ if (!raw.length) return;
943
+ const t = s.nexradTilt;
944
+ const target = t != null && Number.isFinite(Number(t)) ? Number(t) : (0, _nexradTilts.getDefaultRadarTilt)(s.nexradSite);
945
+ const canonical = (0, _nexradTiltCoalesce.nexradLayerTiltToDisplayOption)(target, raw);
946
+ const match = (a, b) => Math.abs(Number(a) - Number(b)) < 1e-4;
947
+ if (match(s.nexradTilt, canonical)) return;
948
+ await this.setState({
949
+ nexradTilt: canonical
950
+ });
951
+ }
952
+ async refreshNexradTimes() {
953
+ var _getNexradLevel3Entry2, _out$level3MotionUnix, _out$level3MotionUnix2;
954
+ const s0 = this.state;
955
+ if (!s0.isNexrad || !s0.nexradSite) return;
956
+ await this._snapNexradTiltToAvailableOptions();
957
+ const s = this.state;
958
+ const listingHours = this._nexradListingWindowHours();
959
+ const out = await (0, _nexrad_support.fetchNexradTimesListing)({
960
+ stationId: s.nexradSite,
961
+ variable: s.nexradProduct || 'REF',
962
+ elev: s.nexradTilt,
963
+ source: s.nexradDataSource || 'level2',
964
+ level3StormRelative: s.nexradStormRelative,
965
+ level3Product: (_getNexradLevel3Entry2 = (0, _nexrad_level3_catalog.getNexradLevel3EntryByRadarKey)(s.nexradProduct)) === null || _getNexradLevel3Entry2 === void 0 ? void 0 : _getNexradLevel3Entry2.product,
966
+ listingWindowHours: listingHours
967
+ });
968
+ const nk = this._nexradTimesCacheKey();
969
+ if (!nk) return;
970
+ this.nexradTimesByStation[nk] = {
971
+ unixTimes: out.unixTimes,
972
+ timeToKeyMap: out.timeToKeyMap,
973
+ level3MotionTimeToKeyMap: out.level3MotionTimeToKeyMap,
974
+ listWindowHours: listingHours
975
+ };
976
+ if (out.level3MotionKey && (_out$level3MotionUnix = out.level3MotionUnixTimes) !== null && _out$level3MotionUnix !== void 0 && _out$level3MotionUnix.length) {
977
+ this.nexradTimesByStation[out.level3MotionKey] = {
978
+ unixTimes: out.level3MotionUnixTimes,
979
+ timeToKeyMap: out.level3MotionTimeToKeyMap,
980
+ listWindowHours: listingHours
981
+ };
982
+ }
983
+ if (out.l2StormMotionListKey && (_out$level3MotionUnix2 = out.level3MotionUnixTimes) !== null && _out$level3MotionUnix2 !== void 0 && _out$level3MotionUnix2.length) {
984
+ this.nexradTimesByStation[out.l2StormMotionListKey] = {
985
+ unixTimes: out.level3MotionUnixTimes,
986
+ timeToKeyMap: out.level3MotionTimeToKeyMap,
987
+ listWindowHours: listingHours
988
+ };
989
+ }
990
+ const filtered = this._getFilteredNexradTimestampsForVariable(out.unixTimes || []);
991
+ const map = out.timeToKeyMap || {};
992
+ if (filtered.length > 0) {
993
+ const cur = this.state.nexradTimestamp != null ? Number(this.state.nexradTimestamp) : null;
994
+ const hasKey = cur != null && Object.prototype.hasOwnProperty.call(map, String(cur));
995
+ const inWindow = cur != null && filtered.includes(cur);
996
+ if (cur == null || !inWindow || !hasKey) {
997
+ this.state.nexradTimestamp = filtered[filtered.length - 1];
998
+ }
999
+ } else if (this.state.nexradTimestamp != null) {
1000
+ this.state.nexradTimestamp = null;
1001
+ }
1002
+ this._emitStateChange();
1003
+ }
1004
+ async setNexradSite(siteId) {
1005
+ var _this$nexradTimesBySt4;
1006
+ if (!this.state.isNexrad) return;
1007
+ const tilt = siteId ? (0, _nexradTilts.getDefaultRadarTilt)(siteId) : null;
1008
+ await this.setState({
1009
+ nexradSite: siteId || null,
1010
+ nexradTilt: tilt,
1011
+ nexradTimestamp: null
1012
+ });
1013
+ await this.refreshNexradTimes();
1014
+ const filtered = this._getFilteredNexradTimestampsForVariable(((_this$nexradTimesBySt4 = this.nexradTimesByStation[this._nexradTimesCacheKey()]) === null || _this$nexradTimesBySt4 === void 0 ? void 0 : _this$nexradTimesBySt4.unixTimes) || []);
1015
+ if (filtered.length > 0) {
1016
+ await this.setState({
1017
+ nexradTimestamp: filtered[filtered.length - 1]
1018
+ });
1019
+ }
1020
+ }
1021
+ async setNexradProduct(product) {
1022
+ var _this$nexradTimesBySt5;
1023
+ if (!this.state.isNexrad) return;
1024
+ const p = (product || 'REF').toUpperCase();
1025
+ await this.setState({
1026
+ nexradProduct: p
1027
+ });
1028
+ await this.refreshNexradTimes();
1029
+ const filtered = this._getFilteredNexradTimestampsForVariable(((_this$nexradTimesBySt5 = this.nexradTimesByStation[this._nexradTimesCacheKey()]) === null || _this$nexradTimesBySt5 === void 0 ? void 0 : _this$nexradTimesBySt5.unixTimes) || []);
1030
+ if (filtered.length > 0) {
1031
+ await this.setState({
1032
+ nexradTimestamp: filtered[filtered.length - 1]
1033
+ });
1034
+ }
1035
+ }
1036
+ async setNexradTilt(tilt) {
1037
+ if (!this.state.isNexrad || !this.state.nexradSite) return;
1038
+ await this.setState({
1039
+ nexradTilt: tilt != null ? Number(tilt) : null
1040
+ });
1041
+ await this.refreshNexradTimes();
1042
+ }
1043
+
1044
+ /**
1045
+ * Opt-in storm-relative velocity (L3: N0G + N0S). When off (default), only base radial velocity is used (faster).
1046
+ * @param {boolean} enabled
1047
+ */
1048
+ async setNexradStormRelative(enabled) {
1049
+ if (!this.state.isNexrad) return;
1050
+ await this.setState({
1051
+ nexradStormRelative: Boolean(enabled)
1052
+ });
1053
+ await this.refreshNexradTimes();
1054
+ }
1055
+ async setNexradTimestamp(ts) {
1056
+ if (!this.state.isNexrad) return;
1057
+ await this.setState({
1058
+ nexradTimestamp: ts != null ? Number(ts) : null
1059
+ });
1060
+ }
1061
+
440
1062
  // --- Data and Calculation Methods ---
441
1063
 
442
- _reconstructData(decompressedDeltas, encoding) {
443
- const expectedLength = encoding.length;
444
- const reconstructedData = new Int8Array(expectedLength);
445
- if (decompressedDeltas.length > 0 && expectedLength > 0) {
446
- // First value is absolute
447
- reconstructedData[0] = decompressedDeltas[0] > 127 ? decompressedDeltas[0] - 256 : decompressedDeltas[0];
1064
+ _ensureGridDecodeWorker() {
1065
+ if (this._gridDecodeWorkerDisabled) {
1066
+ return null;
1067
+ }
1068
+ if (this._gridDecodeWorker) {
1069
+ return this._gridDecodeWorker;
1070
+ }
1071
+ if (typeof Worker === 'undefined') {
1072
+ this._gridDecodeWorkerDisabled = true;
1073
+ return null;
1074
+ }
1075
+ try {
1076
+ this._gridDecodeWorker = new Worker(new URL('./gridDecodeWorker.js', import.meta.url), {
1077
+ type: 'module'
1078
+ });
1079
+ this._gridDecodeWorker.addEventListener('error', () => {
1080
+ this._gridDecodeWorkerDisabled = true;
1081
+ if (this._gridDecodeWorker) {
1082
+ this._gridDecodeWorker.terminate();
1083
+ this._gridDecodeWorker = null;
1084
+ }
1085
+ });
1086
+ } catch {
1087
+ this._gridDecodeWorkerDisabled = true;
1088
+ return null;
1089
+ }
1090
+ return this._gridDecodeWorker;
1091
+ }
448
1092
 
449
- // Subsequent values are deltas from the previous one
450
- for (let i = 1; i < expectedLength; i++) {
451
- const delta = decompressedDeltas[i] > 127 ? decompressedDeltas[i] - 256 : decompressedDeltas[i];
452
- reconstructedData[i] = reconstructedData[i - 1] + delta;
453
- }
1093
+ /**
1094
+ * Offloads zstd + delta decode + transform to a Worker when available; falls back to main-thread
1095
+ * {@link processCompressedGrid}. Uses a copy for postMessage transfer so the original `compressedData`
1096
+ * stays valid if the Worker path fails.
1097
+ */
1098
+ _decodeGridPayload(compressedData, encoding) {
1099
+ const worker = this._ensureGridDecodeWorker();
1100
+ if (!worker) {
1101
+ return Promise.resolve((0, _gridDecodePipeline.processCompressedGrid)(compressedData, encoding));
454
1102
  }
455
- // Return as a Uint8Array, which is what the rest of the code expects
456
- return new Uint8Array(reconstructedData.buffer);
1103
+ const payload = compressedData.slice();
1104
+ return new Promise((resolve, reject) => {
1105
+ const id = ++this._gridDecodeMsgId;
1106
+ const onMsg = e => {
1107
+ if (!e.data || e.data.id !== id) {
1108
+ return;
1109
+ }
1110
+ worker.removeEventListener('message', onMsg);
1111
+ worker.removeEventListener('error', onErr);
1112
+ if (e.data.error) {
1113
+ reject(new Error(e.data.error));
1114
+ return;
1115
+ }
1116
+ const data = new Uint8Array(e.data.dataBuffer, e.data.dataByteOffset, e.data.dataByteLength);
1117
+ resolve({
1118
+ data,
1119
+ encoding: e.data.encoding
1120
+ });
1121
+ };
1122
+ const onErr = err => {
1123
+ worker.removeEventListener('message', onMsg);
1124
+ worker.removeEventListener('error', onErr);
1125
+ reject(err);
1126
+ };
1127
+ worker.addEventListener('message', onMsg);
1128
+ worker.addEventListener('error', onErr);
1129
+ try {
1130
+ worker.postMessage({
1131
+ id,
1132
+ encoding,
1133
+ compressedBuffer: payload.buffer,
1134
+ compressedByteOffset: payload.byteOffset,
1135
+ compressedByteLength: payload.byteLength
1136
+ }, [payload.buffer]);
1137
+ } catch (err) {
1138
+ worker.removeEventListener('message', onMsg);
1139
+ worker.removeEventListener('error', onErr);
1140
+ reject(err);
1141
+ }
1142
+ });
457
1143
  }
458
1144
  async _loadGridData(state) {
459
1145
  if (this.isReactNative) {
460
- console.warn(`[AguaceroCore] _loadGridData was called in React Native. This is a bypass. Data loading is handled natively.`);
1146
+ return null;
1147
+ }
1148
+ if (state.isNexrad) {
461
1149
  return null;
462
1150
  }
463
1151
  const {
@@ -491,17 +1179,6 @@ class AguaceroCore extends _events.EventEmitter {
491
1179
  if (this.dataCache.has(dataUrlIdentifier)) {
492
1180
  return this.dataCache.get(dataUrlIdentifier);
493
1181
  }
494
-
495
- // --- EDITED ---
496
- // If we are in React Native, this function should NOT do any work.
497
- // The native WeatherFrameProcessorModule is now responsible for all data loading.
498
- // This function might still be called by a "cache miss" fallback, but it
499
- // should not fetch data from JS anymore. We return null so the fallback knows
500
- // that the native module is the only source of truth for new data.
501
- if (this.isReactNative) {
502
- console.warn(`_loadGridData was called in React Native for ${dataUrlIdentifier}. This should be handled by the native module. Returning null.`);
503
- return null;
504
- }
505
1182
  const abortController = new AbortController();
506
1183
  this.abortControllers.set(dataUrlIdentifier, abortController);
507
1184
  const loadPromise = (async () => {
@@ -529,38 +1206,18 @@ class AguaceroCore extends _events.EventEmitter {
529
1206
  encoding
530
1207
  } = await response.json();
531
1208
  const compressedData = Uint8Array.from(atob(b64Data), c => c.charCodeAt(0));
532
-
533
- // This path is now ONLY for the web worker
534
- const requestId = this.workerRequestId++;
535
- const workerPromise = new Promise((resolve, reject) => {
536
- this.workerResolvers.set(requestId, {
537
- resolve,
538
- reject
539
- });
540
- });
541
- this.worker.postMessage({
542
- requestId,
543
- compressedData,
544
- encoding
545
- }, [compressedData.buffer]);
546
- const result = await workerPromise;
547
- const finalData = result.data;
548
- const transformedData = new Uint8Array(finalData.length);
549
- for (let i = 0; i < finalData.length; i++) {
550
- const signedValue = finalData[i] > 127 ? finalData[i] - 256 : finalData[i];
551
- transformedData[i] = signedValue + 128;
1209
+ let gridPayload;
1210
+ try {
1211
+ gridPayload = await this._decodeGridPayload(compressedData, encoding);
1212
+ } catch {
1213
+ gridPayload = (0, _gridDecodePipeline.processCompressedGrid)(compressedData, encoding);
552
1214
  }
553
1215
  this.abortControllers.delete(dataUrlIdentifier);
554
1216
  return {
555
- data: transformedData,
556
- encoding
1217
+ data: gridPayload.data,
1218
+ encoding: gridPayload.encoding
557
1219
  };
558
1220
  } catch (error) {
559
- if (error.name === 'AbortError') {
560
- console.log(`Request cancelled for ${resourcePath}`);
561
- } else {
562
- console.error(`Failed to load data for path ${resourcePath}:`, error);
563
- }
564
1221
  this.dataCache.delete(dataUrlIdentifier);
565
1222
  this.abortControllers.delete(dataUrlIdentifier);
566
1223
  return null;
@@ -578,9 +1235,11 @@ class AguaceroCore extends _events.EventEmitter {
578
1235
  // Clear both maps
579
1236
  this.abortControllers.clear();
580
1237
  this.dataCache.clear();
581
- console.log('All pending requests cancelled');
582
1238
  }
583
1239
  async getValueAtLngLat(lng, lat) {
1240
+ if (this.state.isSatellite || this.state.isNexrad) {
1241
+ return null;
1242
+ }
584
1243
  const {
585
1244
  variable,
586
1245
  isMRMS,
@@ -968,7 +1627,6 @@ class AguaceroCore extends _events.EventEmitter {
968
1627
  y
969
1628
  };
970
1629
  } catch (error) {
971
- console.warn(`[GridAccessor] RGEM polar stereographic conversion failed for ${lat}, ${lon}:`, error);
972
1630
  return {
973
1631
  x: -1,
974
1632
  y: -1
@@ -976,91 +1634,8 @@ class AguaceroCore extends _events.EventEmitter {
976
1634
  }
977
1635
  }
978
1636
 
979
- // --- Worker and Status Methods ---
980
-
981
- createWorker() {
982
- if (this.isReactNative) return null;
983
- const workerCode = `
984
- import { decompress } from 'https://cdn.skypack.dev/fzstd@0.1.1';
1637
+ // --- Status Methods ---
985
1638
 
986
- function _reconstructData(decompressedDeltas, encoding) {
987
- const expectedLength = encoding.length;
988
- const reconstructedData = new Int8Array(expectedLength);
989
- if (decompressedDeltas.length > 0 && expectedLength > 0) {
990
- reconstructedData[0] = decompressedDeltas[0] > 127 ? decompressedDeltas[0] - 256 : decompressedDeltas[0];
991
- for (let i = 1; i < expectedLength; i++) {
992
- const delta = decompressedDeltas[i] > 127 ? decompressedDeltas[i] - 256 : decompressedDeltas[i];
993
- reconstructedData[i] = reconstructedData[i - 1] + delta;
994
- }
995
- }
996
- return new Uint8Array(reconstructedData.buffer);
997
- }
998
-
999
- self.onmessage = async (e) => {
1000
- const { requestId, compressedData, encoding } = e.data;
1001
- try {
1002
- const decompressedDeltas = await decompress(compressedData);
1003
- const finalData = _reconstructData(decompressedDeltas, encoding);
1004
- self.postMessage({ success: true, requestId: requestId, decompressedData: finalData, encoding: encoding }, [finalData.buffer]);
1005
- } catch (error) {
1006
- self.postMessage({ success: false, requestId: requestId, error: error.message });
1007
- }
1008
- };
1009
- `;
1010
- const blob = new Blob([workerCode], {
1011
- type: 'application/javascript'
1012
- });
1013
- return new Worker(URL.createObjectURL(blob), {
1014
- type: 'module'
1015
- });
1016
- }
1017
- _processResultQueue() {
1018
- while (this.resultQueue.length > 0) {
1019
- const {
1020
- success,
1021
- requestId,
1022
- decompressedData,
1023
- encoding,
1024
- error
1025
- } = this.resultQueue.shift();
1026
- if (this.workerResolvers.has(requestId)) {
1027
- const {
1028
- resolve,
1029
- reject
1030
- } = this.workerResolvers.get(requestId);
1031
- if (success) {
1032
- resolve({
1033
- data: decompressedData
1034
- }); // Return as { data: ... }
1035
- } else {
1036
- reject(new Error(error));
1037
- }
1038
- this.workerResolvers.delete(requestId);
1039
- }
1040
- }
1041
- this.isProcessingQueue = false;
1042
- }
1043
- _handleWorkerMessage(e) {
1044
- if (this.isReactNative) return;
1045
- const {
1046
- success,
1047
- requestId,
1048
- decompressedData,
1049
- encoding,
1050
- error
1051
- } = e.data;
1052
- this.resultQueue.push({
1053
- success,
1054
- requestId,
1055
- decompressedData,
1056
- encoding,
1057
- error
1058
- });
1059
- if (!this.isProcessingQueue) {
1060
- this.isProcessingQueue = true;
1061
- requestAnimationFrame(() => this._processResultQueue());
1062
- }
1063
- }
1064
1639
  async fetchModelStatus(force = false) {
1065
1640
  if (!this.modelStatus || force) {
1066
1641
  try {
@@ -1086,6 +1661,18 @@ class AguaceroCore extends _events.EventEmitter {
1086
1661
  }
1087
1662
  return this.mrmsStatus;
1088
1663
  }
1664
+ async fetchSatelliteListing(force = false) {
1665
+ if (!this.satelliteListing || force) {
1666
+ try {
1667
+ const response = await fetch(_satellite_support.SATELLITE_FRAMES_URL);
1668
+ if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`);
1669
+ this.satelliteListing = await response.json();
1670
+ } catch (error) {
1671
+ this.satelliteListing = null;
1672
+ }
1673
+ }
1674
+ return this.satelliteListing;
1675
+ }
1089
1676
  startAutoRefresh(intervalSeconds) {
1090
1677
  this.stopAutoRefresh();
1091
1678
  this.autoRefreshIntervalId = setInterval(async () => {