@aguacerowx/javascript-sdk 0.0.18 → 0.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,276 @@
1
+ /**
2
+ * NEXRAD Level-II / Level-III time listings (aligned with aguacero-frontend nexradTimes.ts).
3
+ */
4
+
5
+ import {
6
+ formatTiltForApi,
7
+ clampNexradTiltForVariable,
8
+ getDefaultRadarTilt,
9
+ getRadarTilts,
10
+ } from './nexradTilts.js';
11
+ import {
12
+ NEXRAD_LEVEL3_ELEV,
13
+ NEXRAD_LEVEL3_MOTION_PRODUCT,
14
+ getNexradLevel3EntryByRadarKey,
15
+ } from './nexrad_level3_catalog.js';
16
+
17
+ /** Colormap / DICTIONARIES.fld key for customColormaps (matches frontend userDefaultColormap). */
18
+ export function nexradColormapFldKey(nexradDataSource, nexradProduct) {
19
+ const p = (nexradProduct || 'REF').toUpperCase();
20
+ if (nexradDataSource === 'level3') {
21
+ const entry = getNexradLevel3EntryByRadarKey(p);
22
+ return entry?.fldKey ?? `nexrad_l3_${p.toLowerCase()}`;
23
+ }
24
+ const map = {
25
+ REF: 'nexrad_ref',
26
+ PHI: 'nexrad_phi',
27
+ ZDR: 'nexrad_zdr',
28
+ RHO: 'nexrad_rho',
29
+ KDP: 'nexrad_kdp',
30
+ VEL: 'nexrad_vel',
31
+ SW: 'nexrad_sw',
32
+ };
33
+ return map[p] ?? `nexrad_${p.toLowerCase()}`;
34
+ }
35
+
36
+ export const NEXRAD_SWEEP_LAMBDA_URL = 'https://ddknicwcw2wyov7v5bzxmbqu440tzntf.lambda-url.us-east-2.on.aws/';
37
+ export const NEXRAD_LEVEL3_BASE_URL = 'https://unidata-nexrad-level3.s3.amazonaws.com';
38
+
39
+ const NEXRAD_GROUP_G1 = ['REF', 'PHI', 'ZDR', 'RHO'];
40
+ const NEXRAD_GROUP_G2 = ['VEL', 'SW'];
41
+
42
+ export function variableToNexradGroup(variable) {
43
+ const v = (variable || 'REF').toUpperCase();
44
+ if (NEXRAD_GROUP_G2.includes(v)) return 'g2';
45
+ return 'g1';
46
+ }
47
+
48
+ export function nexradBinGroupIdForKey(cacheGroup) {
49
+ return cacheGroup === 'g1' ? 1 : 2;
50
+ }
51
+
52
+ function getLevel3StationPrefix(stationId) {
53
+ const upper = (stationId || '').toUpperCase();
54
+ if (upper.startsWith('K') && upper.length === 4) return upper.slice(1);
55
+ return upper.slice(-3);
56
+ }
57
+
58
+ function parseS3KeyTimeToUnix(key) {
59
+ const m = key.match(/_(\d{4})_(\d{2})_(\d{2})_(\d{2})_(\d{2})_(\d{2})$/);
60
+ if (!m) return null;
61
+ const [, y, mo, d, h, mi, s] = m;
62
+ const unix = Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h), Number(mi), Number(s)) / 1000;
63
+ return Number.isFinite(unix) ? unix : null;
64
+ }
65
+
66
+ export async function fetchLevel3ProductTimesForStation(stationId, productCode, listWindowHours) {
67
+ const stationPrefix = getLevel3StationPrefix(stationId);
68
+ const nowMs = Date.now();
69
+ const nowSec = Math.floor(nowMs / 1000);
70
+ const hourSec = 3600;
71
+ const windowSec = Math.max(hourSec, Math.ceil(listWindowHours) * hourSec);
72
+
73
+ const dayPrefixForOffset = (dayOff) => {
74
+ const dt = new Date(nowMs - dayOff * 86400000);
75
+ const y = dt.getUTCFullYear();
76
+ const mo = String(dt.getUTCMonth() + 1).padStart(2, '0');
77
+ const day = String(dt.getUTCDate()).padStart(2, '0');
78
+ return `${stationPrefix}_${productCode}_${y}_${mo}_${day}_`;
79
+ };
80
+
81
+ const dayPrefixes = [dayPrefixForOffset(0), dayPrefixForOffset(1)];
82
+ const byUnix = new Map();
83
+
84
+ const listKeysForPrefix = async (prefix) => {
85
+ let continuationToken = null;
86
+ for (let page = 0; page < 8; page++) {
87
+ const params = new URLSearchParams({
88
+ 'list-type': '2',
89
+ prefix,
90
+ 'max-keys': '1000',
91
+ });
92
+ if (continuationToken) params.set('continuation-token', continuationToken);
93
+ const res = await fetch(`${NEXRAD_LEVEL3_BASE_URL}/?${params.toString()}`);
94
+ if (!res.ok) throw new Error(`Level3 list HTTP ${res.status}`);
95
+ const xml = await res.text();
96
+ const keyMatches = [...xml.matchAll(/<Key>([^<]+)<\/Key>/g)];
97
+ keyMatches.forEach((match) => {
98
+ const objectKey = match[1];
99
+ const unix = parseS3KeyTimeToUnix(objectKey);
100
+ if (unix == null) return;
101
+ byUnix.set(unix, objectKey);
102
+ });
103
+ const tokenMatch = xml.match(/<NextContinuationToken>([^<]+)<\/NextContinuationToken>/);
104
+ continuationToken = tokenMatch?.[1] || null;
105
+ if (!continuationToken) break;
106
+ }
107
+ };
108
+
109
+ for (const prefix of dayPrefixes) {
110
+ await listKeysForPrefix(prefix);
111
+ }
112
+
113
+ const sorted = [...byUnix.keys()].sort((a, b) => a - b);
114
+ const windowStart = nowSec - windowSec;
115
+ let inWindow = sorted.filter((t) => t >= windowStart);
116
+ if (inWindow.length === 0 && sorted.length > 0) {
117
+ inWindow = sorted.filter((t) => t >= nowSec - Math.max(6 * hourSec, windowSec));
118
+ }
119
+ if (inWindow.length === 0 && sorted.length > 0) {
120
+ inWindow = sorted.slice(-48);
121
+ }
122
+
123
+ const timeToKeyMap = {};
124
+ inWindow.forEach((t) => {
125
+ const k = byUnix.get(t);
126
+ if (k) timeToKeyMap[String(t)] = k;
127
+ });
128
+
129
+ return { unixTimes: inWindow, timeToKeyMap };
130
+ }
131
+
132
+ /**
133
+ * @param {object} opts
134
+ * @param {string} opts.stationId
135
+ * @param {string} [opts.variable]
136
+ * @param {string} [opts.elev]
137
+ * @param {'level2'|'level3'} [opts.source]
138
+ * @param {boolean} [opts.level3StormRelative]
139
+ * @param {number} opts.listingWindowHours
140
+ */
141
+ export async function fetchNexradTimesListing(opts) {
142
+ const {
143
+ stationId,
144
+ variable = 'REF',
145
+ elev,
146
+ source = 'level2',
147
+ level3StormRelative,
148
+ listingWindowHours,
149
+ } = opts;
150
+ if (!stationId) {
151
+ return { unixTimes: [], timeToKeyMap: {}, level3MotionUnixTimes: [], level3MotionTimeToKeyMap: {} };
152
+ }
153
+
154
+ const dataSource = source === 'level3' ? 'level3' : 'level2';
155
+ let tiltNum = Number(elev);
156
+ if (!Number.isFinite(tiltNum)) tiltNum = getDefaultRadarTilt(stationId);
157
+ const elevNormUse =
158
+ dataSource === 'level3'
159
+ ? NEXRAD_LEVEL3_ELEV
160
+ : formatTiltForApi(clampNexradTiltForVariable(stationId, variable, tiltNum));
161
+ const group = dataSource === 'level3' ? 'l3' : variableToNexradGroup(variable);
162
+ const l3Product =
163
+ dataSource === 'level3'
164
+ ? opts.level3Product ??
165
+ getNexradLevel3EntryByRadarKey(variable)?.product ??
166
+ (variable === 'VEL' ? 'N0G' : variable)
167
+ : '';
168
+ const fetchL3Motion =
169
+ dataSource === 'level3' &&
170
+ l3Product === 'N0G' &&
171
+ variable === 'VEL' &&
172
+ level3StormRelative === true;
173
+ const fetchL2VelMotion =
174
+ dataSource === 'level2' && variable === 'VEL' && level3StormRelative === true;
175
+
176
+ const key =
177
+ dataSource === 'level3'
178
+ ? `${stationId}_l3_${l3Product}_${elevNormUse}`
179
+ : `${stationId}_${group}_${elevNormUse}`;
180
+ const level3MotionKey =
181
+ dataSource === 'level3' && fetchL3Motion
182
+ ? `${stationId}_l3_${NEXRAD_LEVEL3_MOTION_PRODUCT}_${elevNormUse}`
183
+ : '';
184
+ const l2StormMotionListKey =
185
+ dataSource === 'level2' && fetchL2VelMotion
186
+ ? `${stationId}_l3_${NEXRAD_LEVEL3_MOTION_PRODUCT}_${NEXRAD_LEVEL3_ELEV}`
187
+ : '';
188
+
189
+ let times = [];
190
+ let timeToKeyMap = {};
191
+ let l3MotionUnixTimes = [];
192
+ let l3MotionTimeToKeyMap = {};
193
+
194
+ if (dataSource === 'level3') {
195
+ const l3Primary = await fetchLevel3ProductTimesForStation(stationId, l3Product, listingWindowHours);
196
+ times = l3Primary.unixTimes;
197
+ timeToKeyMap = l3Primary.timeToKeyMap;
198
+ if (fetchL3Motion) {
199
+ const l3Motion = await fetchLevel3ProductTimesForStation(
200
+ stationId,
201
+ NEXRAD_LEVEL3_MOTION_PRODUCT,
202
+ listingWindowHours,
203
+ );
204
+ l3MotionUnixTimes = l3Motion.unixTimes;
205
+ l3MotionTimeToKeyMap = l3Motion.timeToKeyMap;
206
+ }
207
+ } else {
208
+ const params = new URLSearchParams({
209
+ station: stationId,
210
+ field: group,
211
+ elev: elevNormUse,
212
+ hours: String(listingWindowHours),
213
+ });
214
+ const url = `${NEXRAD_SWEEP_LAMBDA_URL}?${params.toString()}`;
215
+ const res = await fetch(url);
216
+ if (!res.ok) throw new Error(`NEXRAD lambda HTTP ${res.status}`);
217
+ const data = await res.json();
218
+ if (Array.isArray(data?.times)) {
219
+ times = data.times;
220
+ } else if (data?.body) {
221
+ const body = typeof data.body === 'string' ? JSON.parse(data.body) : data.body;
222
+ times = Array.isArray(body?.times) ? body.times : [];
223
+ }
224
+ const groupId = nexradBinGroupIdForKey(group);
225
+ const elevNorm = elevNormUse;
226
+ times.forEach((t) => {
227
+ timeToKeyMap[String(t)] = `${stationId}_${elevNorm}_${t}_g${groupId}.bin`;
228
+ });
229
+ if (fetchL2VelMotion) {
230
+ const l3Motion = await fetchLevel3ProductTimesForStation(
231
+ stationId,
232
+ NEXRAD_LEVEL3_MOTION_PRODUCT,
233
+ listingWindowHours,
234
+ );
235
+ l3MotionUnixTimes = l3Motion.unixTimes;
236
+ l3MotionTimeToKeyMap = l3Motion.timeToKeyMap;
237
+ }
238
+ }
239
+
240
+ const out = {
241
+ cacheKey: key,
242
+ unixTimes: times,
243
+ timeToKeyMap,
244
+ level3MotionKey: level3MotionKey || null,
245
+ level3MotionUnixTimes: l3MotionUnixTimes,
246
+ level3MotionTimeToKeyMap: l3MotionTimeToKeyMap,
247
+ l2StormMotionListKey: l2StormMotionListKey || null,
248
+ };
249
+ return out;
250
+ }
251
+
252
+ const L3_TILT_INDEX_MANIFEST_SLOTS = 6;
253
+
254
+ /**
255
+ * Tilt angles for the UI (manifest-driven L2 list; Level III KDP/N0H use the first slots like aguacero-frontend).
256
+ * @param {string} siteId
257
+ * @param {'level2'|'level3'} nexradDataSource
258
+ * @param {string} [nexradProduct]
259
+ * @returns {number[]}
260
+ */
261
+ export function getAvailableNexradTilts(siteId, nexradDataSource, nexradProduct) {
262
+ if (!siteId) return [];
263
+ const v = (nexradProduct || 'REF').toUpperCase();
264
+ const ds = nexradDataSource === 'level3' ? 'level3' : 'level2';
265
+ const tilts = getRadarTilts(siteId, v);
266
+ if (ds === 'level2') {
267
+ return [...tilts];
268
+ }
269
+ if (v === 'KDP' || v === 'N0H') {
270
+ return tilts.slice(0, L3_TILT_INDEX_MANIFEST_SLOTS);
271
+ }
272
+ if (v === 'VEL') {
273
+ return [...tilts];
274
+ }
275
+ return [];
276
+ }
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Satellite listing / timeline helpers aligned with aguacero-frontend (ActiveTimeContext + satelliteAvailability).
3
+ * Used by AguaceroCore for GOES-East WebGL satellite mode.
4
+ */
5
+
6
+ export const SATELLITE_FRAMES_URL = 'https://rhnq3edhcry5n6nljn6my4o3h40zwxze.lambda-url.us-east-2.on.aws/';
7
+
8
+ /** Same sector labels as production bundle / ActiveTimeContext SECTOR_CODE_MAP (inverse). */
9
+ export const GOES_EAST_SATELLITE_SECTORS = [
10
+ 'GOES-EAST CONUS',
11
+ 'GOES-EAST FULL DISK',
12
+ 'GOES-EAST MESOSCALE 1',
13
+ 'GOES-EAST MESOSCALE 2',
14
+ ];
15
+
16
+ /** Flat channel ids matching CHANNEL_CATEGORIES in frontend dictionaries. */
17
+ export const GOES_SATELLITE_CHANNELS = [
18
+ ...['true_color', 'geocolor', 'ntmicro', 'day_cloud_phase', 'day_land_cloud_fire', 'air_mass', 'sandwich', 'simple_water_vapor', 'dust', 'fire_temperature'],
19
+ 'C01', 'C02', 'C03', 'C04', 'C05', 'C06', 'C07', 'C08', 'C09', 'C10', 'C11', 'C12', 'C13', 'C14', 'C15', 'C16',
20
+ ];
21
+
22
+ /**
23
+ * Human-readable labels for {@link GOES_SATELLITE_CHANNELS}, aligned with aguacero-frontend `CHANNEL_LABELS`.
24
+ */
25
+ export const GOES_SATELLITE_CHANNEL_LABELS = {
26
+ true_color: 'True Color',
27
+ ntmicro: 'Night Microphysics',
28
+ day_cloud_phase: 'Day Cloud Phase',
29
+ day_land_cloud_fire: 'Natural Color Fire',
30
+ air_mass: 'Air Mass',
31
+ sandwich: 'Sandwich',
32
+ simple_water_vapor: 'Simple Water Vapor',
33
+ dust: 'Dust',
34
+ geocolor: 'Geocolor',
35
+ fire_temperature: 'Fire Temperature',
36
+ C01: 'Blue - Visible',
37
+ C02: 'Red - Visible',
38
+ C03: 'Veggie - Near IR',
39
+ C04: 'Cirrus - Near IR',
40
+ C05: 'Snow/Ice - Near IR',
41
+ C06: 'Cloud Particle - Near IR',
42
+ C07: 'Shortwave Window - IR',
43
+ C08: 'Upper-Level Water Vapor - IR',
44
+ C09: 'Mid-Level Water Vapor - IR',
45
+ C10: 'Lower-Level Water Vapor - IR',
46
+ C11: 'Cloud Top - IR',
47
+ C12: 'Ozone - IR',
48
+ C13: 'Clean Longwave Window - IR',
49
+ C14: 'Longwave Window - IR',
50
+ C15: 'Dirty Longwave Window - IR',
51
+ C16: 'CO2 Longwave - IR',
52
+ };
53
+
54
+ const SECTOR_CODE_MAP = {
55
+ 'GOES-EAST CONUS': 'C',
56
+ 'GOES-EAST FULL DISK': 'F',
57
+ 'GOES-EAST MESOSCALE 1': 'M1',
58
+ 'GOES-EAST MESOSCALE 2': 'M2',
59
+ };
60
+
61
+ const LUMPED_MULTIBAND_SATELLITE_SECTORS = new Set(['GOES-EAST CONUS', 'GOES-EAST FULL DISK']);
62
+
63
+ /** Uppercase RGB channel tokens as in production (CHANNEL_CATEGORIES.RGB). */
64
+ const SATELLITE_RGB_PRODUCT_UPPER = new Set([
65
+ 'TRUE_COLOR',
66
+ 'GEOCOLOR',
67
+ 'NTMICRO',
68
+ 'DAY_CLOUD_PHASE',
69
+ 'DAY_LAND_CLOUD_FIRE',
70
+ 'AIR_MASS',
71
+ 'SANDWICH',
72
+ 'SIMPLE_WATER_VAPOR',
73
+ 'DUST',
74
+ 'FIRE_TEMPERATURE',
75
+ ]);
76
+
77
+ /**
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.
80
+ */
81
+ export const TIMELINE_DURATION_HOUR_VALUES = ['1', '4', '6', '12'];
82
+
83
+ const LEGACY_SATELLITE_DURATION_ALIASES = {
84
+ '0.5': '1',
85
+ };
86
+
87
+ /** Tier keys used in SATELLITE_DURATION_CONFIG (frontend dictionaries). */
88
+ export const SATELLITE_DURATION_CONFIG = {
89
+ CONUS: {
90
+ basic: [
91
+ { label: '1 Hr', value: '1', interval: 300 },
92
+ { label: '4 Hr', value: '4', interval: 300 },
93
+ { label: '6 Hr', value: '6', interval: 300 },
94
+ { label: '12 Hr', value: '12', interval: 300 },
95
+ ],
96
+ },
97
+ FULL_DISK: {
98
+ basic: [
99
+ { label: '1 Hr', value: '1', interval: 600 },
100
+ { label: '4 Hr', value: '4', interval: 600 },
101
+ { label: '6 Hr', value: '6', interval: 600 },
102
+ { label: '12 Hr', value: '12', interval: 600 },
103
+ ],
104
+ },
105
+ MESOSCALE: {
106
+ basic: [
107
+ { label: '1 Hr', value: '1', interval: 120 },
108
+ { label: '4 Hr', value: '4', interval: 120 },
109
+ { label: '6 Hr', value: '6', interval: 120 },
110
+ { label: '12 Hr', value: '12', interval: 120 },
111
+ ],
112
+ },
113
+ };
114
+
115
+ /**
116
+ * Normalizes satellite or MRMS duration option values (including legacy '0.5' → '1').
117
+ * @param {string|number} value
118
+ * @returns {string}
119
+ */
120
+ export function normalizeTimelineDurationValue(value) {
121
+ const s = value == null ? '1' : String(value);
122
+ return LEGACY_SATELLITE_DURATION_ALIASES[s] || s;
123
+ }
124
+
125
+ /**
126
+ * @param {string|number} value
127
+ * @returns {number} Hours in [1, 4, 6, 12], defaulting to 1.
128
+ */
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;
134
+ }
135
+
136
+ export function getDefaultSatelliteDurationOption(sectorName, tier = 'basic') {
137
+ let sectorType = 'CONUS';
138
+ if (sectorName.includes('FULL DISK')) sectorType = 'FULL_DISK';
139
+ else if (sectorName.includes('MESOSCALE')) sectorType = 'MESOSCALE';
140
+ const tierConfig = SATELLITE_DURATION_CONFIG[sectorType]?.[tier] || SATELLITE_DURATION_CONFIG.CONUS.basic;
141
+ return tierConfig.find((o) => o.value === '1') || tierConfig[0];
142
+ }
143
+
144
+ export function calculateUnixTimeFromSatelliteKey(fileKey) {
145
+ try {
146
+ const match = fileKey.match(/_(\d{10})(?:\.ktx2)?$/);
147
+ if (match) return parseInt(match[1], 10);
148
+ const oldMatch = fileKey.match(/_s(\d{4})(\d{3})(\d{2})(\d{2})(\d{2})/);
149
+ if (oldMatch) {
150
+ const [, year, dayOfYear, hour, minute, second] = oldMatch.map(Number);
151
+ const date = new Date(Date.UTC(year, 0, 1));
152
+ date.setUTCDate(dayOfYear);
153
+ date.setUTCHours(hour, minute, second, 0);
154
+ return date.getTime() / 1000;
155
+ }
156
+ return 0;
157
+ } catch {
158
+ return 0;
159
+ }
160
+ }
161
+
162
+ /**
163
+ * @param {string} satelliteKey e.g. "GOES19-EAST.GOES-EAST CONUS.C13"
164
+ * @param {string[]} allFiles S3 keys from satellite listing API
165
+ * @param {{ value: string, interval: number }} durationOption
166
+ */
167
+ export function buildSatelliteTimelineForKey(satelliteKey, allFiles, durationOption) {
168
+ const parts = satelliteKey.split('.');
169
+ if (parts.length < 3) {
170
+ return { unixTimes: [], fileList: [], timeToFileMap: {} };
171
+ }
172
+ const [satelliteName, categoryName, channelName] = parts;
173
+
174
+ const SECTOR_CODE_MAP_LOCAL = {
175
+ 'GOES-EAST CONUS': 'C',
176
+ 'GOES-EAST FULL DISK': 'F',
177
+ 'GOES-EAST MESOSCALE 1': 'M1',
178
+ 'GOES-EAST MESOSCALE 2': 'M2',
179
+ };
180
+
181
+ const satelliteNum = satelliteName.replace('GOES', '').replace('-EAST', '').replace('-WEST', '');
182
+ const satId = `G${satelliteNum}`;
183
+ const sectorCode = SECTOR_CODE_MAP_LOCAL[categoryName] || 'C';
184
+
185
+ let sectorType = 'CONUS';
186
+ if (categoryName.includes('FULL DISK')) sectorType = 'FULL_DISK';
187
+ else if (categoryName.includes('MESOSCALE')) sectorType = 'MESOSCALE';
188
+
189
+ const intervalSeconds = durationOption?.interval || 300;
190
+ const durationHours = durationOption?.value ? parseFloat(durationOption.value) : 1;
191
+
192
+ const filePrefix = `${sectorCode}_${satId}_`;
193
+
194
+ let searchProduct = channelName.toUpperCase();
195
+ if (!/^C\d{2}$/.test(searchProduct)) {
196
+ if (SATELLITE_RGB_PRODUCT_UPPER.has(searchProduct)) {
197
+ searchProduct = LUMPED_MULTIBAND_SATELLITE_SECTORS.has(categoryName) ? 'MULTI' : searchProduct;
198
+ } else {
199
+ searchProduct = 'MULTI';
200
+ }
201
+ }
202
+
203
+ const matchString = `${filePrefix}${searchProduct}`;
204
+
205
+ let relevantFiles = allFiles.filter((file) => file.startsWith(matchString));
206
+
207
+ if (relevantFiles.length === 0) {
208
+ return { unixTimes: [], fileList: [], timeToFileMap: {} };
209
+ }
210
+
211
+ relevantFiles.sort((a, b) => {
212
+ const timeA = calculateUnixTimeFromSatelliteKey(a);
213
+ const timeB = calculateUnixTimeFromSatelliteKey(b);
214
+ return timeA - timeB;
215
+ });
216
+
217
+ let cutoffTime = 0;
218
+ if (durationHours > 0) {
219
+ const latestTime = calculateUnixTimeFromSatelliteKey(relevantFiles[relevantFiles.length - 1]);
220
+ cutoffTime = latestTime - durationHours * 3600;
221
+ }
222
+
223
+ let processedFiles = relevantFiles.filter((f) => calculateUnixTimeFromSatelliteKey(f) >= cutoffTime);
224
+
225
+ if (intervalSeconds > 0 && processedFiles.length > 0) {
226
+ const intervalMinutes = intervalSeconds / 60;
227
+
228
+ if (sectorType === 'MESOSCALE') {
229
+ processedFiles = processedFiles.filter((file) => {
230
+ if (intervalMinutes <= 1.1) return true;
231
+ const t = calculateUnixTimeFromSatelliteKey(file);
232
+ const date = new Date(t * 1000);
233
+ const minutes = date.getUTCMinutes();
234
+ return minutes % intervalMinutes === 0;
235
+ });
236
+ } else {
237
+ if (intervalSeconds >= 300) {
238
+ processedFiles = processedFiles.filter((file) => {
239
+ if (Math.abs(intervalMinutes - 5) < 0.1) return true;
240
+ const t = calculateUnixTimeFromSatelliteKey(file);
241
+ const minutes = new Date(t * 1000).getUTCMinutes();
242
+ const remainder = minutes % intervalMinutes;
243
+ const distToInterval = Math.min(remainder, intervalMinutes - remainder);
244
+ return distToInterval < 2.5;
245
+ });
246
+ } else {
247
+ processedFiles = processedFiles.filter((file) => {
248
+ const t = calculateUnixTimeFromSatelliteKey(file);
249
+ const minutes = new Date(t * 1000).getUTCMinutes();
250
+ return minutes % intervalMinutes === 0;
251
+ });
252
+ }
253
+ }
254
+
255
+ if (processedFiles.length === 0 && relevantFiles.length > 0) {
256
+ processedFiles = relevantFiles.slice(-1);
257
+ }
258
+ }
259
+
260
+ const unixTimes = [];
261
+ const timeToFileMap = {};
262
+ processedFiles.forEach((file) => {
263
+ const unixTime = calculateUnixTimeFromSatelliteKey(file);
264
+ if (unixTime > 0) {
265
+ unixTimes.push(unixTime);
266
+ timeToFileMap[unixTime] = file;
267
+ }
268
+ });
269
+
270
+ // Chronological (oldest → newest): matches standard time sliders (earlier left, later right).
271
+ unixTimes.sort((a, b) => a - b);
272
+
273
+ return { unixTimes, fileList: processedFiles, timeToFileMap };
274
+ }
275
+
276
+ /**
277
+ * Full satellite keys for GOES-19 East (same naming as production).
278
+ */
279
+ export function getAllGoesEastSatelliteKeys(satelliteName = 'GOES19-EAST') {
280
+ const keys = [];
281
+ for (const sector of GOES_EAST_SATELLITE_SECTORS) {
282
+ for (const ch of GOES_SATELLITE_CHANNELS) {
283
+ keys.push(`${satelliteName}.${sector}.${ch}`);
284
+ }
285
+ }
286
+ return keys;
287
+ }
288
+
289
+ export function resolveSatelliteS3FileName(satelliteKey, timeToFileMap, satelliteTimestamp) {
290
+ const fileFromMap = timeToFileMap?.[satelliteTimestamp];
291
+ if (fileFromMap) return fileFromMap;
292
+ const [, , channelName] = satelliteKey.split('.');
293
+ const channelNameUpper = channelName.toUpperCase();
294
+ let name = fileFromMap || '';
295
+ if (!name && satelliteTimestamp != null) {
296
+ const suffix = `_${String(Math.floor(Number(satelliteTimestamp)))}`;
297
+ const multiName = Object.values(timeToFileMap || {}).find((f) => f.includes('MULTI') && f.includes(suffix));
298
+ if (multiName) {
299
+ name = multiName.replace('MULTI', channelNameUpper);
300
+ }
301
+ }
302
+ return name;
303
+ }
304
+
305
+ export { SECTOR_CODE_MAP };