@aguacerowx/javascript-sdk 0.0.20 → 0.0.22

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.22",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -25,6 +25,11 @@
25
25
  "import": "./src/nexrad_support.js",
26
26
  "require": "./src/nexrad_support.js",
27
27
  "default": "./src/nexrad_support.js"
28
+ },
29
+ "./nexradTiltCoalesce.js": {
30
+ "import": "./src/nexradTiltCoalesce.js",
31
+ "require": "./src/nexradTiltCoalesce.js",
32
+ "default": "./src/nexradTiltCoalesce.js"
28
33
  }
29
34
  },
30
35
  "files": [
@@ -11,18 +11,21 @@ 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,
26
+ getRawNexradTiltsForCoalesce,
25
27
  } from './nexrad_support.js';
28
+ import { nexradLayerTiltToDisplayOption } from './nexradTiltCoalesce.js';
26
29
  import { NEXRAD_LEVEL3_ELEV, getNexradLevel3EntryByRadarKey } from './nexrad_level3_catalog.js';
27
30
  import {
28
31
  setRadarTiltsManifest,
@@ -140,17 +143,35 @@ export class AguaceroCore extends EventEmitter {
140
143
 
141
144
  const initialSatellite = initialMode === 'satellite';
142
145
  const initialNexrad = initialMode === 'nexrad';
143
- const initialNexradDs = userLayerOptions.nexradDataSource || 'level2';
144
146
  const initialNexradProd = userLayerOptions.nexradProduct || 'REF';
147
+ const initialNexradDs = inferNexradDataSourceForProduct(initialNexradProd);
145
148
  const initialNexradFld = initialNexrad
146
149
  ? nexradColormapFldKey(initialNexradDs, initialNexradProd)
147
150
  : initialVariable;
151
+ let initialSatelliteInstrumentId = null;
152
+ let initialSatelliteSectorLabel = null;
153
+ let initialSatelliteChannel = null;
154
+ if (initialSatellite) {
155
+ initialSatelliteInstrumentId = userLayerOptions.satelliteId ?? 'GOES19-EAST';
156
+ initialSatelliteSectorLabel = resolveSatelliteSectorLabel(
157
+ userLayerOptions.satelliteSector ?? userLayerOptions.sector ?? 'conus',
158
+ );
159
+ initialSatelliteChannel =
160
+ userLayerOptions.satelliteProduct ??
161
+ userLayerOptions.satelliteChannel ??
162
+ initialVariable ??
163
+ 'C13';
164
+ }
148
165
  this.state = {
149
166
  model: userLayerOptions.model || 'gfs',
150
167
  // EDIT: Set isMRMS based on the initial mode
151
168
  isMRMS: initialMode === 'mrms' && !initialSatellite && !initialNexrad,
152
169
  mrmsTimestamp: null,
153
- variable: initialNexrad ? initialNexradFld : initialVariable,
170
+ variable: initialNexrad
171
+ ? initialNexradFld
172
+ : initialSatellite && initialSatelliteInstrumentId
173
+ ? initialSatelliteChannel
174
+ : initialVariable,
154
175
  date: null,
155
176
  run: null,
156
177
  forecastHour: 0,
@@ -159,21 +180,20 @@ export class AguaceroCore extends EventEmitter {
159
180
  units: options.initialUnit || 'imperial',
160
181
  shaderSmoothingEnabled: options.shaderSmoothingEnabled ?? true,
161
182
  isSatellite: initialSatellite,
162
- satelliteKey: userLayerOptions.satelliteKey || null,
183
+ satelliteInstrumentId: initialSatelliteInstrumentId,
184
+ satelliteSectorLabel: initialSatelliteSectorLabel,
185
+ satelliteChannel: initialSatelliteChannel,
163
186
  satelliteTimestamp: userLayerOptions.satelliteTimestamp != null ? Number(userLayerOptions.satelliteTimestamp) : null,
164
187
  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
- })(),
188
+ satelliteDurationValue: formatTimelineDurationValue(
189
+ userLayerOptions.satelliteDurationValue != null ? userLayerOptions.satelliteDurationValue : '1',
190
+ ),
191
+ mrmsDurationValue: formatTimelineDurationValue(
192
+ userLayerOptions.mrmsDurationValue != null ? userLayerOptions.mrmsDurationValue : '1',
193
+ ),
194
+ nexradDurationValue: formatTimelineDurationValue(
195
+ userLayerOptions.nexradDurationValue != null ? userLayerOptions.nexradDurationValue : '1',
196
+ ),
177
197
  isNexrad: initialNexrad,
178
198
  nexradSite: userLayerOptions.nexradSite ?? null,
179
199
  nexradDataSource: initialNexradDs,
@@ -200,6 +220,20 @@ export class AguaceroCore extends EventEmitter {
200
220
 
201
221
  async setState(newState) {
202
222
  const patch = { ...newState };
223
+ if ('satelliteKey' in patch) delete patch.satelliteKey;
224
+ if ('nexradDataSource' in patch && !('nexradProduct' in patch)) {
225
+ delete patch.nexradDataSource;
226
+ }
227
+ const willBeNexrad =
228
+ patch.isNexrad !== undefined ? Boolean(patch.isNexrad) : this.state.isNexrad;
229
+ if (willBeNexrad && 'nexradProduct' in patch && patch.nexradProduct != null) {
230
+ const p = (patch.nexradProduct || 'REF').toUpperCase();
231
+ const ds = inferNexradDataSourceForProduct(p);
232
+ patch.nexradProduct = p;
233
+ patch.nexradDataSource = ds;
234
+ patch.variable = nexradColormapFldKey(ds, p);
235
+ patch.nexradStormRelative = this._nexradStormRelativeFor(ds, p);
236
+ }
203
237
  if ('forecastHour' in patch && patch.forecastHour != null) {
204
238
  patch.forecastHour = Number(patch.forecastHour);
205
239
  }
@@ -210,12 +244,13 @@ export class AguaceroCore extends EventEmitter {
210
244
  patch.satelliteTimestamp = Number(patch.satelliteTimestamp);
211
245
  }
212
246
  if ('satelliteDurationValue' in patch && patch.satelliteDurationValue != null) {
213
- const n = normalizeTimelineDurationValue(patch.satelliteDurationValue);
214
- patch.satelliteDurationValue = TIMELINE_DURATION_HOUR_VALUES.includes(n) ? n : '1';
247
+ patch.satelliteDurationValue = formatTimelineDurationValue(patch.satelliteDurationValue);
215
248
  }
216
249
  if ('mrmsDurationValue' in patch && patch.mrmsDurationValue != null) {
217
- const n = normalizeTimelineDurationValue(patch.mrmsDurationValue);
218
- patch.mrmsDurationValue = TIMELINE_DURATION_HOUR_VALUES.includes(n) ? n : '1';
250
+ patch.mrmsDurationValue = formatTimelineDurationValue(patch.mrmsDurationValue);
251
+ }
252
+ if ('nexradDurationValue' in patch && patch.nexradDurationValue != null) {
253
+ patch.nexradDurationValue = formatTimelineDurationValue(patch.nexradDurationValue);
219
254
  }
220
255
  if ('nexradTimestamp' in patch && patch.nexradTimestamp != null) {
221
256
  patch.nexradTimestamp = Number(patch.nexradTimestamp);
@@ -253,23 +288,23 @@ export class AguaceroCore extends EventEmitter {
253
288
  }
254
289
 
255
290
  _computeSatelliteTimeline() {
256
- if (!this.state.satelliteKey || !this.satelliteListing?.objects) {
291
+ const { satelliteInstrumentId, satelliteSectorLabel, satelliteChannel } = this.state;
292
+ if (!satelliteInstrumentId || !satelliteSectorLabel || !satelliteChannel || !this.satelliteListing?.objects) {
257
293
  return { unixTimes: [], timeToFileMap: {} };
258
294
  }
259
295
  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';
296
+ const sectorName = satelliteSectorLabel;
264
297
  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);
298
+ const durationOpt = resolveSatelliteDurationOption(sectorName, tier, this.state.satelliteDurationValue);
299
+ return buildSatelliteTimelineForSelection(
300
+ {
301
+ satelliteInstrumentId,
302
+ satelliteSectorLabel,
303
+ satelliteChannel,
304
+ },
305
+ allFiles,
306
+ durationOpt,
307
+ );
273
308
  }
274
309
 
275
310
  /**
@@ -295,7 +330,7 @@ export class AguaceroCore extends EventEmitter {
295
330
  }
296
331
 
297
332
  _nexradListingWindowHours() {
298
- return parseTimelineDurationHours(this.state.mrmsDurationValue);
333
+ return parseTimelineDurationHours(this.state.nexradDurationValue);
299
334
  }
300
335
 
301
336
  _getFilteredNexradTimestampsForVariable(rawList) {
@@ -357,7 +392,7 @@ export class AguaceroCore extends EventEmitter {
357
392
 
358
393
  let availableSatelliteTimestamps = [];
359
394
  let satelliteTimeToFileMap = {};
360
- if (this.state.isSatellite && this.state.satelliteKey) {
395
+ if (this.state.isSatellite && this.state.satelliteInstrumentId) {
361
396
  const timeline = this._computeSatelliteTimeline();
362
397
  satelliteTimeToFileMap = timeline.timeToFileMap || {};
363
398
  availableSatelliteTimestamps = [...(timeline.unixTimes || [])]
@@ -416,7 +451,7 @@ export class AguaceroCore extends EventEmitter {
416
451
 
417
452
  let initialState = { ...this.state };
418
453
 
419
- if (initialState.isSatellite && initialState.satelliteKey) {
454
+ if (initialState.isSatellite && initialState.satelliteInstrumentId) {
420
455
  const timeline = this._computeSatelliteTimeline();
421
456
  const tsList = [...(timeline.unixTimes || [])].sort((a, b) => a - b);
422
457
  if (initialState.satelliteTimestamp == null && tsList.length > 0) {
@@ -625,7 +660,13 @@ export class AguaceroCore extends EventEmitter {
625
660
  async setModel(modelName) {
626
661
  if (modelName === this.state.model || !this.modelStatus?.[modelName]) return;
627
662
  if (this.state.isSatellite) {
628
- await this.setState({ isSatellite: false, satelliteKey: null, satelliteTimestamp: null });
663
+ await this.setState({
664
+ isSatellite: false,
665
+ satelliteInstrumentId: null,
666
+ satelliteSectorLabel: null,
667
+ satelliteChannel: null,
668
+ satelliteTimestamp: null,
669
+ });
629
670
  }
630
671
  const latestRun = findLatestModelRun(this.modelStatus, modelName);
631
672
  if (latestRun) {
@@ -674,7 +715,9 @@ export class AguaceroCore extends EventEmitter {
674
715
  variable,
675
716
  isMRMS: true,
676
717
  isSatellite: false,
677
- satelliteKey: null,
718
+ satelliteInstrumentId: null,
719
+ satelliteSectorLabel: null,
720
+ satelliteChannel: null,
678
721
  satelliteTimestamp: null,
679
722
  mrmsTimestamp: initialTimestamp,
680
723
  });
@@ -691,14 +734,13 @@ export class AguaceroCore extends EventEmitter {
691
734
  }
692
735
 
693
736
  /**
694
- * How many hours of satellite frames to include in the timeline (1, 4, 6, or 12).
737
+ * How many hours of satellite frames to include in the timeline (positive, at most 12 hours).
695
738
  * API default: `layerOptions.satelliteDurationValue` on construction.
696
739
  */
697
740
  async setSatelliteDurationValue(value) {
698
- const v = normalizeTimelineDurationValue(value);
699
- if (!TIMELINE_DURATION_HOUR_VALUES.includes(v)) return;
741
+ const v = formatTimelineDurationValue(value);
700
742
  await this.setState({ satelliteDurationValue: v });
701
- if (!this.state.isSatellite || !this.state.satelliteKey) return;
743
+ if (!this.state.isSatellite || !this.state.satelliteInstrumentId) return;
702
744
  const timeline = this._computeSatelliteTimeline();
703
745
  const tsList = [...(timeline.unixTimes || [])]
704
746
  .map((t) => Number(t))
@@ -713,30 +755,35 @@ export class AguaceroCore extends EventEmitter {
713
755
  }
714
756
 
715
757
  /**
716
- * How many hours of MRMS frames to include in the timeline (1, 4, 6, or 12).
758
+ * Set satellite view using spacecraft id, sector, and channel/product.
759
+ * Omitted fields keep the current selection; when not in satellite mode, missing fields use GOES-19 East CONUS / C13 defaults.
760
+ * @param {{ satelliteId?: string, sector?: string, satelliteSector?: string, satelliteProduct?: string, satelliteChannel?: string, satelliteTimestamp?: number|null }} opts
761
+ */
762
+ async setSatelliteSelection(opts = {}) {
763
+ const cur = this.state.isSatellite ? this.state : null;
764
+ const tsArg =
765
+ opts.satelliteTimestamp !== undefined
766
+ ? opts.satelliteTimestamp
767
+ : this.state.isSatellite && this.state.satelliteTimestamp != null
768
+ ? Number(this.state.satelliteTimestamp)
769
+ : undefined;
770
+ return this.switchMode({
771
+ mode: 'satellite',
772
+ satelliteId: opts.satelliteId ?? cur?.satelliteInstrumentId ?? 'GOES19-EAST',
773
+ satelliteSector: opts.satelliteSector ?? opts.sector ?? cur?.satelliteSectorLabel ?? 'conus',
774
+ satelliteProduct:
775
+ opts.satelliteProduct ?? opts.satelliteChannel ?? cur?.satelliteChannel ?? 'C13',
776
+ satelliteTimestamp: tsArg,
777
+ });
778
+ }
779
+
780
+ /**
781
+ * How many hours of MRMS frames to include in the timeline (positive, at most 12 hours).
717
782
  * API default: `layerOptions.mrmsDurationValue` on construction.
718
783
  */
719
784
  async setMRMSDurationValue(value) {
720
- const v = normalizeTimelineDurationValue(value);
721
- if (!TIMELINE_DURATION_HOUR_VALUES.includes(v)) return;
785
+ const v = formatTimelineDurationValue(value);
722
786
  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
787
  if (!this.state.isMRMS || !this.state.variable) return;
741
788
  const filtered = this._getFilteredMrmsTimestampsForVariable(this.state.variable);
742
789
  if (filtered.length === 0) return;
@@ -747,8 +794,29 @@ export class AguaceroCore extends EventEmitter {
747
794
  }
748
795
  }
749
796
 
797
+ /**
798
+ * NEXRAD sweep listing / scrub window in hours (independent of MRMS duration).
799
+ * API default: `layerOptions.nexradDurationValue` on construction.
800
+ */
801
+ async setNexradDurationValue(value) {
802
+ const v = formatTimelineDurationValue(value);
803
+ await this.setState({ nexradDurationValue: v });
804
+ if (!this.state.isNexrad || !this.state.nexradSite) return;
805
+ await this.refreshNexradTimes();
806
+ const nk = this._nexradTimesCacheKey();
807
+ const filtered = this._getFilteredNexradTimestampsForVariable(
808
+ nk ? this.nexradTimesByStation[nk]?.unixTimes || [] : [],
809
+ );
810
+ if (filtered.length === 0) return;
811
+ const cur = this.state.nexradTimestamp;
812
+ const curN = cur == null ? null : Number(cur);
813
+ if (curN == null || !filtered.includes(curN)) {
814
+ await this.setState({ nexradTimestamp: filtered[filtered.length - 1] });
815
+ }
816
+ }
817
+
750
818
  async switchMode(options) {
751
- const { mode, variable, model, forecastHour, mrmsTimestamp, satelliteKey, satelliteTimestamp } = options;
819
+ let { mode, variable, model, forecastHour, mrmsTimestamp, satelliteTimestamp } = options;
752
820
  if (!mode) {
753
821
  return;
754
822
  }
@@ -758,18 +826,23 @@ export class AguaceroCore extends EventEmitter {
758
826
  if ((mode === 'mrms' || mode === 'model') && !variable) {
759
827
  return;
760
828
  }
761
- if (mode === 'satellite' && !satelliteKey) {
762
- return;
763
- }
764
829
  let targetState = {};
765
830
  if (mode === 'satellite') {
766
- const channelToken = satelliteKey.split('.').pop() || variable || 'C13';
831
+ const satelliteInstrumentId = options.satelliteId ?? 'GOES19-EAST';
832
+ const satelliteSectorLabel = resolveSatelliteSectorLabel(
833
+ options.satelliteSector ?? options.sector ?? 'conus',
834
+ );
835
+ const satelliteChannel =
836
+ options.satelliteProduct ?? options.satelliteChannel ?? variable ?? 'C13';
837
+ const channelToken = satelliteChannel;
767
838
  // Emit satellite mode immediately so map layers (e.g. model grid) clear before listing fetch finishes.
768
839
  await this.setState({
769
840
  isSatellite: true,
770
841
  isMRMS: false,
771
842
  isNexrad: false,
772
- satelliteKey,
843
+ satelliteInstrumentId,
844
+ satelliteSectorLabel,
845
+ satelliteChannel,
773
846
  variable: channelToken,
774
847
  satelliteTimestamp: null,
775
848
  mrmsTimestamp: null,
@@ -780,19 +853,14 @@ export class AguaceroCore extends EventEmitter {
780
853
 
781
854
  await this.fetchSatelliteListing(true);
782
855
  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';
856
+ const sectorName = satelliteSectorLabel;
787
857
  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);
858
+ const durationOpt = resolveSatelliteDurationOption(sectorName, tier, this.state.satelliteDurationValue);
859
+ const timeline = buildSatelliteTimelineForSelection(
860
+ { satelliteInstrumentId, satelliteSectorLabel, satelliteChannel },
861
+ allFiles,
862
+ durationOpt,
863
+ );
796
864
  const tsList = [...(timeline.unixTimes || [])].sort((a, b) => a - b);
797
865
  let finalTs = satelliteTimestamp !== undefined ? satelliteTimestamp : null;
798
866
  if (finalTs == null && tsList.length > 0) {
@@ -821,7 +889,9 @@ export class AguaceroCore extends EventEmitter {
821
889
  variable: variable,
822
890
  mrmsTimestamp: finalTimestamp,
823
891
  model: this.state.model, date: null, run: null, forecastHour: 0,
824
- satelliteKey: null,
892
+ satelliteInstrumentId: null,
893
+ satelliteSectorLabel: null,
894
+ satelliteChannel: null,
825
895
  satelliteTimestamp: null,
826
896
  };
827
897
  } else if (mode === 'model') {
@@ -855,13 +925,14 @@ export class AguaceroCore extends EventEmitter {
855
925
  run: latestRun.run,
856
926
  forecastHour: initialHour, // <-- Changed
857
927
  mrmsTimestamp: null,
858
- satelliteKey: null,
928
+ satelliteInstrumentId: null,
929
+ satelliteSectorLabel: null,
930
+ satelliteChannel: null,
859
931
  satelliteTimestamp: null,
860
932
  };
861
933
  } else if (mode === 'nexrad') {
862
- const nexradDataSource = options.nexradDataSource || 'level2';
863
934
  const nexradProduct = options.nexradProduct || 'REF';
864
- const fld = nexradColormapFldKey(nexradDataSource, nexradProduct);
935
+ const p = nexradProduct.toUpperCase();
865
936
  const site = options.nexradSite ?? null;
866
937
  let tilt =
867
938
  options.nexradTilt != null
@@ -873,16 +944,19 @@ export class AguaceroCore extends EventEmitter {
873
944
  isNexrad: true,
874
945
  isMRMS: false,
875
946
  isSatellite: false,
876
- variable: fld,
877
947
  nexradSite: site,
878
- nexradDataSource,
879
- nexradProduct,
948
+ nexradProduct: p,
880
949
  nexradTilt: tilt,
881
950
  nexradTimestamp: options.nexradTimestamp != null ? Number(options.nexradTimestamp) : null,
882
951
  nexradStormRelative: options.nexradStormRelative === true,
883
952
  nexradShowSitesPicker: options.nexradShowSitesPicker !== false,
953
+ ...(options.nexradDurationValue != null
954
+ ? { nexradDurationValue: formatTimelineDurationValue(options.nexradDurationValue) }
955
+ : {}),
884
956
  mrmsTimestamp: null,
885
- satelliteKey: null,
957
+ satelliteInstrumentId: null,
958
+ satelliteSectorLabel: null,
959
+ satelliteChannel: null,
886
960
  satelliteTimestamp: null,
887
961
  date: null,
888
962
  run: null,
@@ -913,26 +987,23 @@ export class AguaceroCore extends EventEmitter {
913
987
  }
914
988
 
915
989
  /**
916
- * Keep `nexradTilt` on an angle returned by {@link getAvailableNexradTilts} (matches aguacero-frontend elevation buttons).
990
+ * Keep `nexradTilt` on a coalesced elevation (same rules as aguacero-frontend tilt controls).
917
991
  */
918
992
  async _snapNexradTiltToAvailableOptions() {
919
993
  const s = this.state;
920
994
  if (!s.isNexrad || !s.nexradSite) return;
921
- const tilts = getAvailableNexradTilts(
995
+ const raw = getRawNexradTiltsForCoalesce(
922
996
  s.nexradSite,
923
997
  s.nexradDataSource || 'level2',
924
998
  s.nexradProduct || 'REF',
925
999
  );
926
- if (!tilts.length) return;
1000
+ if (!raw.length) return;
927
1001
  const t = s.nexradTilt;
928
- const match = (a, b) => Math.abs(Number(a) - Number(b)) < 1e-4;
929
- if (t != null && tilts.some((x) => match(x, t))) return;
930
1002
  const target = t != null && Number.isFinite(Number(t)) ? Number(t) : getDefaultRadarTilt(s.nexradSite);
931
- let best = tilts[0];
932
- for (const x of tilts) {
933
- if (Math.abs(x - target) < Math.abs(best - target)) best = x;
934
- }
935
- await this.setState({ nexradTilt: best });
1003
+ const canonical = nexradLayerTiltToDisplayOption(target, raw);
1004
+ const match = (a, b) => Math.abs(Number(a) - Number(b)) < 1e-4;
1005
+ if (match(s.nexradTilt, canonical)) return;
1006
+ await this.setState({ nexradTilt: canonical });
936
1007
  }
937
1008
 
938
1009
  async refreshNexradTimes() {
@@ -972,6 +1043,18 @@ export class AguaceroCore extends EventEmitter {
972
1043
  listWindowHours: listingHours,
973
1044
  };
974
1045
  }
1046
+ const filtered = this._getFilteredNexradTimestampsForVariable(out.unixTimes || []);
1047
+ const map = out.timeToKeyMap || {};
1048
+ if (filtered.length > 0) {
1049
+ const cur = this.state.nexradTimestamp != null ? Number(this.state.nexradTimestamp) : null;
1050
+ const hasKey = cur != null && Object.prototype.hasOwnProperty.call(map, String(cur));
1051
+ const inWindow = cur != null && filtered.includes(cur);
1052
+ if (cur == null || !inWindow || !hasKey) {
1053
+ this.state.nexradTimestamp = filtered[filtered.length - 1];
1054
+ }
1055
+ } else if (this.state.nexradTimestamp != null) {
1056
+ this.state.nexradTimestamp = null;
1057
+ }
975
1058
  this._emitStateChange();
976
1059
  }
977
1060
 
@@ -995,48 +1078,7 @@ export class AguaceroCore extends EventEmitter {
995
1078
  async setNexradProduct(product) {
996
1079
  if (!this.state.isNexrad) return;
997
1080
  const p = (product || 'REF').toUpperCase();
998
- const ds = this.state.nexradDataSource || 'level2';
999
- const fld = nexradColormapFldKey(ds, p);
1000
- const nexradStormRelative = this._nexradStormRelativeFor(ds, p);
1001
- await this.setState({ nexradProduct: p, variable: fld, nexradStormRelative });
1002
- await this.refreshNexradTimes();
1003
- const filtered = this._getFilteredNexradTimestampsForVariable(
1004
- this.nexradTimesByStation[this._nexradTimesCacheKey()]?.unixTimes || [],
1005
- );
1006
- if (filtered.length > 0) {
1007
- await this.setState({ nexradTimestamp: filtered[filtered.length - 1] });
1008
- }
1009
- }
1010
-
1011
- async setNexradDataSource(source) {
1012
- if (!this.state.isNexrad) return;
1013
- const ds = source === 'level3' ? 'level3' : 'level2';
1014
- const p = (this.state.nexradProduct || 'REF').toUpperCase();
1015
- const fld = nexradColormapFldKey(ds, p);
1016
- const nexradStormRelative = this._nexradStormRelativeFor(ds, p);
1017
- await this.setState({ nexradDataSource: ds, variable: fld, nexradStormRelative });
1018
- await this.refreshNexradTimes();
1019
- const filtered = this._getFilteredNexradTimestampsForVariable(
1020
- this.nexradTimesByStation[this._nexradTimesCacheKey()]?.unixTimes || [],
1021
- );
1022
- if (filtered.length > 0) {
1023
- await this.setState({ nexradTimestamp: filtered[filtered.length - 1] });
1024
- }
1025
- }
1026
-
1027
- /**
1028
- * Sets NEXRAD product and archive source together (single refresh). Used when the UI exposes one
1029
- * combined menu instead of separate “Level II / Level III” controls.
1030
- * @param {'level2'|'level3'} dataSource
1031
- * @param {string} product - Level-II variable (REF, VEL, …) or Level-III radar key (N0H, HHC, …)
1032
- */
1033
- async setNexradProductMode(dataSource, product) {
1034
- if (!this.state.isNexrad) return;
1035
- const ds = dataSource === 'level3' ? 'level3' : 'level2';
1036
- const p = (product || 'REF').toUpperCase();
1037
- const fld = nexradColormapFldKey(ds, p);
1038
- const nexradStormRelative = this._nexradStormRelativeFor(ds, p);
1039
- await this.setState({ nexradDataSource: ds, nexradProduct: p, variable: fld, nexradStormRelative });
1081
+ await this.setState({ nexradProduct: p });
1040
1082
  await this.refreshNexradTimes();
1041
1083
  const filtered = this._getFilteredNexradTimestampsForVariable(
1042
1084
  this.nexradTimesByStation[this._nexradTimesCacheKey()]?.unixTimes || [],
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.
@@ -35,4 +39,5 @@ export {
35
39
 
36
40
  /** NEXRAD tilt + listing helpers (also importable via subpaths; root re-export fixes Vite/esbuild subpath resolution). */
37
41
  export * from './nexradTilts.js';
42
+ export * from './nexradTiltCoalesce.js';
38
43
  export * from './nexrad_support.js';
@@ -0,0 +1,95 @@
1
+ /**
2
+ * NEXRAD tilt coalescing (matches aguacero-frontend `nexradTiltCoalesce.ts`).
3
+ * Adjacent manifest tilts within {@link NEXRAD_TILT_COALESCE_MAX_GAP}° are merged for UI/listings:
4
+ * pairs → higher tilt; runs of three or more consecutive steps merge the first two into the lower tilt.
5
+ */
6
+
7
+ /** Adjacent tilts (sorted) within this gap share one listing / control. */
8
+ export const NEXRAD_TILT_COALESCE_MAX_GAP = 0.1;
9
+
10
+ /**
11
+ * @param {number[]} segment
12
+ * @param {Map<number, number>} map
13
+ */
14
+ function mergeCoalesceSegment(segment, map) {
15
+ if (segment.length === 0) return;
16
+ if (segment.length === 1) {
17
+ map.set(segment[0], segment[0]);
18
+ return;
19
+ }
20
+ if (segment.length === 2) {
21
+ const canonical = Math.max(segment[0], segment[1]);
22
+ map.set(segment[0], canonical);
23
+ map.set(segment[1], canonical);
24
+ return;
25
+ }
26
+ const low = segment[0];
27
+ map.set(segment[0], low);
28
+ map.set(segment[1], low);
29
+ mergeCoalesceSegment(segment.slice(2), map);
30
+ }
31
+
32
+ /**
33
+ * @param {number[]} sortedUnique
34
+ * @returns {Map<number, number>}
35
+ */
36
+ export function chainCoalesceToCanonical(sortedUnique) {
37
+ const map = new Map();
38
+ let i = 0;
39
+ while (i < sortedUnique.length) {
40
+ let j = i + 1;
41
+ while (
42
+ j < sortedUnique.length &&
43
+ sortedUnique[j] - sortedUnique[j - 1] <= NEXRAD_TILT_COALESCE_MAX_GAP + 1e-9
44
+ ) {
45
+ j++;
46
+ }
47
+ const segment = sortedUnique.slice(i, j);
48
+ mergeCoalesceSegment(segment, map);
49
+ i = j;
50
+ }
51
+ return map;
52
+ }
53
+
54
+ /**
55
+ * Manifest tilt list with adjacent options merged (pairs → higher; triple+ splits per merge rules).
56
+ * @param {number[]} tilts
57
+ * @returns {number[]}
58
+ */
59
+ export function coalesceNexradTiltOptionsForDisplay(tilts) {
60
+ const sorted = [...new Set(tilts)]
61
+ .filter((t) => Number.isFinite(t))
62
+ .sort((a, b) => a - b);
63
+ if (sorted.length <= 1) return sorted;
64
+ const tiltToCanonical = chainCoalesceToCanonical(sorted);
65
+ const reps = [];
66
+ let prevRep;
67
+ for (const t of sorted) {
68
+ const c = tiltToCanonical.get(t);
69
+ if (c === undefined) continue;
70
+ if (c !== prevRep) {
71
+ reps.push(c);
72
+ prevRep = c;
73
+ }
74
+ }
75
+ return reps;
76
+ }
77
+
78
+ /**
79
+ * Map stored/clamped tilt to the coalesced elevation value (same rules as {@link coalesceNexradTiltOptionsForDisplay}).
80
+ * @param {number} layerTilt
81
+ * @param {number[]} manifestTilts raw manifest tilts (before coalescing)
82
+ * @returns {number}
83
+ */
84
+ export function nexradLayerTiltToDisplayOption(layerTilt, manifestTilts) {
85
+ const sorted = [...new Set(manifestTilts)]
86
+ .filter((t) => Number.isFinite(t))
87
+ .sort((a, b) => a - b);
88
+ if (sorted.length === 0) return layerTilt;
89
+ const tiltToCanonical = chainCoalesceToCanonical(sorted);
90
+ if (tiltToCanonical.has(layerTilt)) return tiltToCanonical.get(layerTilt);
91
+ const nearest = sorted.reduce((best, x) =>
92
+ Math.abs(x - layerTilt) < Math.abs(best - layerTilt) ? x : best,
93
+ sorted[0]);
94
+ return tiltToCanonical.get(nearest) ?? layerTilt;
95
+ }
@@ -8,12 +8,24 @@ import {
8
8
  getDefaultRadarTilt,
9
9
  getRadarTilts,
10
10
  } from './nexradTilts.js';
11
+ import { coalesceNexradTiltOptionsForDisplay } from './nexradTiltCoalesce.js';
11
12
  import {
12
13
  NEXRAD_LEVEL3_ELEV,
13
14
  NEXRAD_LEVEL3_MOTION_PRODUCT,
14
15
  getNexradLevel3EntryByRadarKey,
15
16
  } from './nexrad_level3_catalog.js';
16
17
 
18
+ /**
19
+ * Whether listings and archive fetches for this product use Level-II sweep lambda vs Level-III S3 products.
20
+ * AguaceroCore derives this from the product key only (not user-configurable).
21
+ * @param {string} [nexradProduct] - Radar variable key (e.g. REF, KDP, VEL).
22
+ * @returns {'level2'|'level3'}
23
+ */
24
+ export function inferNexradDataSourceForProduct(nexradProduct) {
25
+ const p = (nexradProduct || 'REF').toUpperCase();
26
+ return getNexradLevel3EntryByRadarKey(p) ? 'level3' : 'level2';
27
+ }
28
+
17
29
  /** Colormap / DICTIONARIES.fld key for customColormaps (matches frontend userDefaultColormap). */
18
30
  export function nexradColormapFldKey(nexradDataSource, nexradProduct) {
19
31
  const p = (nexradProduct || 'REF').toUpperCase();
@@ -252,13 +264,15 @@ export async function fetchNexradTimesListing(opts) {
252
264
  const L3_TILT_INDEX_MANIFEST_SLOTS = 6;
253
265
 
254
266
  /**
255
- * Tilt angles for the UI (manifest-driven L2 list; Level III KDP/N0H use the first slots like aguacero-frontend).
267
+ * Raw manifest tilt list before coalescing (same rules as aguacero-frontend `radarTiltOptionsRaw`).
268
+ * Use {@link coalesceNexradTiltOptionsForDisplay} or {@link getAvailableNexradTilts} for UI controls.
269
+ *
256
270
  * @param {string} siteId
257
271
  * @param {'level2'|'level3'} nexradDataSource
258
272
  * @param {string} [nexradProduct]
259
273
  * @returns {number[]}
260
274
  */
261
- export function getAvailableNexradTilts(siteId, nexradDataSource, nexradProduct) {
275
+ export function getRawNexradTiltsForCoalesce(siteId, nexradDataSource, nexradProduct) {
262
276
  if (!siteId) return [];
263
277
  const v = (nexradProduct || 'REF').toUpperCase();
264
278
  const ds = nexradDataSource === 'level3' ? 'level3' : 'level2';
@@ -274,3 +288,18 @@ export function getAvailableNexradTilts(siteId, nexradDataSource, nexradProduct)
274
288
  }
275
289
  return [];
276
290
  }
291
+
292
+ /**
293
+ * Tilt angles for the UI: manifest tilts with adjacent 0.1° steps merged like aguacero-frontend
294
+ * ({@link coalesceNexradTiltOptionsForDisplay}).
295
+ *
296
+ * @param {string} siteId
297
+ * @param {'level2'|'level3'} nexradDataSource
298
+ * @param {string} [nexradProduct]
299
+ * @returns {number[]}
300
+ */
301
+ export function getAvailableNexradTilts(siteId, nexradDataSource, nexradProduct) {
302
+ const raw = getRawNexradTiltsForCoalesce(siteId, nexradDataSource, nexradProduct);
303
+ if (!raw.length) return [];
304
+ return coalesceNexradTiltOptionsForDisplay(raw);
305
+ }
@@ -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)))}`;