@aguacerowx/javascript-sdk 0.0.20 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aguacerowx/javascript-sdk",
3
- "version": "0.0.20",
3
+ "version": "0.0.21",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -11,14 +11,15 @@ import { processCompressedGrid } from './gridDecodePipeline.js';
11
11
  import { getBundleId } from './getBundleId';
12
12
  import {
13
13
  SATELLITE_FRAMES_URL,
14
- buildSatelliteTimelineForKey,
15
- SATELLITE_DURATION_CONFIG,
16
- TIMELINE_DURATION_HOUR_VALUES,
17
- normalizeTimelineDurationValue,
14
+ buildSatelliteTimelineForSelection,
15
+ formatTimelineDurationValue,
16
+ resolveSatelliteSectorLabel,
17
+ resolveSatelliteDurationOption,
18
18
  parseTimelineDurationHours,
19
19
  } from './satellite_support.js';
20
20
  import {
21
21
  fetchNexradTimesListing,
22
+ inferNexradDataSourceForProduct,
22
23
  nexradColormapFldKey,
23
24
  variableToNexradGroup,
24
25
  getAvailableNexradTilts,
@@ -140,17 +141,40 @@ export class AguaceroCore extends EventEmitter {
140
141
 
141
142
  const initialSatellite = initialMode === 'satellite';
142
143
  const initialNexrad = initialMode === 'nexrad';
143
- const initialNexradDs = userLayerOptions.nexradDataSource || 'level2';
144
144
  const initialNexradProd = userLayerOptions.nexradProduct || 'REF';
145
+ const initialNexradDs =
146
+ userLayerOptions.nexradDataSource != null
147
+ ? userLayerOptions.nexradDataSource === 'level3'
148
+ ? 'level3'
149
+ : 'level2'
150
+ : inferNexradDataSourceForProduct(initialNexradProd);
145
151
  const initialNexradFld = initialNexrad
146
152
  ? nexradColormapFldKey(initialNexradDs, initialNexradProd)
147
153
  : initialVariable;
154
+ let initialSatelliteInstrumentId = null;
155
+ let initialSatelliteSectorLabel = null;
156
+ let initialSatelliteChannel = null;
157
+ if (initialSatellite) {
158
+ initialSatelliteInstrumentId = userLayerOptions.satelliteId ?? 'GOES19-EAST';
159
+ initialSatelliteSectorLabel = resolveSatelliteSectorLabel(
160
+ userLayerOptions.satelliteSector ?? userLayerOptions.sector ?? 'conus',
161
+ );
162
+ initialSatelliteChannel =
163
+ userLayerOptions.satelliteProduct ??
164
+ userLayerOptions.satelliteChannel ??
165
+ initialVariable ??
166
+ 'C13';
167
+ }
148
168
  this.state = {
149
169
  model: userLayerOptions.model || 'gfs',
150
170
  // EDIT: Set isMRMS based on the initial mode
151
171
  isMRMS: initialMode === 'mrms' && !initialSatellite && !initialNexrad,
152
172
  mrmsTimestamp: null,
153
- variable: initialNexrad ? initialNexradFld : initialVariable,
173
+ variable: initialNexrad
174
+ ? initialNexradFld
175
+ : initialSatellite && initialSatelliteInstrumentId
176
+ ? initialSatelliteChannel
177
+ : initialVariable,
154
178
  date: null,
155
179
  run: null,
156
180
  forecastHour: 0,
@@ -159,21 +183,20 @@ export class AguaceroCore extends EventEmitter {
159
183
  units: options.initialUnit || 'imperial',
160
184
  shaderSmoothingEnabled: options.shaderSmoothingEnabled ?? true,
161
185
  isSatellite: initialSatellite,
162
- satelliteKey: userLayerOptions.satelliteKey || null,
186
+ satelliteInstrumentId: initialSatelliteInstrumentId,
187
+ satelliteSectorLabel: initialSatelliteSectorLabel,
188
+ satelliteChannel: initialSatelliteChannel,
163
189
  satelliteTimestamp: userLayerOptions.satelliteTimestamp != null ? Number(userLayerOptions.satelliteTimestamp) : null,
164
190
  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
- })(),
191
+ satelliteDurationValue: formatTimelineDurationValue(
192
+ userLayerOptions.satelliteDurationValue != null ? userLayerOptions.satelliteDurationValue : '1',
193
+ ),
194
+ mrmsDurationValue: formatTimelineDurationValue(
195
+ userLayerOptions.mrmsDurationValue != null ? userLayerOptions.mrmsDurationValue : '1',
196
+ ),
197
+ nexradDurationValue: formatTimelineDurationValue(
198
+ userLayerOptions.nexradDurationValue != null ? userLayerOptions.nexradDurationValue : '1',
199
+ ),
177
200
  isNexrad: initialNexrad,
178
201
  nexradSite: userLayerOptions.nexradSite ?? null,
179
202
  nexradDataSource: initialNexradDs,
@@ -200,6 +223,7 @@ export class AguaceroCore extends EventEmitter {
200
223
 
201
224
  async setState(newState) {
202
225
  const patch = { ...newState };
226
+ if ('satelliteKey' in patch) delete patch.satelliteKey;
203
227
  if ('forecastHour' in patch && patch.forecastHour != null) {
204
228
  patch.forecastHour = Number(patch.forecastHour);
205
229
  }
@@ -210,12 +234,13 @@ export class AguaceroCore extends EventEmitter {
210
234
  patch.satelliteTimestamp = Number(patch.satelliteTimestamp);
211
235
  }
212
236
  if ('satelliteDurationValue' in patch && patch.satelliteDurationValue != null) {
213
- const n = normalizeTimelineDurationValue(patch.satelliteDurationValue);
214
- patch.satelliteDurationValue = TIMELINE_DURATION_HOUR_VALUES.includes(n) ? n : '1';
237
+ patch.satelliteDurationValue = formatTimelineDurationValue(patch.satelliteDurationValue);
215
238
  }
216
239
  if ('mrmsDurationValue' in patch && patch.mrmsDurationValue != null) {
217
- const n = normalizeTimelineDurationValue(patch.mrmsDurationValue);
218
- patch.mrmsDurationValue = TIMELINE_DURATION_HOUR_VALUES.includes(n) ? n : '1';
240
+ patch.mrmsDurationValue = formatTimelineDurationValue(patch.mrmsDurationValue);
241
+ }
242
+ if ('nexradDurationValue' in patch && patch.nexradDurationValue != null) {
243
+ patch.nexradDurationValue = formatTimelineDurationValue(patch.nexradDurationValue);
219
244
  }
220
245
  if ('nexradTimestamp' in patch && patch.nexradTimestamp != null) {
221
246
  patch.nexradTimestamp = Number(patch.nexradTimestamp);
@@ -253,23 +278,23 @@ export class AguaceroCore extends EventEmitter {
253
278
  }
254
279
 
255
280
  _computeSatelliteTimeline() {
256
- if (!this.state.satelliteKey || !this.satelliteListing?.objects) {
281
+ const { satelliteInstrumentId, satelliteSectorLabel, satelliteChannel } = this.state;
282
+ if (!satelliteInstrumentId || !satelliteSectorLabel || !satelliteChannel || !this.satelliteListing?.objects) {
257
283
  return { unixTimes: [], timeToFileMap: {} };
258
284
  }
259
285
  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';
286
+ const sectorName = satelliteSectorLabel;
264
287
  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);
288
+ const durationOpt = resolveSatelliteDurationOption(sectorName, tier, this.state.satelliteDurationValue);
289
+ return buildSatelliteTimelineForSelection(
290
+ {
291
+ satelliteInstrumentId,
292
+ satelliteSectorLabel,
293
+ satelliteChannel,
294
+ },
295
+ allFiles,
296
+ durationOpt,
297
+ );
273
298
  }
274
299
 
275
300
  /**
@@ -295,7 +320,7 @@ export class AguaceroCore extends EventEmitter {
295
320
  }
296
321
 
297
322
  _nexradListingWindowHours() {
298
- return parseTimelineDurationHours(this.state.mrmsDurationValue);
323
+ return parseTimelineDurationHours(this.state.nexradDurationValue);
299
324
  }
300
325
 
301
326
  _getFilteredNexradTimestampsForVariable(rawList) {
@@ -357,7 +382,7 @@ export class AguaceroCore extends EventEmitter {
357
382
 
358
383
  let availableSatelliteTimestamps = [];
359
384
  let satelliteTimeToFileMap = {};
360
- if (this.state.isSatellite && this.state.satelliteKey) {
385
+ if (this.state.isSatellite && this.state.satelliteInstrumentId) {
361
386
  const timeline = this._computeSatelliteTimeline();
362
387
  satelliteTimeToFileMap = timeline.timeToFileMap || {};
363
388
  availableSatelliteTimestamps = [...(timeline.unixTimes || [])]
@@ -416,7 +441,7 @@ export class AguaceroCore extends EventEmitter {
416
441
 
417
442
  let initialState = { ...this.state };
418
443
 
419
- if (initialState.isSatellite && initialState.satelliteKey) {
444
+ if (initialState.isSatellite && initialState.satelliteInstrumentId) {
420
445
  const timeline = this._computeSatelliteTimeline();
421
446
  const tsList = [...(timeline.unixTimes || [])].sort((a, b) => a - b);
422
447
  if (initialState.satelliteTimestamp == null && tsList.length > 0) {
@@ -625,7 +650,13 @@ export class AguaceroCore extends EventEmitter {
625
650
  async setModel(modelName) {
626
651
  if (modelName === this.state.model || !this.modelStatus?.[modelName]) return;
627
652
  if (this.state.isSatellite) {
628
- await this.setState({ isSatellite: false, satelliteKey: null, satelliteTimestamp: null });
653
+ await this.setState({
654
+ isSatellite: false,
655
+ satelliteInstrumentId: null,
656
+ satelliteSectorLabel: null,
657
+ satelliteChannel: null,
658
+ satelliteTimestamp: null,
659
+ });
629
660
  }
630
661
  const latestRun = findLatestModelRun(this.modelStatus, modelName);
631
662
  if (latestRun) {
@@ -674,7 +705,9 @@ export class AguaceroCore extends EventEmitter {
674
705
  variable,
675
706
  isMRMS: true,
676
707
  isSatellite: false,
677
- satelliteKey: null,
708
+ satelliteInstrumentId: null,
709
+ satelliteSectorLabel: null,
710
+ satelliteChannel: null,
678
711
  satelliteTimestamp: null,
679
712
  mrmsTimestamp: initialTimestamp,
680
713
  });
@@ -691,14 +724,13 @@ export class AguaceroCore extends EventEmitter {
691
724
  }
692
725
 
693
726
  /**
694
- * How many hours of satellite frames to include in the timeline (1, 4, 6, or 12).
727
+ * How many hours of satellite frames to include in the timeline (positive, at most 12 hours).
695
728
  * API default: `layerOptions.satelliteDurationValue` on construction.
696
729
  */
697
730
  async setSatelliteDurationValue(value) {
698
- const v = normalizeTimelineDurationValue(value);
699
- if (!TIMELINE_DURATION_HOUR_VALUES.includes(v)) return;
731
+ const v = formatTimelineDurationValue(value);
700
732
  await this.setState({ satelliteDurationValue: v });
701
- if (!this.state.isSatellite || !this.state.satelliteKey) return;
733
+ if (!this.state.isSatellite || !this.state.satelliteInstrumentId) return;
702
734
  const timeline = this._computeSatelliteTimeline();
703
735
  const tsList = [...(timeline.unixTimes || [])]
704
736
  .map((t) => Number(t))
@@ -713,30 +745,35 @@ export class AguaceroCore extends EventEmitter {
713
745
  }
714
746
 
715
747
  /**
716
- * How many hours of MRMS frames to include in the timeline (1, 4, 6, or 12).
748
+ * Set satellite view using spacecraft id, sector, and channel/product.
749
+ * Omitted fields keep the current selection; when not in satellite mode, missing fields use GOES-19 East CONUS / C13 defaults.
750
+ * @param {{ satelliteId?: string, sector?: string, satelliteSector?: string, satelliteProduct?: string, satelliteChannel?: string, satelliteTimestamp?: number|null }} opts
751
+ */
752
+ async setSatelliteSelection(opts = {}) {
753
+ const cur = this.state.isSatellite ? this.state : null;
754
+ const tsArg =
755
+ opts.satelliteTimestamp !== undefined
756
+ ? opts.satelliteTimestamp
757
+ : this.state.isSatellite && this.state.satelliteTimestamp != null
758
+ ? Number(this.state.satelliteTimestamp)
759
+ : undefined;
760
+ return this.switchMode({
761
+ mode: 'satellite',
762
+ satelliteId: opts.satelliteId ?? cur?.satelliteInstrumentId ?? 'GOES19-EAST',
763
+ satelliteSector: opts.satelliteSector ?? opts.sector ?? cur?.satelliteSectorLabel ?? 'conus',
764
+ satelliteProduct:
765
+ opts.satelliteProduct ?? opts.satelliteChannel ?? cur?.satelliteChannel ?? 'C13',
766
+ satelliteTimestamp: tsArg,
767
+ });
768
+ }
769
+
770
+ /**
771
+ * How many hours of MRMS frames to include in the timeline (positive, at most 12 hours).
717
772
  * API default: `layerOptions.mrmsDurationValue` on construction.
718
773
  */
719
774
  async setMRMSDurationValue(value) {
720
- const v = normalizeTimelineDurationValue(value);
721
- if (!TIMELINE_DURATION_HOUR_VALUES.includes(v)) return;
775
+ const v = formatTimelineDurationValue(value);
722
776
  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
777
  if (!this.state.isMRMS || !this.state.variable) return;
741
778
  const filtered = this._getFilteredMrmsTimestampsForVariable(this.state.variable);
742
779
  if (filtered.length === 0) return;
@@ -747,8 +784,29 @@ export class AguaceroCore extends EventEmitter {
747
784
  }
748
785
  }
749
786
 
787
+ /**
788
+ * NEXRAD sweep listing / scrub window in hours (independent of MRMS duration).
789
+ * API default: `layerOptions.nexradDurationValue` on construction.
790
+ */
791
+ async setNexradDurationValue(value) {
792
+ const v = formatTimelineDurationValue(value);
793
+ await this.setState({ nexradDurationValue: v });
794
+ if (!this.state.isNexrad || !this.state.nexradSite) return;
795
+ await this.refreshNexradTimes();
796
+ const nk = this._nexradTimesCacheKey();
797
+ const filtered = this._getFilteredNexradTimestampsForVariable(
798
+ nk ? this.nexradTimesByStation[nk]?.unixTimes || [] : [],
799
+ );
800
+ if (filtered.length === 0) return;
801
+ const cur = this.state.nexradTimestamp;
802
+ const curN = cur == null ? null : Number(cur);
803
+ if (curN == null || !filtered.includes(curN)) {
804
+ await this.setState({ nexradTimestamp: filtered[filtered.length - 1] });
805
+ }
806
+ }
807
+
750
808
  async switchMode(options) {
751
- const { mode, variable, model, forecastHour, mrmsTimestamp, satelliteKey, satelliteTimestamp } = options;
809
+ let { mode, variable, model, forecastHour, mrmsTimestamp, satelliteTimestamp } = options;
752
810
  if (!mode) {
753
811
  return;
754
812
  }
@@ -758,18 +816,23 @@ export class AguaceroCore extends EventEmitter {
758
816
  if ((mode === 'mrms' || mode === 'model') && !variable) {
759
817
  return;
760
818
  }
761
- if (mode === 'satellite' && !satelliteKey) {
762
- return;
763
- }
764
819
  let targetState = {};
765
820
  if (mode === 'satellite') {
766
- const channelToken = satelliteKey.split('.').pop() || variable || 'C13';
821
+ const satelliteInstrumentId = options.satelliteId ?? 'GOES19-EAST';
822
+ const satelliteSectorLabel = resolveSatelliteSectorLabel(
823
+ options.satelliteSector ?? options.sector ?? 'conus',
824
+ );
825
+ const satelliteChannel =
826
+ options.satelliteProduct ?? options.satelliteChannel ?? variable ?? 'C13';
827
+ const channelToken = satelliteChannel;
767
828
  // Emit satellite mode immediately so map layers (e.g. model grid) clear before listing fetch finishes.
768
829
  await this.setState({
769
830
  isSatellite: true,
770
831
  isMRMS: false,
771
832
  isNexrad: false,
772
- satelliteKey,
833
+ satelliteInstrumentId,
834
+ satelliteSectorLabel,
835
+ satelliteChannel,
773
836
  variable: channelToken,
774
837
  satelliteTimestamp: null,
775
838
  mrmsTimestamp: null,
@@ -780,19 +843,14 @@ export class AguaceroCore extends EventEmitter {
780
843
 
781
844
  await this.fetchSatelliteListing(true);
782
845
  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';
846
+ const sectorName = satelliteSectorLabel;
787
847
  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);
848
+ const durationOpt = resolveSatelliteDurationOption(sectorName, tier, this.state.satelliteDurationValue);
849
+ const timeline = buildSatelliteTimelineForSelection(
850
+ { satelliteInstrumentId, satelliteSectorLabel, satelliteChannel },
851
+ allFiles,
852
+ durationOpt,
853
+ );
796
854
  const tsList = [...(timeline.unixTimes || [])].sort((a, b) => a - b);
797
855
  let finalTs = satelliteTimestamp !== undefined ? satelliteTimestamp : null;
798
856
  if (finalTs == null && tsList.length > 0) {
@@ -821,7 +879,9 @@ export class AguaceroCore extends EventEmitter {
821
879
  variable: variable,
822
880
  mrmsTimestamp: finalTimestamp,
823
881
  model: this.state.model, date: null, run: null, forecastHour: 0,
824
- satelliteKey: null,
882
+ satelliteInstrumentId: null,
883
+ satelliteSectorLabel: null,
884
+ satelliteChannel: null,
825
885
  satelliteTimestamp: null,
826
886
  };
827
887
  } else if (mode === 'model') {
@@ -855,12 +915,19 @@ export class AguaceroCore extends EventEmitter {
855
915
  run: latestRun.run,
856
916
  forecastHour: initialHour, // <-- Changed
857
917
  mrmsTimestamp: null,
858
- satelliteKey: null,
918
+ satelliteInstrumentId: null,
919
+ satelliteSectorLabel: null,
920
+ satelliteChannel: null,
859
921
  satelliteTimestamp: null,
860
922
  };
861
923
  } else if (mode === 'nexrad') {
862
- const nexradDataSource = options.nexradDataSource || 'level2';
863
924
  const nexradProduct = options.nexradProduct || 'REF';
925
+ const nexradDataSource =
926
+ options.nexradDataSource != null
927
+ ? options.nexradDataSource === 'level3'
928
+ ? 'level3'
929
+ : 'level2'
930
+ : inferNexradDataSourceForProduct(nexradProduct);
864
931
  const fld = nexradColormapFldKey(nexradDataSource, nexradProduct);
865
932
  const site = options.nexradSite ?? null;
866
933
  let tilt =
@@ -881,8 +948,13 @@ export class AguaceroCore extends EventEmitter {
881
948
  nexradTimestamp: options.nexradTimestamp != null ? Number(options.nexradTimestamp) : null,
882
949
  nexradStormRelative: options.nexradStormRelative === true,
883
950
  nexradShowSitesPicker: options.nexradShowSitesPicker !== false,
951
+ ...(options.nexradDurationValue != null
952
+ ? { nexradDurationValue: formatTimelineDurationValue(options.nexradDurationValue) }
953
+ : {}),
884
954
  mrmsTimestamp: null,
885
- satelliteKey: null,
955
+ satelliteInstrumentId: null,
956
+ satelliteSectorLabel: null,
957
+ satelliteChannel: null,
886
958
  satelliteTimestamp: null,
887
959
  date: null,
888
960
  run: null,
@@ -995,10 +1067,10 @@ export class AguaceroCore extends EventEmitter {
995
1067
  async setNexradProduct(product) {
996
1068
  if (!this.state.isNexrad) return;
997
1069
  const p = (product || 'REF').toUpperCase();
998
- const ds = this.state.nexradDataSource || 'level2';
1070
+ const ds = inferNexradDataSourceForProduct(p);
999
1071
  const fld = nexradColormapFldKey(ds, p);
1000
1072
  const nexradStormRelative = this._nexradStormRelativeFor(ds, p);
1001
- await this.setState({ nexradProduct: p, variable: fld, nexradStormRelative });
1073
+ await this.setState({ nexradProduct: p, nexradDataSource: ds, variable: fld, nexradStormRelative });
1002
1074
  await this.refreshNexradTimes();
1003
1075
  const filtered = this._getFilteredNexradTimestampsForVariable(
1004
1076
  this.nexradTimesByStation[this._nexradTimesCacheKey()]?.unixTimes || [],
@@ -1025,8 +1097,9 @@ export class AguaceroCore extends EventEmitter {
1025
1097
  }
1026
1098
 
1027
1099
  /**
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.
1100
+ * Sets NEXRAD product and archive source together (single refresh). Prefer {@link setNexradProduct}
1101
+ * for product-only changes (Level II vs III is inferred). Use this when you must force a specific
1102
+ * archive source (rare).
1030
1103
  * @param {'level2'|'level3'} dataSource
1031
1104
  * @param {string} product - Level-II variable (REF, VEL, …) or Level-III radar key (N0H, HHC, …)
1032
1105
  */
package/src/index.js CHANGED
@@ -13,14 +13,18 @@ export {
13
13
  GOES_SATELLITE_CHANNELS,
14
14
  GOES_SATELLITE_CHANNEL_LABELS,
15
15
  SATELLITE_DURATION_CONFIG,
16
+ TIMELINE_DURATION_MAX_HOURS,
16
17
  TIMELINE_DURATION_HOUR_VALUES,
18
+ formatTimelineDurationValue,
17
19
  normalizeTimelineDurationValue,
18
20
  parseTimelineDurationHours,
21
+ resolveSatelliteDurationOption,
19
22
  getDefaultSatelliteDurationOption,
20
- buildSatelliteTimelineForKey,
21
- getAllGoesEastSatelliteKeys,
23
+ buildSatelliteTimelineForSelection,
24
+ getAllGoesEastSatelliteSelections,
22
25
  calculateUnixTimeFromSatelliteKey,
23
26
  resolveSatelliteS3FileName,
27
+ resolveSatelliteSectorLabel,
24
28
  } from './satellite_support.js';
25
29
 
26
30
  // Now, export them all so other packages can import them.
@@ -14,6 +14,17 @@ import {
14
14
  getNexradLevel3EntryByRadarKey,
15
15
  } from './nexrad_level3_catalog.js';
16
16
 
17
+ /**
18
+ * Whether listings and archive fetches for this product use Level-II sweep lambda vs Level-III S3 products.
19
+ * AguaceroCore applies this from the product key; set `nexradDataSource` in layer options only to override (rare).
20
+ * @param {string} [nexradProduct] - Radar variable key (e.g. REF, KDP, VEL).
21
+ * @returns {'level2'|'level3'}
22
+ */
23
+ export function inferNexradDataSourceForProduct(nexradProduct) {
24
+ const p = (nexradProduct || 'REF').toUpperCase();
25
+ return getNexradLevel3EntryByRadarKey(p) ? 'level3' : 'level2';
26
+ }
27
+
17
28
  /** Colormap / DICTIONARIES.fld key for customColormaps (matches frontend userDefaultColormap). */
18
29
  export function nexradColormapFldKey(nexradDataSource, nexradProduct) {
19
30
  const p = (nexradProduct || 'REF').toUpperCase();
@@ -13,6 +13,33 @@ export const GOES_EAST_SATELLITE_SECTORS = [
13
13
  'GOES-EAST MESOSCALE 2',
14
14
  ];
15
15
 
16
+ const SECTOR_SYNONYM_TO_LABEL = {
17
+ conus: 'GOES-EAST CONUS',
18
+ full_disk: 'GOES-EAST FULL DISK',
19
+ fulldisk: 'GOES-EAST FULL DISK',
20
+ mesoscale_1: 'GOES-EAST MESOSCALE 1',
21
+ mesoscale1: 'GOES-EAST MESOSCALE 1',
22
+ m1: 'GOES-EAST MESOSCALE 1',
23
+ mesoscale_2: 'GOES-EAST MESOSCALE 2',
24
+ mesoscale2: 'GOES-EAST MESOSCALE 2',
25
+ m2: 'GOES-EAST MESOSCALE 2',
26
+ };
27
+
28
+ /**
29
+ * Normalizes a sector argument to the listing sector label (middle segment; must match archive paths).
30
+ * Accepts short tokens (<code>conus</code>, <code>full_disk</code>, …), synonyms (<code>m1</code>, <code>fulldisk</code>, …), or full labels (passthrough for West / custom feeds).
31
+ * @param {string} [sector]
32
+ * @returns {string}
33
+ */
34
+ export function resolveSatelliteSectorLabel(sector) {
35
+ if (sector == null || sector === '') return 'GOES-EAST CONUS';
36
+ const raw = String(sector).trim();
37
+ const norm = raw.toLowerCase().replace(/[\s-]+/g, '_');
38
+ if (SECTOR_SYNONYM_TO_LABEL[norm]) return SECTOR_SYNONYM_TO_LABEL[norm];
39
+ if (GOES_EAST_SATELLITE_SECTORS.includes(raw)) return raw;
40
+ return raw;
41
+ }
42
+
16
43
  /** Flat channel ids matching CHANNEL_CATEGORIES in frontend dictionaries. */
17
44
  export const GOES_SATELLITE_CHANNELS = [
18
45
  ...['true_color', 'geocolor', 'ntmicro', 'day_cloud_phase', 'day_land_cloud_fire', 'air_mass', 'sandwich', 'simple_water_vapor', 'dust', 'fire_temperature'],
@@ -75,8 +102,12 @@ const SATELLITE_RGB_PRODUCT_UPPER = new Set([
75
102
  ]);
76
103
 
77
104
  /**
78
- * Allowed timeline window lengths (hours) for satellite and MRMS in the Web SDK.
79
- * Values are stringified for state and API parity with satellite duration options.
105
+ * Maximum timeline window length in hours for satellite, MRMS, and NEXRAD. Values are clamped to (0, this].
106
+ */
107
+ export const TIMELINE_DURATION_MAX_HOURS = 12;
108
+
109
+ /**
110
+ * Preset hour buttons (UI). Any positive duration ≤ {@link TIMELINE_DURATION_MAX_HOURS} is valid via {@link formatTimelineDurationValue}.
80
111
  */
81
112
  export const TIMELINE_DURATION_HOUR_VALUES = ['1', '4', '6', '12'];
82
113
 
@@ -113,24 +144,54 @@ export const SATELLITE_DURATION_CONFIG = {
113
144
  };
114
145
 
115
146
  /**
116
- * Normalizes satellite or MRMS duration option values (including legacy '0.5' '1').
147
+ * Parses a user duration to hours, then clamps to a positive value not exceeding {@link TIMELINE_DURATION_MAX_HOURS}.
148
+ * @param {string|number} value
149
+ * @returns {number}
150
+ */
151
+ export function parseTimelineDurationHours(value) {
152
+ let s = value == null ? '1' : String(value).trim();
153
+ s = LEGACY_SATELLITE_DURATION_ALIASES[s] || s;
154
+ const n = Number(s);
155
+ if (!Number.isFinite(n) || n <= 0) return 1;
156
+ if (n > TIMELINE_DURATION_MAX_HOURS) return TIMELINE_DURATION_MAX_HOURS;
157
+ return n;
158
+ }
159
+
160
+ /**
161
+ * Canonical string for AguaceroCore state (stable for equality checks). Supports fractional hours (e.g. <code>'2.5'</code>).
117
162
  * @param {string|number} value
118
163
  * @returns {string}
119
164
  */
165
+ export function formatTimelineDurationValue(value) {
166
+ const n = parseTimelineDurationHours(value);
167
+ if (Math.abs(n - Math.round(n)) < 1e-6) return String(Math.round(n));
168
+ return String(Math.round(n * 10000) / 10000);
169
+ }
170
+
171
+ /** @returns {string} Same as {@link formatTimelineDurationValue} (backward-compatible name). */
120
172
  export function normalizeTimelineDurationValue(value) {
121
- const s = value == null ? '1' : String(value);
122
- return LEGACY_SATELLITE_DURATION_ALIASES[s] || s;
173
+ return formatTimelineDurationValue(value);
123
174
  }
124
175
 
125
176
  /**
126
- * @param {string|number} value
127
- * @returns {number} Hours in [1, 4, 6, 12], defaulting to 1.
177
+ * Picks a preset GOES duration option when one matches; otherwise builds a custom option using the same cadence as the 1&nbsp;hr preset for that sector/tier.
178
+ * @param {string} sectorName - e.g. <code>GOES-EAST CONUS</code>
179
+ * @param {string} [tier='basic']
180
+ * @param {string|number} durationValue
181
+ * @returns {{ label: string, value: string, interval: number }}
128
182
  */
129
- export function parseTimelineDurationHours(value) {
130
- const s = normalizeTimelineDurationValue(value);
131
- const n = Number(s);
132
- if (TIMELINE_DURATION_HOUR_VALUES.includes(s) && [1, 4, 6, 12].includes(n)) return n;
133
- return 1;
183
+ export function resolveSatelliteDurationOption(sectorName, tier, durationValue) {
184
+ let sectorType = 'CONUS';
185
+ if (sectorName.includes('FULL DISK')) sectorType = 'FULL_DISK';
186
+ else if (sectorName.includes('MESOSCALE')) sectorType = 'MESOSCALE';
187
+ const t = tier || 'basic';
188
+ const tierConfig =
189
+ SATELLITE_DURATION_CONFIG[sectorType]?.[t] || SATELLITE_DURATION_CONFIG.CONUS.basic;
190
+ const key = formatTimelineDurationValue(durationValue);
191
+ const preset = tierConfig.find((o) => String(o.value) === String(key));
192
+ if (preset) return preset;
193
+ const interval = tierConfig.find((o) => o.value === '1')?.interval || 300;
194
+ return { label: `${key} Hr`, value: key, interval };
134
195
  }
135
196
 
136
197
  export function getDefaultSatelliteDurationOption(sectorName, tier = 'basic') {
@@ -160,16 +221,17 @@ export function calculateUnixTimeFromSatelliteKey(fileKey) {
160
221
  }
161
222
 
162
223
  /**
163
- * @param {string} satelliteKey e.g. "GOES19-EAST.GOES-EAST CONUS.C13"
224
+ * @param {{ satelliteInstrumentId: string, satelliteSectorLabel: string, satelliteChannel: string }} selection
164
225
  * @param {string[]} allFiles S3 keys from satellite listing API
165
226
  * @param {{ value: string, interval: number }} durationOption
166
227
  */
167
- export function buildSatelliteTimelineForKey(satelliteKey, allFiles, durationOption) {
168
- const parts = satelliteKey.split('.');
169
- if (parts.length < 3) {
228
+ export function buildSatelliteTimelineForSelection(selection, allFiles, durationOption) {
229
+ const satelliteName = selection?.satelliteInstrumentId;
230
+ const categoryName = selection?.satelliteSectorLabel;
231
+ const channelName = selection?.satelliteChannel;
232
+ if (!satelliteName || !categoryName || !channelName) {
170
233
  return { unixTimes: [], fileList: [], timeToFileMap: {} };
171
234
  }
172
- const [satelliteName, categoryName, channelName] = parts;
173
235
 
174
236
  const SECTOR_CODE_MAP_LOCAL = {
175
237
  'GOES-EAST CONUS': 'C',
@@ -274,23 +336,32 @@ export function buildSatelliteTimelineForKey(satelliteKey, allFiles, durationOpt
274
336
  }
275
337
 
276
338
  /**
277
- * Full satellite keys for GOES-19 East (same naming as production).
339
+ * All GOES-East spacecraft / sector / channel combinations the SDK knows (same naming as production).
340
+ * @param {string} [satelliteInstrumentId='GOES19-EAST']
341
+ * @returns {Array<{ satelliteInstrumentId: string, satelliteSectorLabel: string, satelliteChannel: string }>}
278
342
  */
279
- export function getAllGoesEastSatelliteKeys(satelliteName = 'GOES19-EAST') {
280
- const keys = [];
343
+ export function getAllGoesEastSatelliteSelections(satelliteInstrumentId = 'GOES19-EAST') {
344
+ const out = [];
281
345
  for (const sector of GOES_EAST_SATELLITE_SECTORS) {
282
346
  for (const ch of GOES_SATELLITE_CHANNELS) {
283
- keys.push(`${satelliteName}.${sector}.${ch}`);
347
+ out.push({
348
+ satelliteInstrumentId,
349
+ satelliteSectorLabel: sector,
350
+ satelliteChannel: ch,
351
+ });
284
352
  }
285
353
  }
286
- return keys;
354
+ return out;
287
355
  }
288
356
 
289
- export function resolveSatelliteS3FileName(satelliteKey, timeToFileMap, satelliteTimestamp) {
357
+ /**
358
+ * @param {string} satelliteChannel — band or RGB id (third segment of the archive descriptor)
359
+ */
360
+ export function resolveSatelliteS3FileName(satelliteChannel, timeToFileMap, satelliteTimestamp) {
290
361
  const fileFromMap = timeToFileMap?.[satelliteTimestamp];
291
362
  if (fileFromMap) return fileFromMap;
292
- const [, , channelName] = satelliteKey.split('.');
293
- const channelNameUpper = channelName.toUpperCase();
363
+ const channelName = satelliteChannel;
364
+ const channelNameUpper = String(channelName).toUpperCase();
294
365
  let name = fileFromMap || '';
295
366
  if (!name && satelliteTimestamp != null) {
296
367
  const suffix = `_${String(Math.floor(Number(satelliteTimestamp)))}`;