@aguacerowx/javascript-sdk 0.0.16 → 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.
@@ -1,22 +1,8 @@
1
- // This module acts as a platform-agnostic proxy.
2
-
3
- let getBundleIdImpl;
4
-
5
- try {
6
- // This line will ONLY succeed in a React Native environment where the
7
- // 'react-native-device-info' module is installed.
8
- const DeviceInfo = require('react-native-device-info');
9
-
10
- // If the above line doesn't throw an error, we set the implementation
11
- // to the native version.
12
- getBundleIdImpl = () => DeviceInfo.getBundleId();
13
-
14
- } catch (e) {
15
- // If require() fails, we know we are in a non-React Native environment
16
- // (like the web). We set the implementation to a "dummy" function
17
- // that does nothing and returns null.
18
- getBundleIdImpl = () => null;
19
- }
20
-
21
- // Export the chosen implementation.
22
- export const getBundleId = getBundleIdImpl;
1
+ /**
2
+ * Default implementation for web and Node bundlers (Vite, webpack).
3
+ * Do not import react-native-device-info here: static analysis would pull in
4
+ * `react-native` (Flow sources) and break esbuild.
5
+ *
6
+ * Metro resolves `getBundleId.native.js` on iOS/Android instead of this file.
7
+ */
8
+ export const getBundleId = () => null;
@@ -0,0 +1,14 @@
1
+ /**
2
+ * React Native (Metro picks this file over getBundleId.js on native platforms).
3
+ */
4
+
5
+ let getBundleIdImpl;
6
+
7
+ try {
8
+ const DeviceInfo = require('react-native-device-info');
9
+ getBundleIdImpl = () => DeviceInfo.getBundleId();
10
+ } catch {
11
+ getBundleIdImpl = () => null;
12
+ }
13
+
14
+ export const getBundleId = getBundleIdImpl;
@@ -0,0 +1,32 @@
1
+ import { decompress } from 'fzstd';
2
+
3
+ function reconstructData(decompressedDeltas, encoding) {
4
+ const expectedLength = encoding.length;
5
+ const reconstructedData = new Int8Array(expectedLength);
6
+
7
+ if (decompressedDeltas.length > 0 && expectedLength > 0) {
8
+ reconstructedData[0] = decompressedDeltas[0] > 127 ? decompressedDeltas[0] - 256 : decompressedDeltas[0];
9
+ for (let i = 1; i < expectedLength; i++) {
10
+ const delta = decompressedDeltas[i] > 127 ? decompressedDeltas[i] - 256 : decompressedDeltas[i];
11
+ reconstructedData[i] = reconstructedData[i - 1] + delta;
12
+ }
13
+ }
14
+ return new Uint8Array(reconstructedData.buffer);
15
+ }
16
+
17
+ /**
18
+ * zstd decompress → delta reconstruction → unsigned byte offset for GPU lookup.
19
+ * Used on the main thread as a fallback and inside {@link ./gridDecodeWorker.js}.
20
+ */
21
+ export function processCompressedGrid(compressedData, encoding) {
22
+ const decompressedDeltas = decompress(compressedData);
23
+ const finalData = reconstructData(decompressedDeltas, encoding);
24
+
25
+ const transformedData = new Uint8Array(finalData.length);
26
+ for (let i = 0; i < finalData.length; i++) {
27
+ const signedValue = finalData[i] > 127 ? finalData[i] - 256 : finalData[i];
28
+ transformedData[i] = signedValue + 128;
29
+ }
30
+
31
+ return { data: transformedData, encoding };
32
+ }
@@ -0,0 +1,24 @@
1
+ import { processCompressedGrid } from './gridDecodePipeline.js';
2
+
3
+ self.onmessage = (e) => {
4
+ const { id, encoding, compressedBuffer, compressedByteOffset, compressedByteLength } = e.data;
5
+ try {
6
+ const compressedData = new Uint8Array(compressedBuffer, compressedByteOffset, compressedByteLength);
7
+ const { data, encoding: enc } = processCompressedGrid(compressedData, encoding);
8
+ self.postMessage(
9
+ {
10
+ id,
11
+ encoding: enc,
12
+ dataBuffer: data.buffer,
13
+ dataByteOffset: data.byteOffset,
14
+ dataByteLength: data.byteLength,
15
+ },
16
+ [data.buffer]
17
+ );
18
+ } catch (err) {
19
+ self.postMessage({
20
+ id,
21
+ error: err?.message || String(err),
22
+ });
23
+ }
24
+ };
package/src/index.js CHANGED
@@ -5,7 +5,23 @@ import { AguaceroCore } from './AguaceroCore.js';
5
5
  import { EventEmitter } from './events.js';
6
6
  import { THEME_CONFIGS } from './map-styles.js';
7
7
  import { DICTIONARIES } from './dictionaries.js';
8
+ import { DEFAULT_COLORMAPS } from './default-colormaps.js';
8
9
  import { getUnitConversionFunction } from './unitConversions.js';
10
+ export {
11
+ SATELLITE_FRAMES_URL,
12
+ GOES_EAST_SATELLITE_SECTORS,
13
+ GOES_SATELLITE_CHANNELS,
14
+ GOES_SATELLITE_CHANNEL_LABELS,
15
+ SATELLITE_DURATION_CONFIG,
16
+ TIMELINE_DURATION_HOUR_VALUES,
17
+ normalizeTimelineDurationValue,
18
+ parseTimelineDurationHours,
19
+ getDefaultSatelliteDurationOption,
20
+ buildSatelliteTimelineForKey,
21
+ getAllGoesEastSatelliteKeys,
22
+ calculateUnixTimeFromSatelliteKey,
23
+ resolveSatelliteS3FileName,
24
+ } from './satellite_support.js';
9
25
 
10
26
  // Now, export them all so other packages can import them.
11
27
  export {
@@ -13,5 +29,10 @@ export {
13
29
  EventEmitter,
14
30
  THEME_CONFIGS,
15
31
  DICTIONARIES,
32
+ DEFAULT_COLORMAPS,
16
33
  getUnitConversionFunction
17
- };
34
+ };
35
+
36
+ /** NEXRAD tilt + listing helpers (also importable via subpaths; root re-export fixes Vite/esbuild subpath resolution). */
37
+ export * from './nexradTilts.js';
38
+ export * from './nexrad_support.js';
@@ -0,0 +1,128 @@
1
+ /**
2
+ * NEXRAD tilt helpers (mirrors aguacero-frontend dictionaries + radar tilts manifest behavior).
3
+ * When {@link setRadarTiltsManifest} has been called with a non-empty map, per-site tilts follow it.
4
+ */
5
+
6
+ const WSR88D_TILTS = [0.5, 0.9, 1.3, 1.8, 2.4, 3.1, 4.0, 5.1, 6.4];
7
+ const TDWR_TILTS = [0.3, 1.0, 2, 6.5, 8.8, 16.0, 21.3];
8
+
9
+ const WSR88D_SITE_IDS_WITH_T_PREFIX = new Set(['TJUA']);
10
+
11
+ /** @type {Record<string, { g1_tilts: number[]; g2_tilts: number[]; last_seen?: number }>} */
12
+ let manifestBySite = {};
13
+
14
+ export function setRadarTiltsManifest(map) {
15
+ manifestBySite = map && typeof map === 'object' ? map : {};
16
+ }
17
+
18
+ export function getRadarTiltsManifest() {
19
+ return manifestBySite;
20
+ }
21
+
22
+ export function isNexradTdwrSiteId(siteId) {
23
+ const id = String(siteId ?? '').trim().toUpperCase();
24
+ if (!id) return false;
25
+ if (WSR88D_SITE_IDS_WITH_T_PREFIX.has(id)) return false;
26
+ return id.startsWith('T');
27
+ }
28
+
29
+ export function isTerminalRadar(siteId) {
30
+ return isNexradTdwrSiteId(siteId);
31
+ }
32
+
33
+ const NEXRAD_G2_VARIABLES = new Set(['VEL', 'SW']);
34
+ const TDWR_DUAL_POL_CAPPED = new Set(['ZDR', 'PHI', 'RHO', 'KDP']);
35
+ const TDWR_DUAL_POL_MAX_TILT = 0.3;
36
+
37
+ function uniqSorted(nums) {
38
+ const arr = nums.filter((n) => typeof n === 'number' && Number.isFinite(n));
39
+ const rounded = arr.map((n) => Math.round(n * 1000) / 1000);
40
+ return [...new Set(rounded)].sort((a, b) => a - b);
41
+ }
42
+
43
+ function computeRadarTiltsFromManifestEntry(entry, siteId, radarVariable) {
44
+ const v = (radarVariable || 'REF').toUpperCase();
45
+ let base;
46
+ if (NEXRAD_G2_VARIABLES.has(v)) {
47
+ base = uniqSorted(entry.g2_tilts || []);
48
+ } else {
49
+ base = uniqSorted([...(entry.g1_tilts || []), ...(entry.g2_tilts || [])]);
50
+ }
51
+ if (isNexradTdwrSiteId(siteId) && TDWR_DUAL_POL_CAPPED.has(v)) {
52
+ return base.filter((t) => t <= TDWR_DUAL_POL_MAX_TILT);
53
+ }
54
+ return base;
55
+ }
56
+
57
+ function getDefaultTiltFromManifestEntry(entry) {
58
+ const g1 = entry.g1_tilts;
59
+ if (!Array.isArray(g1) || g1.length === 0) return null;
60
+ const sorted = uniqSorted(g1);
61
+ return sorted[0] ?? null;
62
+ }
63
+
64
+ export function getDefaultRadarTilt(siteId) {
65
+ const id = siteId?.toUpperCase();
66
+ const entry = id ? manifestBySite[id] : undefined;
67
+ if (entry) {
68
+ const t = getDefaultTiltFromManifestEntry(entry);
69
+ if (t != null) return t;
70
+ }
71
+ return isTerminalRadar(siteId) ? 0.3 : 0.5;
72
+ }
73
+
74
+ export function getRadarTilts(siteId, radarVariable) {
75
+ const id = siteId?.toUpperCase();
76
+ const entry = id ? manifestBySite[id] : undefined;
77
+ if (entry) {
78
+ const fromManifest = computeRadarTiltsFromManifestEntry(entry, siteId, radarVariable);
79
+ if (fromManifest.length) return fromManifest;
80
+ }
81
+ const base = isTerminalRadar(siteId) ? TDWR_TILTS : WSR88D_TILTS;
82
+ if (!radarVariable || !isTerminalRadar(siteId)) return [...base];
83
+ const v = radarVariable.toUpperCase();
84
+ if (['ZDR', 'PHI', 'RHO'].includes(v)) {
85
+ return base.filter((t) => t <= TDWR_DUAL_POL_MAX_TILT);
86
+ }
87
+ return [...base];
88
+ }
89
+
90
+ export function clampNexradTiltForVariable(siteId, variable, tilt) {
91
+ if (!isTerminalRadar(siteId)) return tilt;
92
+ const v = (variable || 'REF').toUpperCase();
93
+ if (['ZDR', 'PHI', 'RHO'].includes(v)) {
94
+ return Math.min(tilt, TDWR_DUAL_POL_MAX_TILT);
95
+ }
96
+ return tilt;
97
+ }
98
+
99
+ export function formatTiltForApi(tilt) {
100
+ return tilt.toFixed(2);
101
+ }
102
+
103
+ export const RADAR_TILTS_MANIFEST_URL =
104
+ 'https://radar-tilts.s3.us-east-2.amazonaws.com/manifest.json';
105
+
106
+ export async function fetchRadarTiltsManifestFromNetwork() {
107
+ try {
108
+ const res = await fetch(RADAR_TILTS_MANIFEST_URL, { cache: 'no-store' });
109
+ if (!res.ok) return null;
110
+ const raw = await res.json();
111
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
112
+ const out = {};
113
+ for (const [key, val] of Object.entries(raw)) {
114
+ if (!val || typeof val !== 'object' || Array.isArray(val)) continue;
115
+ const g1 = val.g1_tilts;
116
+ const g2 = val.g2_tilts;
117
+ if (!Array.isArray(g1) || !Array.isArray(g2)) continue;
118
+ out[key.toUpperCase()] = {
119
+ g1_tilts: g1.filter((n) => typeof n === 'number' && Number.isFinite(n)),
120
+ g2_tilts: g2.filter((n) => typeof n === 'number' && Number.isFinite(n)),
121
+ last_seen: typeof val.last_seen === 'number' ? val.last_seen : undefined,
122
+ };
123
+ }
124
+ return Object.keys(out).length ? out : null;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
@@ -0,0 +1,26 @@
1
+ /** Subset of aguacero-frontend NEXRAD_LEVEL3_MENU for API product resolution. */
2
+
3
+ export const NEXRAD_LEVEL3_ELEV = '0.50';
4
+ export const NEXRAD_LEVEL3_MOTION_PRODUCT = 'N0S';
5
+
6
+ const MENU = [
7
+ { radarKey: 'VEL', product: 'N0G', fldKey: 'nexrad_vel' },
8
+ { radarKey: 'KDP', product: 'N0K', fldKey: 'nexrad_l3_n0k' },
9
+ { radarKey: 'N0H', product: 'N0H', fldKey: 'nexrad_l3_n0h' },
10
+ { radarKey: 'HHC', product: 'HHC', fldKey: 'nexrad_l3_hhc' },
11
+ { radarKey: 'EET', product: 'EET', fldKey: 'nexrad_l3_eet' },
12
+ { radarKey: 'DVL', product: 'DVL', fldKey: 'nexrad_l3_dvl' },
13
+ { radarKey: 'DAA', product: 'DAA', fldKey: 'nexrad_l3_daa' },
14
+ { radarKey: 'DU3', product: 'DU3', fldKey: 'nexrad_l3_du3' },
15
+ { radarKey: 'DTA', product: 'DTA', fldKey: 'nexrad_l3_dta' },
16
+ ];
17
+
18
+ const byRadarKey = new Map();
19
+ for (const e of MENU) {
20
+ byRadarKey.set(e.radarKey, e);
21
+ }
22
+ byRadarKey.set('NVL', byRadarKey.get('DVL'));
23
+
24
+ export function getNexradLevel3EntryByRadarKey(radarKey) {
25
+ return byRadarKey.get(radarKey);
26
+ }
@@ -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
+ }