@aguacerowx/mapsgl 0.0.56 → 0.0.58

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,116 +1,116 @@
1
- import type { DecodedRadarFrame } from './nexradArchiveCache.js';
2
-
3
- /**
4
- * Storm motion from N0S (ICD 56) product description dep8/dep9, same as AtticRadar
5
- * (loaders_nexrad.js process_storm_relative_velocity).
6
- * dep8: storm speed in tenths of knots; dep9: direction in tenths of degrees.
7
- */
8
- export function parseLevel3StormMotionFromBuffer(buffer: ArrayBuffer): { speedMs: number; directionDeg: number } | null {
9
- const bytes = new Uint8Array(buffer);
10
- const marker = [83, 68, 85, 83];
11
- let start = 0;
12
- for (let i = 0; i <= bytes.length - 4; i++) {
13
- if (bytes[i] === marker[0] && bytes[i + 1] === marker[1] && bytes[i + 2] === marker[2] && bytes[i + 3] === marker[3]) {
14
- start = i;
15
- break;
16
- }
17
- }
18
- const dv = new DataView(buffer, start, bytes.length - start);
19
- const textLen = 30;
20
- const msgHdrLen = 18;
21
- const pdbStart = textLen + msgHdrLen;
22
- if (pdbStart + 86 > dv.byteLength) return null;
23
- if (dv.getInt16(pdbStart, false) !== -1) return null;
24
- const dep8Offset = pdbStart + 82;
25
- const dep8 = dv.getInt16(dep8Offset, false);
26
- const dep9 = dv.getInt16(dep8Offset + 2, false);
27
- const stormSpeedKt = dep8 / 10;
28
- const speedMs = stormSpeedKt * 0.514444;
29
- const directionDeg = dep9 / 10;
30
- if (!Number.isFinite(speedMs) || !Number.isFinite(directionDeg)) return null;
31
- return { speedMs, directionDeg };
32
- }
33
-
34
- /** AtticRadar calculateStormComponent — radial component of storm motion (m/s). */
35
- export function level3StormRadialComponentMs(stormSpeedMs: number, stormDirectionDeg: number, azimuthDeg: number): number {
36
- const stormDirRad = (stormDirectionDeg * Math.PI) / 180;
37
- const azRad = (azimuthDeg * Math.PI) / 180;
38
- return stormSpeedMs * Math.cos(stormDirRad - azRad);
39
- }
40
-
41
- function decodeGateRawMs(hi: number, lo: number, valueScale: number, valueOffset: number): number | null {
42
- const raw = lo + hi * 256;
43
- let signed = raw;
44
- if (raw >= 32768) signed = raw - 65536;
45
- if (signed <= -32768) return null;
46
- return signed * valueScale + valueOffset;
47
- }
48
-
49
- function encodeGateRawMs(physicalMs: number, valueScale: number, valueOffset: number): [number, number] {
50
- const raw = Math.round((physicalMs - valueOffset) / valueScale);
51
- const signed = Math.max(-32768, Math.min(32767, raw));
52
- const u = signed < 0 ? signed + 65536 : signed;
53
- return [(u >> 8) & 0xff, u & 0xff];
54
- }
55
-
56
- /**
57
- * Apply storm-relative correction to super-res base velocity (N0G): v_srv = v + storm_radial_component.
58
- * Matches AtticRadar process_storm_relative_velocity / PR #12.
59
- */
60
- export function applyLevel3StormRelativeToFrame(
61
- frame: DecodedRadarFrame,
62
- stormSpeedMs: number,
63
- stormDirectionDeg: number,
64
- ): DecodedRadarFrame {
65
- const { nRays, nGates, gateData, rayBoundariesDeg, valueScale, valueOffset } = frame;
66
- const out = new Uint8Array(gateData.length);
67
- for (let r = 0; r < nRays; r++) {
68
- const azLow = rayBoundariesDeg[r];
69
- const azHigh = rayBoundariesDeg[r + 1];
70
- const azimuthDeg = (Number(azLow) + Number(azHigh)) * 0.5;
71
- const stormComp = level3StormRadialComponentMs(stormSpeedMs, stormDirectionDeg, azimuthDeg);
72
- for (let g = 0; g < nGates; g++) {
73
- const o = (r * nGates + g) * 2;
74
- const hi = gateData[o];
75
- const lo = gateData[o + 1];
76
- const v = decodeGateRawMs(hi, lo, valueScale, valueOffset);
77
- if (v == null) {
78
- out[o] = hi;
79
- out[o + 1] = lo;
80
- continue;
81
- }
82
- const corrected = v + stormComp;
83
- const [nh, nl] = encodeGateRawMs(corrected, valueScale, valueOffset);
84
- out[o] = nh;
85
- out[o + 1] = nl;
86
- }
87
- }
88
- return { ...frame, gateData: out };
89
- }
90
-
91
- export function pickNearestLevel3ObjectKey(
92
- unixTime: number,
93
- timeToKeyMap: Record<string, string>,
94
- maxDeltaSec = 600,
95
- ): string | null {
96
- const direct = timeToKeyMap[String(unixTime)];
97
- if (direct) return direct;
98
- const entries = Object.entries(timeToKeyMap);
99
- if (entries.length === 0) return null;
100
- let bestKey: string | null = null;
101
- let bestDelta = Infinity;
102
- for (const [tStr, key] of entries) {
103
- const t = Number(tStr);
104
- if (!Number.isFinite(t)) continue;
105
- const d = Math.abs(t - unixTime);
106
- if (d < bestDelta) {
107
- bestDelta = d;
108
- bestKey = key;
109
- }
110
- }
111
- if (bestKey == null) return null;
112
- if (bestDelta > maxDeltaSec && Number.isFinite(maxDeltaSec)) {
113
- return null;
114
- }
115
- return bestKey;
116
- }
1
+ import type { DecodedRadarFrame } from './nexradArchiveCache.js';
2
+
3
+ /**
4
+ * Storm motion from N0S (ICD 56) product description dep8/dep9, same as AtticRadar
5
+ * (loaders_nexrad.js process_storm_relative_velocity).
6
+ * dep8: storm speed in tenths of knots; dep9: direction in tenths of degrees.
7
+ */
8
+ export function parseLevel3StormMotionFromBuffer(buffer: ArrayBuffer): { speedMs: number; directionDeg: number } | null {
9
+ const bytes = new Uint8Array(buffer);
10
+ const marker = [83, 68, 85, 83];
11
+ let start = 0;
12
+ for (let i = 0; i <= bytes.length - 4; i++) {
13
+ if (bytes[i] === marker[0] && bytes[i + 1] === marker[1] && bytes[i + 2] === marker[2] && bytes[i + 3] === marker[3]) {
14
+ start = i;
15
+ break;
16
+ }
17
+ }
18
+ const dv = new DataView(buffer, start, bytes.length - start);
19
+ const textLen = 30;
20
+ const msgHdrLen = 18;
21
+ const pdbStart = textLen + msgHdrLen;
22
+ if (pdbStart + 86 > dv.byteLength) return null;
23
+ if (dv.getInt16(pdbStart, false) !== -1) return null;
24
+ const dep8Offset = pdbStart + 82;
25
+ const dep8 = dv.getInt16(dep8Offset, false);
26
+ const dep9 = dv.getInt16(dep8Offset + 2, false);
27
+ const stormSpeedKt = dep8 / 10;
28
+ const speedMs = stormSpeedKt * 0.514444;
29
+ const directionDeg = dep9 / 10;
30
+ if (!Number.isFinite(speedMs) || !Number.isFinite(directionDeg)) return null;
31
+ return { speedMs, directionDeg };
32
+ }
33
+
34
+ /** AtticRadar calculateStormComponent — radial component of storm motion (m/s). */
35
+ export function level3StormRadialComponentMs(stormSpeedMs: number, stormDirectionDeg: number, azimuthDeg: number): number {
36
+ const stormDirRad = (stormDirectionDeg * Math.PI) / 180;
37
+ const azRad = (azimuthDeg * Math.PI) / 180;
38
+ return stormSpeedMs * Math.cos(stormDirRad - azRad);
39
+ }
40
+
41
+ function decodeGateRawMs(hi: number, lo: number, valueScale: number, valueOffset: number): number | null {
42
+ const raw = lo + hi * 256;
43
+ let signed = raw;
44
+ if (raw >= 32768) signed = raw - 65536;
45
+ if (signed <= -32768) return null;
46
+ return signed * valueScale + valueOffset;
47
+ }
48
+
49
+ function encodeGateRawMs(physicalMs: number, valueScale: number, valueOffset: number): [number, number] {
50
+ const raw = Math.round((physicalMs - valueOffset) / valueScale);
51
+ const signed = Math.max(-32768, Math.min(32767, raw));
52
+ const u = signed < 0 ? signed + 65536 : signed;
53
+ return [(u >> 8) & 0xff, u & 0xff];
54
+ }
55
+
56
+ /**
57
+ * Apply storm-relative correction to super-res base velocity (N0G): v_srv = v + storm_radial_component.
58
+ * Matches AtticRadar process_storm_relative_velocity / PR #12.
59
+ */
60
+ export function applyLevel3StormRelativeToFrame(
61
+ frame: DecodedRadarFrame,
62
+ stormSpeedMs: number,
63
+ stormDirectionDeg: number,
64
+ ): DecodedRadarFrame {
65
+ const { nRays, nGates, gateData, rayBoundariesDeg, valueScale, valueOffset } = frame;
66
+ const out = new Uint8Array(gateData.length);
67
+ for (let r = 0; r < nRays; r++) {
68
+ const azLow = rayBoundariesDeg[r];
69
+ const azHigh = rayBoundariesDeg[r + 1];
70
+ const azimuthDeg = (Number(azLow) + Number(azHigh)) * 0.5;
71
+ const stormComp = level3StormRadialComponentMs(stormSpeedMs, stormDirectionDeg, azimuthDeg);
72
+ for (let g = 0; g < nGates; g++) {
73
+ const o = (r * nGates + g) * 2;
74
+ const hi = gateData[o];
75
+ const lo = gateData[o + 1];
76
+ const v = decodeGateRawMs(hi, lo, valueScale, valueOffset);
77
+ if (v == null) {
78
+ out[o] = hi;
79
+ out[o + 1] = lo;
80
+ continue;
81
+ }
82
+ const corrected = v + stormComp;
83
+ const [nh, nl] = encodeGateRawMs(corrected, valueScale, valueOffset);
84
+ out[o] = nh;
85
+ out[o + 1] = nl;
86
+ }
87
+ }
88
+ return { ...frame, gateData: out };
89
+ }
90
+
91
+ export function pickNearestLevel3ObjectKey(
92
+ unixTime: number,
93
+ timeToKeyMap: Record<string, string>,
94
+ maxDeltaSec = 600,
95
+ ): string | null {
96
+ const direct = timeToKeyMap[String(unixTime)];
97
+ if (direct) return direct;
98
+ const entries = Object.entries(timeToKeyMap);
99
+ if (entries.length === 0) return null;
100
+ let bestKey: string | null = null;
101
+ let bestDelta = Infinity;
102
+ for (const [tStr, key] of entries) {
103
+ const t = Number(tStr);
104
+ if (!Number.isFinite(t)) continue;
105
+ const d = Math.abs(t - unixTime);
106
+ if (d < bestDelta) {
107
+ bestDelta = d;
108
+ bestKey = key;
109
+ }
110
+ }
111
+ if (bestKey == null) return null;
112
+ if (bestDelta > maxDeltaSec && Number.isFinite(maxDeltaSec)) {
113
+ return null;
114
+ }
115
+ return bestKey;
116
+ }
@@ -1,119 +1,119 @@
1
- import type { NexradSite } from './PreprocessedSweepParser.js';
2
- import nexradSitesDefault from './nexradSitesDefault.json';
3
- import { nexradArchiveDiag, redactApiKeyFromUrl } from './nexradArchiveDiag.js';
4
-
5
- type NexradSitesPayload = {
6
- sites?: NexradSite[];
7
- features?: unknown[];
8
- } | NexradSite[];
9
-
10
- /** Default: same as aguacero-frontend — app serves `public/data/nexrad.json` at `/data/nexrad.json`. */
11
- let sitesUrl = '/data/nexrad.json';
12
-
13
- /** Override default nexrad.json URL if your static path differs. */
14
- export function setNexradSitesJsonUrl(url: string) {
15
- sitesUrl = url || sitesUrl;
16
- }
17
-
18
- /**
19
- * When sitesUrl is HTTPS (e.g. CloudFront), use the same auth as AguaceroCore grid fetches:
20
- * `?apiKey=`, `x-api-key`, and `x-app-identifier` on React Native when bundle id is set.
21
- */
22
- let SITES_FETCH_API_KEY = '';
23
- let SITES_FETCH_BUNDLE_ID = '';
24
- export function setNexradSitesFetchAuth(apiKey: string, bundleId?: string) {
25
- SITES_FETCH_API_KEY = apiKey || '';
26
- if (bundleId !== undefined) {
27
- SITES_FETCH_BUNDLE_ID = bundleId || '';
28
- }
29
- }
30
-
31
- let nexradSitesPayloadPromise: Promise<NexradSitesPayload> | null = null;
32
-
33
- function sitesFetchUrl(): string {
34
- if (!sitesUrl.startsWith('http') || !SITES_FETCH_API_KEY) {
35
- return sitesUrl;
36
- }
37
- const sep = sitesUrl.includes('?') ? '&' : '?';
38
- return `${sitesUrl}${sep}apiKey=${SITES_FETCH_API_KEY}`;
39
- }
40
-
41
- function sitesFetchHeaders(): Record<string, string> | undefined {
42
- if (!SITES_FETCH_API_KEY) {
43
- return undefined;
44
- }
45
- const headers: Record<string, string> = {
46
- 'x-api-key': SITES_FETCH_API_KEY,
47
- };
48
- const nav = (globalThis as { navigator?: { product?: string } }).navigator;
49
- if (nav?.product === 'ReactNative' && SITES_FETCH_BUNDLE_ID) {
50
- headers['x-app-identifier'] = SITES_FETCH_BUNDLE_ID;
51
- }
52
- return headers;
53
- }
54
-
55
- function embeddedSitesPayload(): NexradSitesPayload {
56
- return nexradSitesDefault as NexradSitesPayload;
57
- }
58
-
59
- export function loadNexradSitesPayload(): Promise<NexradSitesPayload> {
60
- if (nexradSitesPayloadPromise) {
61
- return nexradSitesPayloadPromise;
62
- }
63
- nexradSitesPayloadPromise = (async (): Promise<NexradSitesPayload> => {
64
- const url = sitesFetchUrl();
65
- const headers = sitesFetchHeaders();
66
- const headerKeys = headers ? Object.keys(headers).join(',') : '';
67
- nexradArchiveDiag('sites.fetch.start', {
68
- canonicalUrl: sitesUrl,
69
- fetchUrl: redactApiKeyFromUrl(url),
70
- hasApiKeyQuery: url.includes('apiKey='),
71
- headerKeys: headerKeys || '(none)',
72
- apiKeyLen: SITES_FETCH_API_KEY.length,
73
- bundleIdLen: SITES_FETCH_BUNDLE_ID.length,
74
- });
75
- try {
76
- const response = await fetch(url, headers ? { headers } : undefined);
77
- if (response.ok) {
78
- nexradArchiveDiag('sites.fetch.ok', {
79
- status: response.status,
80
- canonicalUrl: sitesUrl,
81
- });
82
- return (await response.json()) as NexradSitesPayload;
83
- }
84
- if (sitesUrl.startsWith('https://')) {
85
- console.warn(
86
- `[mapsgl] nexrad.json HTTP ${response.status} for ${sitesUrl} — using embedded site list`,
87
- );
88
- nexradArchiveDiag('sites.fetch.fallbackEmbedded', {
89
- httpStatus: response.status,
90
- reason: 'HTTPS non-OK — CloudFront may not host /data/nexrad.json; embedded list used',
91
- canonicalUrl: sitesUrl,
92
- });
93
- return embeddedSitesPayload();
94
- }
95
- throw new Error(`nexrad.json fetch failed: HTTP ${response.status}`);
96
- } catch (error) {
97
- if (sitesUrl.startsWith('https://')) {
98
- console.warn('[mapsgl] nexrad.json load failed — using embedded site list:', error);
99
- nexradArchiveDiag('sites.fetch.catchFallbackEmbedded', {
100
- message: error instanceof Error ? error.message : String(error),
101
- canonicalUrl: sitesUrl,
102
- });
103
- return embeddedSitesPayload();
104
- }
105
- console.error('[mapsgl] Could not load nexrad.json:', error);
106
- nexradSitesPayloadPromise = null;
107
- throw error;
108
- }
109
- })();
110
- return nexradSitesPayloadPromise;
111
- }
112
-
113
- export async function loadNexradSites(): Promise<NexradSite[]> {
114
- const payload = await loadNexradSitesPayload();
115
- if (Array.isArray(payload)) return payload as NexradSite[];
116
- const sites = (payload as { sites?: NexradSite[] }).sites;
117
- if (sites && Array.isArray(sites)) return sites;
118
- return [];
119
- }
1
+ import type { NexradSite } from './PreprocessedSweepParser.js';
2
+ import nexradSitesDefault from './nexradSitesDefault.json';
3
+ import { nexradArchiveDiag, redactApiKeyFromUrl } from './nexradArchiveDiag.js';
4
+
5
+ type NexradSitesPayload = {
6
+ sites?: NexradSite[];
7
+ features?: unknown[];
8
+ } | NexradSite[];
9
+
10
+ /** Default: same as aguacero-frontend — app serves `public/data/nexrad.json` at `/data/nexrad.json`. */
11
+ let sitesUrl = '/data/nexrad.json';
12
+
13
+ /** Override default nexrad.json URL if your static path differs. */
14
+ export function setNexradSitesJsonUrl(url: string) {
15
+ sitesUrl = url || sitesUrl;
16
+ }
17
+
18
+ /**
19
+ * When sitesUrl is HTTPS (e.g. CloudFront), use the same auth as AguaceroCore grid fetches:
20
+ * `?apiKey=`, `x-api-key`, and `x-app-identifier` on React Native when bundle id is set.
21
+ */
22
+ let SITES_FETCH_API_KEY = '';
23
+ let SITES_FETCH_BUNDLE_ID = '';
24
+ export function setNexradSitesFetchAuth(apiKey: string, bundleId?: string) {
25
+ SITES_FETCH_API_KEY = apiKey || '';
26
+ if (bundleId !== undefined) {
27
+ SITES_FETCH_BUNDLE_ID = bundleId || '';
28
+ }
29
+ }
30
+
31
+ let nexradSitesPayloadPromise: Promise<NexradSitesPayload> | null = null;
32
+
33
+ function sitesFetchUrl(): string {
34
+ if (!sitesUrl.startsWith('http') || !SITES_FETCH_API_KEY) {
35
+ return sitesUrl;
36
+ }
37
+ const sep = sitesUrl.includes('?') ? '&' : '?';
38
+ return `${sitesUrl}${sep}apiKey=${SITES_FETCH_API_KEY}`;
39
+ }
40
+
41
+ function sitesFetchHeaders(): Record<string, string> | undefined {
42
+ if (!SITES_FETCH_API_KEY) {
43
+ return undefined;
44
+ }
45
+ const headers: Record<string, string> = {
46
+ 'x-api-key': SITES_FETCH_API_KEY,
47
+ };
48
+ const nav = (globalThis as { navigator?: { product?: string } }).navigator;
49
+ if (nav?.product === 'ReactNative' && SITES_FETCH_BUNDLE_ID) {
50
+ headers['x-app-identifier'] = SITES_FETCH_BUNDLE_ID;
51
+ }
52
+ return headers;
53
+ }
54
+
55
+ function embeddedSitesPayload(): NexradSitesPayload {
56
+ return nexradSitesDefault as NexradSitesPayload;
57
+ }
58
+
59
+ export function loadNexradSitesPayload(): Promise<NexradSitesPayload> {
60
+ if (nexradSitesPayloadPromise) {
61
+ return nexradSitesPayloadPromise;
62
+ }
63
+ nexradSitesPayloadPromise = (async (): Promise<NexradSitesPayload> => {
64
+ const url = sitesFetchUrl();
65
+ const headers = sitesFetchHeaders();
66
+ const headerKeys = headers ? Object.keys(headers).join(',') : '';
67
+ nexradArchiveDiag('sites.fetch.start', {
68
+ canonicalUrl: sitesUrl,
69
+ fetchUrl: redactApiKeyFromUrl(url),
70
+ hasApiKeyQuery: url.includes('apiKey='),
71
+ headerKeys: headerKeys || '(none)',
72
+ apiKeyLen: SITES_FETCH_API_KEY.length,
73
+ bundleIdLen: SITES_FETCH_BUNDLE_ID.length,
74
+ });
75
+ try {
76
+ const response = await fetch(url, headers ? { headers } : undefined);
77
+ if (response.ok) {
78
+ nexradArchiveDiag('sites.fetch.ok', {
79
+ status: response.status,
80
+ canonicalUrl: sitesUrl,
81
+ });
82
+ return (await response.json()) as NexradSitesPayload;
83
+ }
84
+ if (sitesUrl.startsWith('https://')) {
85
+ console.warn(
86
+ `[mapsgl] nexrad.json HTTP ${response.status} for ${sitesUrl} — using embedded site list`,
87
+ );
88
+ nexradArchiveDiag('sites.fetch.fallbackEmbedded', {
89
+ httpStatus: response.status,
90
+ reason: 'HTTPS non-OK — CloudFront may not host /data/nexrad.json; embedded list used',
91
+ canonicalUrl: sitesUrl,
92
+ });
93
+ return embeddedSitesPayload();
94
+ }
95
+ throw new Error(`nexrad.json fetch failed: HTTP ${response.status}`);
96
+ } catch (error) {
97
+ if (sitesUrl.startsWith('https://')) {
98
+ console.warn('[mapsgl] nexrad.json load failed — using embedded site list:', error);
99
+ nexradArchiveDiag('sites.fetch.catchFallbackEmbedded', {
100
+ message: error instanceof Error ? error.message : String(error),
101
+ canonicalUrl: sitesUrl,
102
+ });
103
+ return embeddedSitesPayload();
104
+ }
105
+ console.error('[mapsgl] Could not load nexrad.json:', error);
106
+ nexradSitesPayloadPromise = null;
107
+ throw error;
108
+ }
109
+ })();
110
+ return nexradSitesPayloadPromise;
111
+ }
112
+
113
+ export async function loadNexradSites(): Promise<NexradSite[]> {
114
+ const payload = await loadNexradSitesPayload();
115
+ if (Array.isArray(payload)) return payload as NexradSite[];
116
+ const sites = (payload as { sites?: NexradSite[] }).sites;
117
+ if (sites && Array.isArray(sites)) return sites;
118
+ return [];
119
+ }
@@ -1,26 +1,26 @@
1
- /**
2
- * Verbose NEXRAD archive pipeline logs for React Native (Hermes / Logcat).
3
- * Filter: {@code [Aguacero][NEXRAD][archive]}
4
- *
5
- * Omits raw API keys; use lengths / booleans only.
6
- */
7
- export type NexradArchiveDiagDetail = Record<string, string | number | boolean | null | undefined>;
8
-
9
- export function redactApiKeyFromUrl(u: string): string {
10
- return u.replace(/([?&])apiKey=[^&]*/gi, '$1apiKey=(redacted)');
11
- }
12
-
13
- function isReactNative(): boolean {
14
- const nav = (globalThis as { navigator?: { product?: string } }).navigator;
15
- return nav?.product === 'ReactNative';
16
- }
17
-
18
- /** Logs only on React Native to avoid noisy web consoles (mapsgl path is already observable in DevTools). */
19
- export function nexradArchiveDiag(phase: string, detail?: NexradArchiveDiagDetail): void {
20
- if (!isReactNative()) return;
21
- if (detail !== undefined) {
22
- console.warn(`[Aguacero][NEXRAD][archive] ${phase}`, detail);
23
- } else {
24
- console.warn(`[Aguacero][NEXRAD][archive] ${phase}`);
25
- }
26
- }
1
+ /**
2
+ * Verbose NEXRAD archive pipeline logs for React Native (Hermes / Logcat).
3
+ * Filter: {@code [Aguacero][NEXRAD][archive]}
4
+ *
5
+ * Omits raw API keys; use lengths / booleans only.
6
+ */
7
+ export type NexradArchiveDiagDetail = Record<string, string | number | boolean | null | undefined>;
8
+
9
+ export function redactApiKeyFromUrl(u: string): string {
10
+ return u.replace(/([?&])apiKey=[^&]*/gi, '$1apiKey=(redacted)');
11
+ }
12
+
13
+ function isReactNative(): boolean {
14
+ const nav = (globalThis as { navigator?: { product?: string } }).navigator;
15
+ return nav?.product === 'ReactNative';
16
+ }
17
+
18
+ /** Logs only on React Native to avoid noisy web consoles (mapsgl path is already observable in DevTools). */
19
+ export function nexradArchiveDiag(phase: string, detail?: NexradArchiveDiagDetail): void {
20
+ if (!isReactNative()) return;
21
+ if (detail !== undefined) {
22
+ console.warn(`[Aguacero][NEXRAD][archive] ${phase}`, detail);
23
+ } else {
24
+ console.warn(`[Aguacero][NEXRAD][archive] ${phase}`);
25
+ }
26
+ }