@aguacerowx/react-native 0.0.51 → 0.0.53

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.
Files changed (53) hide show
  1. package/android/src/main/cpp/satellite_ktx_jni.cpp +6 -1
  2. package/android/src/main/java/com/aguacerowx/reactnative/SatelliteLayer.java +121 -1
  3. package/android/src/main/java/com/aguacerowx/reactnative/SatelliteLayerView.java +556 -384
  4. package/android/src/main/java/com/aguacerowx/reactnative/WeatherFrameProcessorModule.java +315 -311
  5. package/ios/SatelliteLayerView.swift +517 -510
  6. package/ios/WeatherFrameProcessorModule.swift +222 -219
  7. package/lib/commonjs/WeatherLayerManager.js +82 -46
  8. package/lib/commonjs/WeatherLayerManager.js.map +1 -1
  9. package/lib/commonjs/aguaceroRnDebug.js +9 -1
  10. package/lib/commonjs/aguaceroRnDebug.js.map +1 -1
  11. package/lib/commonjs/gridCdnAuth.js +64 -0
  12. package/lib/commonjs/gridCdnAuth.js.map +1 -0
  13. package/lib/commonjs/index.js +50 -0
  14. package/lib/commonjs/index.js.map +1 -1
  15. package/lib/commonjs/nexrad/nexradAndroidController.js +25 -25
  16. package/lib/commonjs/nexrad/nexradAndroidController.js.map +1 -1
  17. package/lib/commonjs/nexrad/nexradDiag.js +24 -24
  18. package/lib/commonjs/satellite/satelliteAndroidController.js +32 -24
  19. package/lib/commonjs/satellite/satelliteAndroidController.js.map +1 -1
  20. package/lib/commonjs/satelliteRnDebug.js +261 -0
  21. package/lib/commonjs/satelliteRnDebug.js.map +1 -0
  22. package/lib/module/WeatherLayerManager.js +82 -46
  23. package/lib/module/WeatherLayerManager.js.map +1 -1
  24. package/lib/module/aguaceroRnDebug.js +9 -1
  25. package/lib/module/aguaceroRnDebug.js.map +1 -1
  26. package/lib/module/gridCdnAuth.js +56 -0
  27. package/lib/module/gridCdnAuth.js.map +1 -0
  28. package/lib/module/index.js +2 -0
  29. package/lib/module/index.js.map +1 -1
  30. package/lib/module/nexrad/nexradAndroidController.js +25 -25
  31. package/lib/module/nexrad/nexradAndroidController.js.map +1 -1
  32. package/lib/module/nexrad/nexradDiag.js +24 -24
  33. package/lib/module/satellite/satelliteAndroidController.js +32 -24
  34. package/lib/module/satellite/satelliteAndroidController.js.map +1 -1
  35. package/lib/module/satelliteRnDebug.js +248 -0
  36. package/lib/module/satelliteRnDebug.js.map +1 -0
  37. package/lib/typescript/WeatherLayerManager.d.ts.map +1 -1
  38. package/lib/typescript/aguaceroRnDebug.d.ts.map +1 -1
  39. package/lib/typescript/gridCdnAuth.d.ts +24 -0
  40. package/lib/typescript/gridCdnAuth.d.ts.map +1 -0
  41. package/lib/typescript/index.d.ts +2 -0
  42. package/lib/typescript/satellite/satelliteAndroidController.d.ts.map +1 -1
  43. package/lib/typescript/satelliteRnDebug.d.ts +81 -0
  44. package/lib/typescript/satelliteRnDebug.d.ts.map +1 -0
  45. package/package.json +2 -2
  46. package/src/WeatherLayerManager.js +2044 -2004
  47. package/src/aguaceroRnDebug.js +9 -1
  48. package/src/gridCdnAuth.js +56 -0
  49. package/src/index.js +27 -15
  50. package/src/nexrad/nexradAndroidController.js +1078 -1078
  51. package/src/nexrad/nexradDiag.js +150 -150
  52. package/src/satellite/satelliteAndroidController.js +257 -245
  53. package/src/satelliteRnDebug.js +269 -0
@@ -1,2005 +1,2045 @@
1
- // packages/react-native/src/WeatherLayerManager.js
2
-
3
- import {
4
- AguaceroCore,
5
- DICTIONARIES,
6
- formatTimelineDurationValue,
7
- getUnitConversionFunction,
8
- parseTimelineDurationHours,
9
- } from '@aguacerowx/javascript-sdk';
10
- import { fromByteArray } from 'base64-js';
11
- import React, { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
12
- import { NativeModules, Platform, UIManager } from 'react-native';
13
- import { AguaceroContext } from './AguaceroContext';
14
- import { GridRenderLayer } from './GridRenderLayer';
15
- import NexradRadarLayer from './NexradRadarLayer';
16
- import SatelliteLayer from './SatelliteLayer';
17
- import { NexradSitesMapLayer } from './NexradSitesMapLayer';
18
- import { NexradAndroidController } from './nexrad/nexradAndroidController';
19
- import { SatelliteAndroidController } from './satellite/satelliteAndroidController';
20
- import NwsAlertsOverlay from './nws/NwsAlertsOverlay';
21
- import { mapRegistry } from './MapRegistry';
22
- import { satBridgeWarn } from './satelliteBridgeDiag';
23
- import {
24
- augmentProcessFrameOptionsForDebug,
25
- aguaceroDebugWarn,
26
- configureAguaceroRnDebug,
27
- getAguaceroAuthDiagnosticSnapshot,
28
- isAguaceroRnDebugEnabled,
29
- } from './aguaceroRnDebug';
30
- import { installAguaceroCoreDebugHooks, logProcessFrameAuthMismatch } from './aguaceroCoreDebugHooks';
31
-
32
- const NEXRAD_NATIVE = Platform.OS === 'android' || Platform.OS === 'ios';
33
- const SATELLITE_NATIVE = Platform.OS === 'android' || Platform.OS === 'ios';
34
-
35
- satBridgeWarn('SDK fingerprint', {
36
- platform: Platform.OS,
37
- SATELLITE_NATIVE,
38
- note: 'If you never see sat-bridge logs, the app bundle is not loading this WeatherLayerManager build.',
39
- });
40
-
41
- /**
42
- * Same filtering as {@link AguaceroCore#_getFilteredMrmsTimestampsForVariable} for older cores
43
- * where that helper (or {@code setMRMSDurationValue}) is missing from the prototype chain.
44
- */
45
- function getFilteredMrmsTimestampsCompat(core, variable) {
46
- const raw = core.mrmsStatus?.[variable];
47
- if (!raw || !raw.length) return [];
48
- const hours = parseTimelineDurationHours(core.state.mrmsDurationValue);
49
- let list = [...raw]
50
- .map((t) => Number(t))
51
- .filter((t) => !Number.isNaN(t))
52
- .sort((a, b) => a - b);
53
- if (hours > 0 && list.length > 0) {
54
- const latest = list[list.length - 1];
55
- const cutoff = latest - hours * 3600;
56
- list = list.filter((t) => t >= cutoff);
57
- }
58
- return list;
59
- }
60
-
61
- /**
62
- * Older npm installs may resolve an {@link AguaceroCore} without {@code setMRMSDurationValue} /
63
- * {@code setNexradDurationValue}; mirror those methods here using the same logic as the SDK.
64
- */
65
- async function applyMrmsDurationValue(core, value) {
66
- if (typeof core.setMRMSDurationValue === 'function') {
67
- await core.setMRMSDurationValue(value);
68
- return;
69
- }
70
- const v = formatTimelineDurationValue(value);
71
- await core.setState({ mrmsDurationValue: v });
72
- if (!core.state.isMRMS || !core.state.variable) return;
73
- const filtered =
74
- typeof core._getFilteredMrmsTimestampsForVariable === 'function'
75
- ? core._getFilteredMrmsTimestampsForVariable(core.state.variable)
76
- : getFilteredMrmsTimestampsCompat(core, core.state.variable);
77
- if (!filtered || filtered.length === 0) return;
78
- const curN = core.state.mrmsTimestamp == null ? null : Number(core.state.mrmsTimestamp);
79
- if (curN == null || !filtered.includes(curN)) {
80
- await core.setState({ mrmsTimestamp: filtered[filtered.length - 1] });
81
- }
82
- }
83
-
84
- async function applyNexradDurationValue(core, value) {
85
- if (typeof core.setNexradDurationValue === 'function') {
86
- await core.setNexradDurationValue(value);
87
- return;
88
- }
89
- const v = formatTimelineDurationValue(value);
90
- await core.setState({ nexradDurationValue: v });
91
- if (!core.state.isNexrad || !core.state.nexradSite) return;
92
- if (typeof core.refreshNexradTimes === 'function') {
93
- await core.refreshNexradTimes();
94
- }
95
- const nk = typeof core._nexradTimesCacheKey === 'function' ? core._nexradTimesCacheKey() : null;
96
- const raw = nk ? core.nexradTimesByStation?.[nk]?.unixTimes || [] : [];
97
- const filtered =
98
- typeof core._getFilteredNexradTimestampsForVariable === 'function'
99
- ? core._getFilteredNexradTimestampsForVariable(raw)
100
- : [...raw].map((t) => Number(t)).filter((t) => !Number.isNaN(t)).sort((a, b) => a - b);
101
- if (!filtered || filtered.length === 0) return;
102
- const curN = core.state.nexradTimestamp == null ? null : Number(core.state.nexradTimestamp);
103
- if (curN == null || !filtered.includes(curN)) {
104
- await core.setState({ nexradTimestamp: filtered[filtered.length - 1] });
105
- }
106
- }
107
-
108
- function findLatestModelRun(modelsData, modelName) {
109
- const model = modelsData?.[modelName];
110
- if (!model) return null;
111
- const availableDates = Object.keys(model).sort((a, b) => b.localeCompare(a));
112
- for (const date of availableDates) {
113
- const runs = model[date];
114
- if (!runs) continue;
115
- const availableRuns = Object.keys(runs).sort((a, b) => b.localeCompare(a));
116
- if (availableRuns.length > 0) return { date: date, run: availableRuns[0] };
117
- }
118
- return null;
119
- }
120
- const { WeatherFrameProcessorModule, InspectorModule } = NativeModules;
121
-
122
- /**
123
- * `state:change` payloads can briefly have {@code isNexrad} before {@code nexradSite} / {@code nexradTimestamp}
124
- * are populated; {@code core.state} is updated first. Merge so readouts match the active radar.
125
- *
126
- * @param {object | null} emitted
127
- * @param {object | null} coreState
128
- */
129
- function mergeNexradEmittedWithCore(emitted, coreState) {
130
- if (!emitted || !coreState) return null;
131
- return {
132
- ...emitted,
133
- nexradSite: coreState.nexradSite ?? emitted.nexradSite,
134
- nexradTimestamp: coreState.nexradTimestamp ?? emitted.nexradTimestamp,
135
- nexradProduct: coreState.nexradProduct ?? emitted.nexradProduct,
136
- nexradTilt: coreState.nexradTilt ?? emitted.nexradTilt,
137
- nexradDataSource: coreState.nexradDataSource ?? emitted.nexradDataSource,
138
- nexradStormRelative: coreState.nexradStormRelative ?? emitted.nexradStormRelative,
139
- };
140
- }
141
-
142
- /**
143
- * Compact timeline identity for deduping {@code state:change}: extending the NEXRAD/satellite window
144
- * often keeps the same selected unix — without this, {@link WeatherLayerManager} short-circuits and
145
- * never calls native {@code sync} / preload with the expanded frame list.
146
- */
147
- function nexradObsTimelineSig(state) {
148
- const arr = [...(state?.availableNexradTimestamps || [])]
149
- .map(Number)
150
- .filter((t) => Number.isFinite(t))
151
- .sort((a, b) => a - b);
152
- if (!arr.length) return '0';
153
- return `${arr.length}:${arr[0]}:${arr[arr.length - 1]}`;
154
- }
155
-
156
- function satelliteObsTimelineSig(state) {
157
- const keys = Object.keys(state?.satelliteTimeToFileMap || {})
158
- .map(Number)
159
- .filter((t) => Number.isFinite(t))
160
- .sort((a, b) => a - b);
161
- if (!keys.length) return '0';
162
- return `${keys.length}:${keys[0]}:${keys[keys.length - 1]}`;
163
- }
164
-
165
- /** True when {@link WeatherLayerManager} `debug` / {@link configureAguaceroRnDebug}, or legacy `__AGUACERO_WX_GRID_DEBUG__`. */
166
- function wxGridDebugEnabled() {
167
- if (isAguaceroRnDebugEnabled()) return true;
168
- try {
169
- if (typeof __DEV__ !== 'undefined' && __DEV__) return true;
170
- return Boolean(typeof globalThis !== 'undefined' && globalThis.__AGUACERO_WX_GRID_DEBUG__);
171
- } catch {
172
- return false;
173
- }
174
- }
175
-
176
- function wxGridVerbose(tag, detail) {
177
- if (!wxGridDebugEnabled()) return;
178
- if (detail !== undefined) {
179
- console.log(`[AguaceroWX][grid][${tag}]`, detail);
180
- } else {
181
- console.log(`[AguaceroWX][grid][${tag}]`);
182
- }
183
- }
184
-
185
- /** Always logs (Metro / device logs) — use for failures and unusual early exits. */
186
- function wxGridWarn(tag, detail) {
187
- if (detail !== undefined) {
188
- console.warn(`[AguaceroWX][grid][${tag}]`, detail);
189
- } else {
190
- console.warn(`[AguaceroWX][grid][${tag}]`);
191
- }
192
- }
193
-
194
- /**
195
- * Native {@code processFrame} must mirror browser/AguaceroCore grid auth: encoded {@code apiKey}
196
- * in the query string and optional {@code Origin} / {@code Referer} (many CloudFront setups require them).
197
- *
198
- * @param {string} baseGridUrl
199
- * @param {string} resourcePath
200
- * @param {string} apiKey
201
- * @param {string | null | undefined} bundleId
202
- * @param {string | undefined} gridRequestSiteOrigin - e.g. production web origin your edge allowlists (no trailing slash)
203
- */
204
- function buildGridFrameProcessOptions(baseGridUrl, resourcePath, apiKey, bundleId, gridRequestSiteOrigin) {
205
- const url = `${baseGridUrl}${resourcePath}?apiKey=${encodeURIComponent(apiKey)}`;
206
- const options = { url, apiKey, bundleId };
207
- if (typeof gridRequestSiteOrigin === 'string') {
208
- let origin = gridRequestSiteOrigin.trim();
209
- while (origin.endsWith('/')) {
210
- origin = origin.slice(0, -1);
211
- }
212
- if (origin.length > 0) {
213
- options.gridRequestSiteOrigin = origin;
214
- }
215
- }
216
- return options;
217
- }
218
-
219
- /**
220
- * A helper function to generate the raw RGBA byte buffer for the colormap texture.
221
- */
222
- const _generateColormapBytes = (colormap) => {
223
- const width = 256;
224
- const data = new Uint8Array(width * 4);
225
- const stops = colormap.reduce((acc, _, i) => (i % 2 === 0 ? [...acc, { value: colormap[i], color: colormap[i + 1] }] : acc), []);
226
-
227
- if (stops.length === 0) return data;
228
-
229
- const minVal = stops[0].value;
230
- const maxVal = stops[stops.length - 1].value;
231
-
232
- const hexToRgb = (hex) => {
233
- const r = parseInt(hex.slice(1, 3), 16);
234
- const g = parseInt(hex.slice(3, 5), 16);
235
- const b = parseInt(hex.slice(5, 7), 16);
236
- return [r, g, b];
237
- };
238
-
239
- for (let i = 0; i < width; i++) {
240
- const val = minVal + (i / (width - 1)) * (maxVal - minVal);
241
- let lower = stops[0];
242
- let upper = stops[stops.length - 1];
243
- for (let j = 0; j < stops.length - 1; j++) {
244
- if (val >= stops[j].value && val <= stops[j + 1].value) {
245
- lower = stops[j];
246
- upper = stops[j + 1];
247
- break;
248
- }
249
- }
250
- const t = (val - lower.value) / (upper.value - lower.value || 1);
251
- const lowerRgb = hexToRgb(lower.color);
252
- const upperRgb = hexToRgb(upper.color);
253
- const rgb = lowerRgb.map((c, idx) => c * (1 - t) + upperRgb[idx] * t);
254
-
255
- const offset = i * 4;
256
- data[offset + 0] = Math.round(rgb[0]);
257
- data[offset + 1] = Math.round(rgb[1]);
258
- data[offset + 2] = Math.round(rgb[2]);
259
- data[offset + 3] = 255;
260
- }
261
- return data;
262
- };
263
-
264
- AguaceroCore.prototype.setMapCenter = function (center) {
265
- this.emit('map:move', center);
266
- };
267
-
268
- export const WeatherLayerManager = forwardRef((props, ref) => {
269
- const {
270
- inspectorEnabled,
271
- onInspect,
272
- apiKey,
273
- customColormaps,
274
- initialMode,
275
- initialVariable,
276
- autoRefresh,
277
- autoRefreshInterval,
278
- initialModel,
279
- belowID: belowIDFromProps,
280
- interpolateNexradColormap = true,
281
- nexradGateSmoothing = false,
282
- watchesWarnings: watchesWarningsProp,
283
- onNwsAlertClick,
284
- /** Same semantics as mapsgl / AguaceroCore: Origin + Referer for grid CDN (required by many CloudFront rules). */
285
- gridRequestSiteOrigin,
286
- /** When true, logs auth/HTTP diagnostics under `[AguaceroRN][debug]` (Metro / Logcat / Xcode). */
287
- debug = false,
288
- ...restProps
289
- } = props;
290
-
291
- useEffect(() => {
292
- configureAguaceroRnDebug({ enabled: Boolean(debug) });
293
- }, [debug]);
294
- const context = useContext(AguaceroContext);
295
-
296
- // Create the core here instead of getting it from context
297
- const core = useMemo(
298
- () =>
299
- new AguaceroCore({
300
- apiKey: apiKey,
301
- customColormaps: customColormaps,
302
- gridRequestSiteOrigin: gridRequestSiteOrigin,
303
- layerOptions: {
304
- mode: initialMode,
305
- variable: initialVariable,
306
- model: initialModel,
307
- },
308
- autoRefresh: false, // <-- add this
309
- }),
310
- [apiKey, gridRequestSiteOrigin],
311
- );
312
-
313
- useEffect(() => {
314
- if (!core) return;
315
- installAguaceroCoreDebugHooks(core, {
316
- gridRequestSiteOriginProp: gridRequestSiteOrigin ?? null,
317
- debugProp: Boolean(debug),
318
- });
319
- if (isAguaceroRnDebugEnabled() && !apiKey) {
320
- aguaceroDebugWarn('WeatherLayerManager.missingApiKey', {
321
- hint: 'apiKey prop is empty — all CDN requests will fail or return 403',
322
- });
323
- }
324
- if (isAguaceroRnDebugEnabled() && apiKey && !gridRequestSiteOrigin) {
325
- aguaceroDebugWarn('WeatherLayerManager.missingGridOrigin', {
326
- hint: 'gridRequestSiteOrigin is not set — CloudFront often returns 403 without Origin/Referer on React Native',
327
- snapshot: getAguaceroAuthDiagnosticSnapshot(core),
328
- });
329
- }
330
- }, [core, debug, gridRequestSiteOrigin, apiKey]);
331
-
332
- const [watchesWarningsOptions, setWatchesWarningsOptions] = useState(() => ({
333
- alertInteractionEnabled: true,
334
- ...(watchesWarningsProp ?? {}),
335
- }));
336
-
337
- useEffect(() => {
338
- setWatchesWarningsOptions((prev) => ({
339
- ...prev,
340
- ...(watchesWarningsProp ?? {}),
341
- }));
342
- }, [watchesWarningsProp]);
343
-
344
- useEffect(() => {
345
- setNexradSitesMapVisible(Boolean(core.state.isNexrad && core.state.nexradShowSitesPicker !== false));
346
- }, [core]);
347
-
348
- const gridLayerRef = useRef(null);
349
- const nexradLayerRef = useRef(null);
350
- const nexradControllerRef = useRef(null);
351
- /** Latest {@code state:change} payload (includes {@code colormap}, NEXRAD maps) — required for readouts; {@code core.state} omits those. */
352
- const lastEmittedStateForInspectRef = useRef(null);
353
- const satelliteLayerRef = useRef(null);
354
- const satelliteControllerRef = useRef(null);
355
- /** @type {React.MutableRefObject<{ cancel?: () => void } | null>} */
356
- const nexradPreloadInteractionRef = useRef(null);
357
- const currentGridDataRef = useRef(null);
358
- const autoRefreshIntervalId = useRef(null);
359
-
360
- const ensureNexradController = useCallback(() => {
361
- if (!NEXRAD_NATIVE) return null;
362
- if (!nexradControllerRef.current) {
363
- nexradControllerRef.current = new NexradAndroidController(core, nexradLayerRef, {
364
- interpolateNexradColormap,
365
- nexradGateSmoothing,
366
- });
367
- }
368
- return nexradControllerRef.current;
369
- }, [core, interpolateNexradColormap, nexradGateSmoothing]);
370
-
371
- /** Synchronous NEXRAD readout (mapsgl-style); no await — avoids jank on map move. */
372
- const getNexradInspectPayloadAt = useCallback(
373
- (lng, lat) => {
374
- if (!NEXRAD_NATIVE || !core?.state?.isNexrad) return null;
375
- const ctl = ensureNexradController();
376
- if (!ctl) {
377
- return null;
378
- }
379
- const emitted = lastEmittedStateForInspectRef.current;
380
- if (!emitted) {
381
- return null;
382
- }
383
- const st = mergeNexradEmittedWithCore(emitted, core.state);
384
- if (!st?.nexradSite || st.nexradTimestamp == null) {
385
- return null;
386
- }
387
- return ctl.getInspectPayload(lng, lat, st) ?? null;
388
- },
389
- [core, ensureNexradController],
390
- );
391
-
392
- const ensureSatelliteController = useCallback(() => {
393
- if (!SATELLITE_NATIVE) return null;
394
- if (!satelliteControllerRef.current) {
395
- satelliteControllerRef.current = new SatelliteAndroidController(core, satelliteLayerRef);
396
- }
397
- return satelliteControllerRef.current;
398
- }, [core]);
399
-
400
- useEffect(() => {
401
- if (!SATELLITE_NATIVE) return;
402
- let gridCmdKeys = [];
403
- let satCmdKeys = [];
404
- try {
405
- gridCmdKeys = Object.keys(UIManager.getViewManagerConfig?.('GridRenderLayer')?.Commands ?? {});
406
- } catch {
407
- gridCmdKeys = ['error'];
408
- }
409
- try {
410
- satCmdKeys = Object.keys(UIManager.getViewManagerConfig?.('SatelliteLayer')?.Commands ?? {});
411
- } catch {
412
- satCmdKeys = ['error'];
413
- }
414
- satBridgeWarn('mount UIManager command registration', {
415
- GridRenderLayerCommands: gridCmdKeys,
416
- SatelliteLayerCommands: satCmdKeys,
417
- satelliteMissing: satCmdKeys.length === 0,
418
- });
419
- }, []);
420
-
421
- useEffect(() => {
422
- const ctl = nexradControllerRef.current;
423
- if (NEXRAD_NATIVE && ctl) {
424
- ctl.updateStyleOptions({
425
- interpolateNexradColormap,
426
- nexradGateSmoothing,
427
- });
428
- if (core?.state?.isNexrad) {
429
- ctl.applyStyleFromState(core.state);
430
- }
431
- }
432
- }, [interpolateNexradColormap, nexradGateSmoothing, core]);
433
-
434
- // Cache for preloaded grid data - stores the processed data ready for GPU upload
435
- const preloadedDataCache = useRef(new Map());
436
-
437
- // Store geometry and colormap that don't change with forecast hour
438
- const cachedGeometry = useRef(null);
439
- const cachedColormap = useRef(null);
440
- const cachedDataRange = useRef([0, 1]);
441
-
442
- // Track if we've done the initial load
443
- const hasInitialLoad = useRef(false);
444
- const hasPreloadedRef = useRef(false);
445
-
446
- // Track the last state we processed to avoid redundant updates
447
- const lastProcessedState = useRef(null);
448
- const previousStateRef = useRef(null);
449
-
450
- const [renderProps, setRenderProps] = useState({
451
- opacity: 1,
452
- dataRange: [0, 1]
453
- });
454
- /** Drives {@link NexradSitesMapLayer}; must re-render when core NEXRAD / picker flags change. */
455
- const [nexradSitesMapVisible, setNexradSitesMapVisible] = useState(false);
456
-
457
- useImperativeHandle(ref, () => {
458
- const setAutoRefresh = (enabled, intervalSeconds) => {
459
- if (autoRefreshIntervalId.current) {
460
- clearInterval(autoRefreshIntervalId.current);
461
- autoRefreshIntervalId.current = null;
462
- }
463
- if (enabled) {
464
- const effectiveInterval = (intervalSeconds || autoRefreshInterval || 30) * 1000;
465
- // Run once immediately, then start the interval
466
- _checkForUpdates();
467
- autoRefreshIntervalId.current = setInterval(_checkForUpdates, effectiveInterval);
468
- }
469
- };
470
- return {
471
- play: () => {
472
- core.play();
473
- },
474
- pause: () => {
475
- core.pause();
476
- },
477
- togglePlay: () => {
478
- core.togglePlay();
479
- },
480
- step: (direction) => {
481
- core.step(direction);
482
- },
483
- setPlaybackSpeed: (speed) => {
484
- if (speed > 0) {
485
- core.playbackSpeed = speed;
486
- if (core.isPlaying) {
487
- core.pause();
488
- core.play();
489
- }
490
- }
491
- },
492
- setOpacity: (opacity) => core.setOpacity(opacity),
493
- setUnits: (units) => core.setUnits(units),
494
- switchMode: (options) => core.switchMode(options),
495
- getAvailableVariables: (model) => core.getAvailableVariables(model),
496
- getVariableDisplayName: (code) => core.getVariableDisplayName(code),
497
- setRun: (runString) => core.setState({ run: runString.split(':')[1] }),
498
- setState: (newState) => core.setState(newState),
499
- setMRMSTimestamp: (timestamp) => core.setMRMSTimestamp(timestamp),
500
- setMRMSDurationValue: (value) => applyMrmsDurationValue(core, value),
501
- setNexradSite: (siteId) => core.setNexradSite(siteId),
502
- setNexradProduct: (product) => core.setNexradProduct(product),
503
- setNexradTilt: (tilt) => core.setNexradTilt(tilt),
504
- setNexradStormRelative: (enabled) => core.setNexradStormRelative(enabled),
505
- setNexradTimestamp: (ts) => core.setNexradTimestamp(ts),
506
- setNexradDurationValue: (value) => applyNexradDurationValue(core, value),
507
- setSatelliteTimestamp: (timestamp) => core.setSatelliteTimestamp(timestamp),
508
- setSatelliteDurationValue: (value) => core.setSatelliteDurationValue(value),
509
- setSatelliteSelection: (opts) => core.setSatelliteSelection(opts),
510
- setShaderSmoothing: async (enabled) => {
511
- await core.setShaderSmoothing(enabled);
512
- if (gridLayerRef.current) {
513
- gridLayerRef.current.setSmoothing(enabled);
514
- }
515
- },
516
- setSmoothing: (enabled) => {
517
- if (gridLayerRef.current) {
518
- gridLayerRef.current.setSmoothing(enabled);
519
- }
520
- },
521
- setAutoRefresh,
522
- refreshData: () => {
523
- _checkForUpdates();
524
- },
525
- /**
526
- * NWS watches/warnings (native map, iOS + Android): same options as mapsgl {@link WeatherLayerManager#configureWatchesWarnings}.
527
- * @param {object} partial
528
- */
529
- configureWatchesWarnings: (partial) => {
530
- setWatchesWarningsOptions((prev) => ({ ...prev, ...partial }));
531
- },
532
- };
533
- }, [core, autoRefreshInterval, _checkForUpdates]);
534
-
535
- const preloadAllFramesToDisk = async (state) => {
536
- if (state.isNexrad || state.isSatellite) {
537
- hasPreloadedRef.current = false;
538
- wxGridVerbose('preloadSkip', { reason: 'nexrad_or_satellite' });
539
- return;
540
- }
541
-
542
- if (hasPreloadedRef.current) {
543
- wxGridVerbose('preloadSkip', { reason: 'hasPreloadedRef_gate', isMRMS: state.isMRMS, variable: state.variable });
544
- return;
545
- }
546
-
547
- const { isMRMS, model, date, run, variable, units, availableHours, availableTimestamps, forecastHour, mrmsTimestamp } = state;
548
-
549
- // CRITICAL: Don't start preloading if we don't have a valid current frame
550
- if (isMRMS && (mrmsTimestamp == null || !availableTimestamps || availableTimestamps.length === 0)) {
551
- hasPreloadedRef.current = false;
552
- wxGridWarn('preloadAbort', {
553
- reason: 'mrms_missing_frame_or_timestamps',
554
- mrmsTimestamp,
555
- timestampCount: availableTimestamps?.length ?? 0,
556
- variable,
557
- });
558
- return;
559
- }
560
-
561
- if (!isMRMS && (forecastHour == null || !availableHours || availableHours.length === 0)) {
562
- hasPreloadedRef.current = false;
563
- wxGridWarn('preloadAbort', {
564
- reason: 'model_missing_hour_or_hours',
565
- forecastHour,
566
- hourCount: availableHours?.length ?? 0,
567
- model,
568
- variable,
569
- });
570
- return;
571
- }
572
-
573
- // Only mark as "has preloaded" after validation passes
574
- hasPreloadedRef.current = true;
575
-
576
- wxGridVerbose('preloadStart', {
577
- isMRMS,
578
- model,
579
- date,
580
- run,
581
- variable,
582
- mrmsTimestamp: isMRMS ? mrmsTimestamp : undefined,
583
- forecastHour: !isMRMS ? forecastHour : undefined,
584
- baseGridUrl: core?.baseGridUrl,
585
- hasApiKey: Boolean(core?.apiKey),
586
- bundleId: core?.bundleId ?? null,
587
- gridRequestSiteOrigin: gridRequestSiteOrigin || null,
588
- });
589
-
590
- // Fix the current forecast hour if it's invalid for this variable/model combo
591
- let effectiveForecastHour = forecastHour;
592
- if (!isMRMS && variable === 'ptypeRefl' && model === 'hrrr' && forecastHour === 0) {
593
- const validHours = availableHours.filter(hour => hour !== 0);
594
- effectiveForecastHour = validHours.length > 0 ? validHours[0] : 0;
595
- }
596
-
597
- if (!cachedGeometry.current || !cachedColormap.current) {
598
- const gridModel = isMRMS ? 'mrms' : model;
599
- const { corners, gridDef } = core._getGridCornersAndDef(gridModel);
600
- gridLayerRef.current.updateGeometry(corners, gridDef);
601
- cachedGeometry.current = { model: gridModel, variable };
602
-
603
- wxGridVerbose('geometryColormapInit', {
604
- gridModel,
605
- variable,
606
- nx: gridDef?.grid_params?.nx,
607
- ny: gridDef?.grid_params?.ny,
608
- });
609
-
610
- const { colormap, baseUnit } = core._getColormapForVariable(variable);
611
- const toUnit = core._getTargetUnit(baseUnit, units);
612
- const finalColormap = core._convertColormapUnits(colormap, baseUnit, toUnit);
613
- let dataRange;
614
- if (variable === 'ptypeRefl' || variable === 'ptypeRate') {
615
- dataRange = isMRMS ? [5, 380] : [5, 380];
616
- } else {
617
- dataRange = [finalColormap[0], finalColormap[finalColormap.length - 2]];
618
- }
619
- const colormapBytes = _generateColormapBytes(finalColormap);
620
- const colormapAsBase64 = fromByteArray(colormapBytes);
621
-
622
- gridLayerRef.current.updateColormapTexture(colormapAsBase64);
623
- cachedColormap.current = { key: `${variable}-${units}` };
624
- cachedDataRange.current = dataRange;
625
-
626
- setRenderProps({ opacity: state.opacity, dataRange: dataRange });
627
- hasInitialLoad.current = true;
628
- }
629
-
630
- // Apply the same filtering logic as in AguaceroCore._emitStateChange
631
- let filteredHours = availableHours;
632
- if (!isMRMS && variable === 'ptypeRefl' && model === 'hrrr' && availableHours && availableHours.length > 0) {
633
- filteredHours = availableHours.filter(hour => hour !== 0);
634
- }
635
-
636
- const allFrames = isMRMS ? availableTimestamps : filteredHours;
637
- if (!allFrames || allFrames.length === 0) {
638
- hasPreloadedRef.current = false;
639
- wxGridWarn('preloadAbort', { reason: 'no_frames_after_filter', isMRMS, variable });
640
- return;
641
- }
642
-
643
- const currentFrame = isMRMS ? mrmsTimestamp : effectiveForecastHour;
644
-
645
- // Double-check currentFrame is valid
646
- if (currentFrame == null) {
647
- hasPreloadedRef.current = false;
648
- wxGridWarn('preloadAbort', { reason: 'currentFrame_null', isMRMS, variable });
649
- return;
650
- }
651
-
652
- // Reverse the frame order to load from last to first
653
- const reversedFrames = [...allFrames].reverse();
654
- const framesToPreload = reversedFrames.filter(frame => frame !== currentFrame);
655
-
656
- const { corners, gridDef } = core._getGridCornersAndDef(isMRMS ? 'mrms' : model);
657
- const { nx, ny } = gridDef.grid_params;
658
-
659
- // Load the current frame FIRST and WAIT for it before continuing
660
- const currentCacheKey = isMRMS ? `mrms-${currentFrame}-${variable}` : `${model}-${date}-${run}-${currentFrame}-${variable}`;
661
-
662
- if (!preloadedDataCache.current.has(currentCacheKey)) {
663
- let resourcePath;
664
- if (isMRMS) {
665
- const frameDate = new Date(currentFrame * 1000);
666
- const y = frameDate.getUTCFullYear();
667
- const m = (frameDate.getUTCMonth() + 1).toString().padStart(2, '0');
668
- const d = frameDate.getUTCDate().toString().padStart(2, '0');
669
- resourcePath = `/grids/mrms/${y}${m}${d}/${currentFrame}/0/${variable}/0`;
670
- } else {
671
- resourcePath = `/grids/${model}/${date}/${run}/${currentFrame}/${variable}/0`;
672
- }
673
-
674
- const options = augmentProcessFrameOptionsForDebug(
675
- buildGridFrameProcessOptions(
676
- core.baseGridUrl,
677
- resourcePath,
678
- core.apiKey,
679
- core.bundleId,
680
- gridRequestSiteOrigin,
681
- ),
682
- core,
683
- );
684
- logProcessFrameAuthMismatch(core, options, { phase: 'preloadCurrent', currentCacheKey });
685
-
686
- try {
687
- wxGridVerbose('processFrameRequest', { currentCacheKey, resourcePath, frame: currentFrame });
688
- const result = await WeatherFrameProcessorModule.processFrame(options);
689
-
690
- if (!result || !result.filePath) {
691
- hasPreloadedRef.current = false;
692
- wxGridWarn('preloadAbort', {
693
- reason: 'processFrame_empty_result',
694
- currentCacheKey,
695
- resultKeys: result ? Object.keys(result) : [],
696
- });
697
- return;
698
- }
699
-
700
- const { baseUnit } = core._getColormapForVariable(variable);
701
-
702
- const toUnit = core._getTargetUnit(baseUnit, units);
703
-
704
- const fieldInfo = DICTIONARIES?.fld?.[variable] || {};
705
- const serverDataUnit = fieldInfo.defaultUnit || baseUnit;
706
-
707
- let dataScale = result.scale;
708
- let dataOffset = result.offset;
709
-
710
- let convertedScale = dataScale;
711
- let convertedOffset = dataOffset;
712
-
713
- if (serverDataUnit !== baseUnit) {
714
- const conversionFunc = getUnitConversionFunction(serverDataUnit, baseUnit);
715
- if (conversionFunc) {
716
- if (result.scaleType === 'sqrt') {
717
- const physicalAtOffset = dataOffset * dataOffset;
718
- const physicalAtOffsetPlusScale = (dataOffset + dataScale) * (dataOffset + dataScale);
719
- const convertedPhysicalAtOffset = conversionFunc(physicalAtOffset);
720
- const convertedPhysicalAtOffsetPlusScale = conversionFunc(physicalAtOffsetPlusScale);
721
- convertedOffset = Math.sqrt(Math.abs(convertedPhysicalAtOffset)) * Math.sign(convertedPhysicalAtOffset);
722
- const newOffsetPlusScale = Math.sqrt(Math.abs(convertedPhysicalAtOffsetPlusScale)) * Math.sign(convertedPhysicalAtOffsetPlusScale);
723
- convertedScale = newOffsetPlusScale - convertedOffset;
724
- } else {
725
- convertedOffset = conversionFunc(dataOffset);
726
- const convertedOffsetPlusScale = conversionFunc(dataOffset + dataScale);
727
- convertedScale = convertedOffsetPlusScale - convertedOffset;
728
- }
729
- dataScale = convertedScale;
730
- dataOffset = convertedOffset;
731
- }
732
- }
733
-
734
- if (baseUnit !== toUnit) {
735
- const conversionFunc = getUnitConversionFunction(baseUnit, toUnit);
736
- if (conversionFunc) {
737
- if (result.scaleType === 'sqrt') {
738
- const physicalAtOffset = dataOffset * dataOffset;
739
- const physicalAtOffsetPlusScale = (dataOffset + dataScale) * (dataOffset + dataScale);
740
- const convertedPhysicalAtOffset = conversionFunc(physicalAtOffset);
741
- const convertedPhysicalAtOffsetPlusScale = conversionFunc(physicalAtOffsetPlusScale);
742
- convertedOffset = Math.sqrt(Math.abs(convertedPhysicalAtOffset)) * Math.sign(convertedPhysicalAtOffset);
743
- const newOffsetPlusScale = Math.sqrt(Math.abs(convertedPhysicalAtOffsetPlusScale)) * Math.sign(convertedPhysicalAtOffsetPlusScale);
744
- convertedScale = newOffsetPlusScale - convertedOffset;
745
- } else {
746
- convertedOffset = conversionFunc(dataOffset);
747
- const convertedOffsetPlusScale = conversionFunc(dataOffset + dataScale);
748
- convertedScale = convertedOffsetPlusScale - convertedOffset;
749
- }
750
- }
751
- }
752
-
753
- const frameData = {
754
- filePath: result.filePath,
755
- nx, ny,
756
- scale: convertedScale,
757
- offset: convertedOffset,
758
- missing: result.missing,
759
- corners,
760
- gridDef,
761
- scaleType: result.scaleType,
762
- originalScale: result.scale,
763
- originalOffset: result.offset
764
- };
765
-
766
- preloadedDataCache.current.set(currentCacheKey, frameData);
767
-
768
- // Update the GPU with the current frame
769
- gridLayerRef.current.updateDataTextureFromFile(
770
- frameData.filePath,
771
- frameData.nx, frameData.ny,
772
- frameData.scale, frameData.offset, frameData.missing,
773
- frameData.scaleType
774
- );
775
-
776
- currentGridDataRef.current = {
777
- nx: frameData.nx,
778
- ny: frameData.ny,
779
- scale: frameData.scale,
780
- offset: frameData.offset,
781
- missing: frameData.missing,
782
- gridDef: frameData.gridDef,
783
- variable: variable,
784
- units: units,
785
- scaleType: frameData.scaleType
786
- };
787
-
788
- wxGridVerbose('preloadCurrentFrameOk', {
789
- currentCacheKey,
790
- nx: frameData.nx,
791
- ny: frameData.ny,
792
- scale: frameData.scale,
793
- offset: frameData.offset,
794
- filePathSuffix: String(frameData.filePath).split('/').pop(),
795
- });
796
- } catch (e) {
797
- const cancelled =
798
- e &&
799
- (e.code === 'E_CANCELLED' ||
800
- e?.userInfo?.code === 'E_CANCELLED' ||
801
- (typeof e?.message === 'string' && e.message.includes('superseded')));
802
- if (!cancelled) {
803
- hasPreloadedRef.current = false;
804
- const errDetail = {
805
- currentCacheKey,
806
- code: e?.code,
807
- message: e?.message,
808
- userInfo: e?.userInfo,
809
- };
810
- wxGridWarn('preloadProcessFrameError', errDetail);
811
- if (isAguaceroRnDebugEnabled()) {
812
- aguaceroDebugWarn('preloadProcessFrameError', {
813
- ...errDetail,
814
- auth: getAguaceroAuthDiagnosticSnapshot(core),
815
- is403:
816
- e?.code === 'HTTP_ERROR' &&
817
- typeof e?.message === 'string' &&
818
- e.message.includes('403'),
819
- });
820
- }
821
- } else {
822
- wxGridVerbose('preloadProcessFrameCancelled', { currentCacheKey });
823
- }
824
- }
825
- }
826
-
827
- // NOW preload the rest of the frames asynchronously
828
- framesToPreload.forEach((frame) => {
829
- const cacheKey = isMRMS ? `mrms-${frame}-${variable}` : `${model}-${date}-${run}-${frame}-${variable}`;
830
- if (preloadedDataCache.current.has(cacheKey)) {
831
- return;
832
- }
833
-
834
- let resourcePath;
835
- if (isMRMS) {
836
- const frameDate = new Date(frame * 1000);
837
- const y = frameDate.getUTCFullYear();
838
- const m = (frameDate.getUTCMonth() + 1).toString().padStart(2, '0');
839
- const d = frameDate.getUTCDate().toString().padStart(2, '0');
840
- resourcePath = `/grids/mrms/${y}${m}${d}/${frame}/0/${variable}/0`;
841
- } else {
842
- resourcePath = `/grids/${model}/${date}/${run}/${frame}/${variable}/0`;
843
- }
844
-
845
- const options = augmentProcessFrameOptionsForDebug(
846
- buildGridFrameProcessOptions(
847
- core.baseGridUrl,
848
- resourcePath,
849
- core.apiKey,
850
- core.bundleId,
851
- gridRequestSiteOrigin,
852
- ),
853
- core,
854
- );
855
- logProcessFrameAuthMismatch(core, options, { phase: 'preloadBackground', cacheKey });
856
-
857
- WeatherFrameProcessorModule.processFrame(options)
858
- .then(result => {
859
- if (!result || !result.filePath) {
860
- return;
861
- }
862
-
863
- // ADD: Same two-step conversion as the current frame
864
- const { baseUnit } = core._getColormapForVariable(variable);
865
- const toUnit = core._getTargetUnit(baseUnit, units);
866
- const fieldInfo = DICTIONARIES?.fld?.[variable] || {};
867
- const serverDataUnit = fieldInfo.defaultUnit || baseUnit;
868
-
869
- let dataScale = result.scale;
870
- let dataOffset = result.offset;
871
-
872
- let convertedScale = dataScale;
873
- let convertedOffset = dataOffset;
874
-
875
- // Step 1: Convert from server unit to colormap base unit
876
- if (serverDataUnit !== baseUnit) {
877
- const conversionFunc = getUnitConversionFunction(serverDataUnit, baseUnit);
878
- if (conversionFunc) {
879
- if (result.scaleType === 'sqrt') {
880
- const physicalAtOffset = dataOffset * dataOffset;
881
- const physicalAtOffsetPlusScale = (dataOffset + dataScale) * (dataOffset + dataScale);
882
- const convertedPhysicalAtOffset = conversionFunc(physicalAtOffset);
883
- const convertedPhysicalAtOffsetPlusScale = conversionFunc(physicalAtOffsetPlusScale);
884
- convertedOffset = Math.sqrt(Math.abs(convertedPhysicalAtOffset)) * Math.sign(convertedPhysicalAtOffset);
885
- const newOffsetPlusScale = Math.sqrt(Math.abs(convertedPhysicalAtOffsetPlusScale)) * Math.sign(convertedPhysicalAtOffsetPlusScale);
886
- convertedScale = newOffsetPlusScale - convertedOffset;
887
- } else {
888
- convertedOffset = conversionFunc(dataOffset);
889
- const convertedOffsetPlusScale = conversionFunc(dataOffset + dataScale);
890
- convertedScale = convertedOffsetPlusScale - convertedOffset;
891
- }
892
- dataScale = convertedScale;
893
- dataOffset = convertedOffset;
894
- }
895
- }
896
-
897
- // Step 2: Convert from colormap base unit to target display unit
898
- if (baseUnit !== toUnit) {
899
- const conversionFunc = getUnitConversionFunction(baseUnit, toUnit);
900
- if (conversionFunc) {
901
- if (result.scaleType === 'sqrt') {
902
- const physicalAtOffset = dataOffset * dataOffset;
903
- const physicalAtOffsetPlusScale = (dataOffset + dataScale) * (dataOffset + dataScale);
904
- const convertedPhysicalAtOffset = conversionFunc(physicalAtOffset);
905
- const convertedPhysicalAtOffsetPlusScale = conversionFunc(physicalAtOffsetPlusScale);
906
- convertedOffset = Math.sqrt(Math.abs(convertedPhysicalAtOffset)) * Math.sign(convertedPhysicalAtOffset);
907
- const newOffsetPlusScale = Math.sqrt(Math.abs(convertedPhysicalAtOffsetPlusScale)) * Math.sign(convertedPhysicalAtOffsetPlusScale);
908
- convertedScale = newOffsetPlusScale - convertedOffset;
909
- } else {
910
- convertedOffset = conversionFunc(dataOffset);
911
- const convertedOffsetPlusScale = conversionFunc(dataOffset + dataScale);
912
- convertedScale = convertedOffsetPlusScale - convertedOffset;
913
- }
914
- }
915
- }
916
-
917
- const frameData = {
918
- filePath: result.filePath,
919
- nx, ny,
920
- scale: convertedScale,
921
- offset: convertedOffset,
922
- missing: result.missing,
923
- corners,
924
- gridDef,
925
- scaleType: result.scaleType,
926
- originalScale: result.scale,
927
- originalOffset: result.offset
928
- };
929
-
930
- preloadedDataCache.current.set(cacheKey, frameData);
931
-
932
- if (Platform.OS === 'ios' && gridLayerRef.current.primeGpuCache) {
933
- const frameInfoForGpu = {
934
- [cacheKey]: {
935
- filePath: frameData.filePath,
936
- nx: frameData.nx,
937
- ny: frameData.ny,
938
- scale: frameData.scale,
939
- offset: frameData.offset,
940
- missing: frameData.missing,
941
- scaleType: frameData.scaleType || 'linear',
942
- originalScale: frameData.originalScale,
943
- originalOffset: frameData.originalOffset
944
- }
945
- };
946
- gridLayerRef.current.primeGpuCache(frameInfoForGpu);
947
- }
948
- })
949
- .catch((e) => {
950
- const cancelled =
951
- e &&
952
- (e.code === 'E_CANCELLED' ||
953
- e?.userInfo?.code === 'E_CANCELLED' ||
954
- (typeof e?.message === 'string' && e.message.includes('superseded')));
955
- if (!cancelled) {
956
- wxGridWarn('backgroundFrameError', { cacheKey, code: e?.code, message: e?.message });
957
- }
958
- });
959
- });
960
- };
961
-
962
- useEffect(() => {
963
- // This effect manages the auto-refresh based on props
964
- if (autoRefresh) {
965
- const interval = (autoRefreshInterval || 30) * 1000;
966
- _checkForUpdates(); // Run immediately on enable
967
- autoRefreshIntervalId.current = setInterval(_checkForUpdates, interval);
968
- }
969
-
970
- // Cleanup function: this runs when the component unmounts or props change
971
- return () => {
972
- if (autoRefreshIntervalId.current) {
973
- clearInterval(autoRefreshIntervalId.current);
974
- autoRefreshIntervalId.current = null;
975
- }
976
- };
977
- }, [autoRefresh, autoRefreshInterval, _checkForUpdates]);
978
-
979
- const updateGPUWithCachedData = (state) => {
980
- const { model, date, run, forecastHour, variable, units, isMRMS, mrmsTimestamp } = state;
981
-
982
- const cacheKey = isMRMS
983
- ? `mrms-${mrmsTimestamp}-${variable}`
984
- : `${model}-${date}-${run}-${forecastHour}-${variable}`;
985
-
986
- if (Platform.OS === 'ios' && gridLayerRef.current.setActiveFrame) {
987
- // Get the cached data BEFORE calling setActiveFrame
988
- const cachedData = preloadedDataCache.current.get(cacheKey);
989
-
990
- if (cachedData) {
991
- currentGridDataRef.current = {
992
- nx: cachedData.nx,
993
- ny: cachedData.ny,
994
- scale: cachedData.scale,
995
- offset: cachedData.offset,
996
- missing: cachedData.missing,
997
- gridDef: cachedData.gridDef,
998
- variable: variable,
999
- units: units,
1000
- scaleType: cachedData.scaleType
1001
- };
1002
- } else {
1003
- wxGridVerbose('gpuIOS_setActiveFrame_no_row', {
1004
- cacheKey,
1005
- cacheSize: preloadedDataCache.current.size,
1006
- });
1007
- }
1008
-
1009
- gridLayerRef.current.setActiveFrame(cacheKey);
1010
- wxGridVerbose('gpuIOS_setActiveFrame', { cacheKey, hadCachedRow: Boolean(cachedData) });
1011
- return true;
1012
- }
1013
-
1014
- const cachedData = preloadedDataCache.current.get(cacheKey);
1015
-
1016
- if (!cachedData) {
1017
- wxGridWarn('updateGPUNoCache', {
1018
- cacheKey,
1019
- cacheSize: preloadedDataCache.current.size,
1020
- sampleKeys: Array.from(preloadedDataCache.current.keys()).slice(0, 5),
1021
- });
1022
- return false;
1023
- }
1024
-
1025
- if (!gridLayerRef.current) {
1026
- wxGridWarn('updateGPUNoGridLayerRef', { cacheKey });
1027
- return false;
1028
- }
1029
-
1030
- if (!cachedGeometry.current || cachedGeometry.current.model !== (isMRMS ? 'mrms' : model) || cachedGeometry.current.variable !== variable) {
1031
- gridLayerRef.current.updateGeometry(cachedData.corners, cachedData.gridDef);
1032
- cachedGeometry.current = { model: (isMRMS ? 'mrms' : model), variable };
1033
- }
1034
-
1035
- const colormapKey = `${variable}-${units}`;
1036
- if (!cachedColormap.current || cachedColormap.current.key !== colormapKey) {
1037
- const { colormap, baseUnit } = core._getColormapForVariable(variable);
1038
- const toUnit = core._getTargetUnit(baseUnit, units);
1039
- const finalColormap = core._convertColormapUnits(colormap, baseUnit, toUnit);
1040
- let dataRange;
1041
- if (variable === 'ptypeRefl' || variable === 'ptypeRate') {
1042
- if (isMRMS) {
1043
- dataRange = [5, 380];
1044
- } else {
1045
- dataRange = [5, 380];
1046
- }
1047
- } else {
1048
- dataRange = [finalColormap[0], finalColormap[finalColormap.length - 2]];
1049
- }
1050
- const colormapBytes = _generateColormapBytes(finalColormap);
1051
- const colormapAsBase64 = fromByteArray(colormapBytes);
1052
-
1053
- gridLayerRef.current.updateColormapTexture(colormapAsBase64);
1054
- cachedColormap.current = { key: colormapKey };
1055
- cachedDataRange.current = dataRange;
1056
-
1057
- setRenderProps(prev => ({ ...prev, dataRange }));
1058
- }
1059
-
1060
- if (cachedData.filePath) {
1061
- gridLayerRef.current.updateDataTextureFromFile(
1062
- cachedData.filePath,
1063
- cachedData.nx, cachedData.ny,
1064
- cachedData.scale, cachedData.offset, cachedData.missing,
1065
- cachedData.scaleType
1066
- );
1067
- currentGridDataRef.current = {
1068
- nx: cachedData.nx,
1069
- ny: cachedData.ny,
1070
- scale: cachedData.scale,
1071
- offset: cachedData.offset,
1072
- missing: cachedData.missing,
1073
- gridDef: cachedData.gridDef,
1074
- variable: variable,
1075
- units: units,
1076
- scaleType: cachedData.scaleType
1077
- };
1078
- wxGridVerbose('gpuUploadFromFile', {
1079
- cacheKey,
1080
- nx: cachedData.nx,
1081
- ny: cachedData.ny,
1082
- filePathSuffix: String(cachedData.filePath).split('/').pop(),
1083
- });
1084
- } else {
1085
- wxGridWarn('updateGPUNoFilePath', { cacheKey });
1086
- return false;
1087
- }
1088
-
1089
- // Update inspector parameters for file-based data too
1090
- if (gridLayerRef.current && gridLayerRef.current.updateDataParameters) {
1091
- gridLayerRef.current.updateDataParameters(cachedData.scale, cachedData.offset, cachedData.missing);
1092
- }
1093
- return true;
1094
- };
1095
-
1096
- const handleStateChangeRef = useRef(null);
1097
- const debounceTimeoutRef = useRef(null);
1098
-
1099
- useEffect(() => {
1100
- if (core && props.customColormaps) {
1101
- core.customColormaps = props.customColormaps;
1102
- // Trigger a re-render if we already have data loaded
1103
- if (hasInitialLoad.current) {
1104
- core._emitStateChange();
1105
- }
1106
- }
1107
- }, [core, props.customColormaps]);
1108
-
1109
- const getValueAtPoint = async (lng, lat) => {
1110
- if (!core) {
1111
- return null;
1112
- }
1113
-
1114
- if (NEXRAD_NATIVE && core.state?.isNexrad) {
1115
- return getNexradInspectPayloadAt(lng, lat);
1116
- }
1117
-
1118
- // ADD THIS: Check if we have valid data before attempting inspection
1119
- if (!currentGridDataRef.current) {
1120
- return null;
1121
- }
1122
-
1123
- try {
1124
- const gridIndices = core._getGridIndexFromLngLat(lng, lat);
1125
- if (!gridIndices) return null;
1126
-
1127
- const { i, j } = gridIndices;
1128
-
1129
- const value = await InspectorModule.getValueAtGridIndex(i, j);
1130
-
1131
- if (value === null) {
1132
- return null;
1133
- }
1134
-
1135
- const { colormap, baseUnit } = core._getColormapForVariable(core.state.variable);
1136
- const displayUnit = core._getTargetUnit(baseUnit, core.state.units);
1137
- const finalColormap = core._convertColormapUnits(colormap, baseUnit, displayUnit);
1138
- const minThreshold = finalColormap[0];
1139
-
1140
- if (value < minThreshold) {
1141
- return null;
1142
- }
1143
-
1144
- // Filter out values below the minimum threshold (matching shader behavior)
1145
- if (value < minThreshold) {
1146
- return null;
1147
- }
1148
-
1149
- // Also check if value is NaN or effectively missing
1150
- if (!isFinite(value)) {
1151
- return null;
1152
- }
1153
-
1154
- return {
1155
- value: value,
1156
- unit: displayUnit,
1157
- variable: {
1158
- code: core.state.variable,
1159
- name: core.getVariableDisplayName(core.state.variable)
1160
- },
1161
- lngLat: { lng, lat }
1162
- };
1163
- } catch {
1164
- return null;
1165
- }
1166
- };
1167
-
1168
- const getValueAtPointRef = useRef(getValueAtPoint);
1169
- getValueAtPointRef.current = getValueAtPoint;
1170
- const getNexradInspectPayloadAtRef = useRef(getNexradInspectPayloadAt);
1171
- getNexradInspectPayloadAtRef.current = getNexradInspectPayloadAt;
1172
-
1173
- const _checkForUpdates = useMemo(() => async () => {
1174
- if (!core) return;
1175
- const s = core.state;
1176
- const { isMRMS, isSatellite, isNexrad, model: currentModel, variable: currentVariable, date, run } = s;
1177
-
1178
- if (isSatellite) {
1179
- const prevTimeline = core._computeSatelliteTimeline();
1180
- const prevTimes = [...(prevTimeline.unixTimes || [])]
1181
- .map((t) => Number(t))
1182
- .filter((t) => !Number.isNaN(t))
1183
- .sort((a, b) => a - b);
1184
- const prevMax = prevTimes.length ? prevTimes[prevTimes.length - 1] : null;
1185
- const curSat = s.satelliteTimestamp == null ? null : Number(s.satelliteTimestamp);
1186
-
1187
- await core.fetchSatelliteListing(true);
1188
- core._emitStateChange();
1189
-
1190
- const nextTimeline = core._computeSatelliteTimeline();
1191
- const nextTimes = [...(nextTimeline.unixTimes || [])]
1192
- .map((t) => Number(t))
1193
- .filter((t) => !Number.isNaN(t))
1194
- .sort((a, b) => a - b);
1195
- const newMax = nextTimes.length ? nextTimes[nextTimes.length - 1] : null;
1196
-
1197
- if (prevMax != null && curSat != null && curSat === prevMax && newMax != null && newMax > prevMax) {
1198
- await core.setSatelliteTimestamp(newMax);
1199
- } else if (curSat != null && newMax != null && nextTimes.length && !nextTimes.includes(curSat)) {
1200
- await core.setSatelliteTimestamp(newMax);
1201
- }
1202
- return;
1203
- }
1204
-
1205
- if (isNexrad && s.nexradSite) {
1206
- const nk = core._nexradTimesCacheKey();
1207
- const rawBefore = nk ? core.nexradTimesByStation[nk]?.unixTimes : [];
1208
- const filteredBefore = core._getFilteredNexradTimestampsForVariable(rawBefore || []);
1209
- const prevMax = filteredBefore.length ? filteredBefore[filteredBefore.length - 1] : null;
1210
- const curNx = s.nexradTimestamp == null ? null : Number(s.nexradTimestamp);
1211
-
1212
- await core.refreshNexradTimes();
1213
-
1214
- const rawAfter = nk ? core.nexradTimesByStation[nk]?.unixTimes : [];
1215
- const filteredAfter = core._getFilteredNexradTimestampsForVariable(rawAfter || []);
1216
- const newMax = filteredAfter.length ? filteredAfter[filteredAfter.length - 1] : null;
1217
-
1218
- if (prevMax != null && curNx != null && curNx === prevMax && newMax != null && newMax > prevMax) {
1219
- await core.setNexradTimestamp(newMax);
1220
- } else if (curNx != null && newMax != null && filteredAfter.length && !filteredAfter.includes(curNx)) {
1221
- await core.setNexradTimestamp(newMax);
1222
- }
1223
- return;
1224
- }
1225
-
1226
- if (isMRMS) {
1227
- // --- MRMS LOGIC (Keep existing logic) ---
1228
- const oldTimestamps = new Set(core.mrmsStatus?.[currentVariable] || []);
1229
- const mrmsStatus = await core.fetchMRMSStatus(true);
1230
- const newTimestamps = mrmsStatus?.[currentVariable] || [];
1231
- if (newTimestamps.length === 0) return;
1232
-
1233
- const newTimestampsToPreload = newTimestamps.filter(ts => !oldTimestamps.has(ts));
1234
-
1235
- if (newTimestampsToPreload.length > 0) {
1236
- core.mrmsStatus = mrmsStatus;
1237
- core._emitStateChange(); // Update UI slider without changing selection
1238
-
1239
- // ... (Keep your existing preloading logic here) ...
1240
- const { corners, gridDef } = core._getGridCornersAndDef('mrms');
1241
- const { nx, ny } = gridDef.grid_params;
1242
-
1243
- newTimestampsToPreload.forEach(frame => {
1244
- const cacheKey = `mrms-${frame}-${currentVariable}`;
1245
- if (preloadedDataCache.current.has(cacheKey)) return;
1246
-
1247
- const frameDate = new Date(frame * 1000);
1248
- const y = frameDate.getUTCFullYear(), m = (frameDate.getUTCMonth() + 1).toString().padStart(2, '0'), d = frameDate.getUTCDate().toString().padStart(2, '0');
1249
- const resourcePath = `/grids/mrms/${y}${m}${d}/${frame}/0/${currentVariable}/0`;
1250
- const options = augmentProcessFrameOptionsForDebug(
1251
- buildGridFrameProcessOptions(
1252
- core.baseGridUrl,
1253
- resourcePath,
1254
- core.apiKey,
1255
- core.bundleId,
1256
- gridRequestSiteOrigin,
1257
- ),
1258
- core,
1259
- );
1260
- logProcessFrameAuthMismatch(core, options, { phase: 'mrmsRefresh', cacheKey });
1261
-
1262
- WeatherFrameProcessorModule.processFrame(options)
1263
- .then(result => {
1264
- if (!result || !result.filePath) return;
1265
- const frameData = { filePath: result.filePath, nx, ny, scale: result.scale, offset: result.offset, missing: result.missing, corners, gridDef, scaleType: result.scaleType, originalScale: result.scale, originalOffset: result.offset };
1266
- preloadedDataCache.current.set(cacheKey, frameData);
1267
- if (Platform.OS === 'ios' && gridLayerRef.current?.primeGpuCache) {
1268
- gridLayerRef.current.primeGpuCache({ [cacheKey]: frameData });
1269
- }
1270
- })
1271
- .catch((e) => {
1272
- const cancelled =
1273
- e &&
1274
- (e.code === 'E_CANCELLED' ||
1275
- e?.userInfo?.code === 'E_CANCELLED' ||
1276
- (typeof e?.message === 'string' && e.message.includes('superseded')));
1277
- if (!cancelled) {
1278
- wxGridWarn('mrmsRefreshFrameError', { cacheKey, code: e?.code, message: e?.message });
1279
- }
1280
- });
1281
- });
1282
-
1283
- const newTimestampsSet = new Set(newTimestamps);
1284
- oldTimestamps.forEach(oldTs => {
1285
- if (!newTimestampsSet.has(oldTs)) {
1286
- const cacheKey = `mrms-${oldTs}-${currentVariable}`;
1287
- preloadedDataCache.current.delete(cacheKey);
1288
- }
1289
- });
1290
- }
1291
- } else {
1292
- const previousStatus = core.modelStatus;
1293
- const modelStatus = await core.fetchModelStatus(true);
1294
- const statusChanged = JSON.stringify(previousStatus) !== JSON.stringify(modelStatus);
1295
- if (statusChanged) {
1296
- core._emitStateChange();
1297
- }
1298
-
1299
- const latestRun = findLatestModelRun(modelStatus, currentModel);
1300
- if (!latestRun) return;
1301
- }
1302
- }, [core]);
1303
-
1304
- useEffect(() => {
1305
- if (!core) {
1306
- return;
1307
- }
1308
-
1309
- const handleStateChange = (newState) => {
1310
- if (!previousStateRef.current) {
1311
- previousStateRef.current = core.state;
1312
- }
1313
-
1314
- const variableChanged = !previousStateRef.current || newState.variable !== previousStateRef.current.variable;
1315
-
1316
- if (variableChanged && gridLayerRef.current?.setVariable) {
1317
- gridLayerRef.current.setVariable(newState.variable);
1318
- }
1319
-
1320
- const stateKey = newState.isNexrad
1321
- ? `nx-${newState.nexradSite}-${newState.nexradProduct}-${newState.nexradDataSource}-${newState.nexradTimestamp}-${newState.nexradTilt}-${newState.units}-${newState.model}-${newState.variable}-${newState.mrmsTimestamp}-${newState.forecastHour}-dur:${newState.nexradDurationValue ?? ''}-tl:${nexradObsTimelineSig(newState)}`
1322
- : newState.isSatellite
1323
- ? `sat-${newState.satelliteInstrumentId}-${newState.satelliteSectorLabel}-${newState.satelliteChannel}-${newState.satelliteTimestamp}-${newState.variable}-${newState.units}-${newState.opacity}-dur:${newState.satelliteDurationValue ?? ''}-tl:${satelliteObsTimelineSig(newState)}`
1324
- : `${newState.model}-${newState.variable}-${newState.date}-${newState.run}-${newState.forecastHour}-${newState.units}-${newState.mrmsTimestamp}`;
1325
-
1326
- const sameTimeline = newState.isNexrad
1327
- ? newState.nexradTimestamp === previousStateRef.current?.nexradTimestamp &&
1328
- newState.nexradSite === previousStateRef.current?.nexradSite &&
1329
- newState.nexradProduct === previousStateRef.current?.nexradProduct &&
1330
- Number(newState.nexradTilt) === Number(previousStateRef.current?.nexradTilt) &&
1331
- newState.nexradDurationValue === previousStateRef.current?.nexradDurationValue &&
1332
- nexradObsTimelineSig(newState) === nexradObsTimelineSig(previousStateRef.current)
1333
- : newState.isSatellite
1334
- ? newState.satelliteTimestamp === previousStateRef.current?.satelliteTimestamp &&
1335
- newState.satelliteInstrumentId === previousStateRef.current?.satelliteInstrumentId &&
1336
- newState.satelliteSectorLabel === previousStateRef.current?.satelliteSectorLabel &&
1337
- newState.satelliteChannel === previousStateRef.current?.satelliteChannel &&
1338
- newState.satelliteDurationValue === previousStateRef.current?.satelliteDurationValue &&
1339
- satelliteObsTimelineSig(newState) === satelliteObsTimelineSig(previousStateRef.current)
1340
- : newState.forecastHour === previousStateRef.current?.forecastHour &&
1341
- newState.mrmsTimestamp === previousStateRef.current?.mrmsTimestamp;
1342
-
1343
- const sameModeAnchor =
1344
- newState.isSatellite
1345
- ? newState.satelliteInstrumentId === previousStateRef.current?.satelliteInstrumentId &&
1346
- newState.satelliteSectorLabel === previousStateRef.current?.satelliteSectorLabel &&
1347
- newState.satelliteChannel === previousStateRef.current?.satelliteChannel
1348
- : newState.model === previousStateRef.current?.model &&
1349
- newState.isMRMS === previousStateRef.current?.isMRMS;
1350
-
1351
- const isOpacityOnlyChange =
1352
- hasInitialLoad.current &&
1353
- newState.opacity !== renderProps.opacity &&
1354
- newState.variable === previousStateRef.current?.variable &&
1355
- sameTimeline &&
1356
- sameModeAnchor &&
1357
- newState.units === previousStateRef.current?.units;
1358
-
1359
- const isPlayStateOnlyChange =
1360
- hasInitialLoad.current &&
1361
- newState.isPlaying !== previousStateRef.current?.isPlaying &&
1362
- newState.variable === previousStateRef.current?.variable &&
1363
- sameTimeline &&
1364
- sameModeAnchor &&
1365
- newState.units === previousStateRef.current?.units &&
1366
- newState.opacity === previousStateRef.current?.opacity;
1367
-
1368
- if (!isOpacityOnlyChange && !isPlayStateOnlyChange && lastProcessedState.current === stateKey) {
1369
- previousStateRef.current = newState;
1370
- return;
1371
- }
1372
-
1373
- if (
1374
- wxGridDebugEnabled() &&
1375
- Platform.OS === 'ios' &&
1376
- ((newState.isNexrad && previousStateRef.current?.isNexrad) ||
1377
- (newState.isSatellite && previousStateRef.current?.isSatellite))
1378
- ) {
1379
- const prev = previousStateRef.current;
1380
- if (newState.isNexrad && prev?.isNexrad) {
1381
- if (
1382
- newState.nexradDurationValue !== prev.nexradDurationValue ||
1383
- nexradObsTimelineSig(newState) !== nexradObsTimelineSig(prev)
1384
- ) {
1385
- wxGridVerbose('iosNexradTimelineWindowChanged', {
1386
- duration: { from: prev.nexradDurationValue, to: newState.nexradDurationValue },
1387
- timelineSig: { from: nexradObsTimelineSig(prev), to: nexradObsTimelineSig(newState) },
1388
- willSyncNative: true,
1389
- });
1390
- }
1391
- }
1392
- if (newState.isSatellite && prev?.isSatellite) {
1393
- if (
1394
- newState.satelliteDurationValue !== prev.satelliteDurationValue ||
1395
- satelliteObsTimelineSig(newState) !== satelliteObsTimelineSig(prev)
1396
- ) {
1397
- wxGridVerbose('iosSatelliteTimelineWindowChanged', {
1398
- duration: { from: prev.satelliteDurationValue, to: newState.satelliteDurationValue },
1399
- timelineSig: { from: satelliteObsTimelineSig(prev), to: satelliteObsTimelineSig(newState) },
1400
- willSyncNative: true,
1401
- });
1402
- }
1403
- }
1404
- }
1405
-
1406
- if (!isOpacityOnlyChange && !isPlayStateOnlyChange) {
1407
- lastProcessedState.current = stateKey;
1408
- }
1409
-
1410
- if (isOpacityOnlyChange) {
1411
- setRenderProps(prev => ({ ...prev, opacity: newState.opacity }));
1412
- if (NEXRAD_NATIVE && newState.isNexrad) {
1413
- nexradControllerRef.current?.applyStyleFromState?.(newState);
1414
- }
1415
- if (SATELLITE_NATIVE && newState.isSatellite) {
1416
- satelliteLayerRef.current?.updateSatelliteStyle?.(
1417
- JSON.stringify({
1418
- visible: newState.visible !== false,
1419
- opacity: newState.opacity ?? 1,
1420
- fillSmoothing: 0,
1421
- }),
1422
- );
1423
- }
1424
- previousStateRef.current = newState;
1425
- return;
1426
- }
1427
-
1428
- if (isPlayStateOnlyChange) {
1429
- previousStateRef.current = newState;
1430
- return;
1431
- }
1432
-
1433
- const isUnitsOnlyChange =
1434
- hasInitialLoad.current &&
1435
- newState.model === previousStateRef.current.model &&
1436
- newState.isMRMS === previousStateRef.current.isMRMS &&
1437
- newState.variable === previousStateRef.current.variable &&
1438
- newState.date === previousStateRef.current.date &&
1439
- newState.run === previousStateRef.current.run &&
1440
- (newState.isNexrad
1441
- ? newState.nexradTimestamp === previousStateRef.current.nexradTimestamp &&
1442
- newState.nexradSite === previousStateRef.current.nexradSite &&
1443
- newState.nexradProduct === previousStateRef.current.nexradProduct
1444
- : newState.forecastHour === previousStateRef.current.forecastHour &&
1445
- newState.mrmsTimestamp === previousStateRef.current.mrmsTimestamp) &&
1446
- newState.units !== previousStateRef.current.units;
1447
-
1448
- if (isUnitsOnlyChange) {
1449
- if (NEXRAD_NATIVE && newState.isNexrad) {
1450
- setRenderProps(prev => ({ ...prev, opacity: newState.opacity }));
1451
- nexradControllerRef.current?.applyStyleFromState?.(newState);
1452
- previousStateRef.current = newState;
1453
- return;
1454
- }
1455
- const { variable, units, isMRMS, mrmsTimestamp, model, date, run, forecastHour } = newState;
1456
- const oldCacheKey = isMRMS
1457
- ? `mrms-${mrmsTimestamp}-${variable}`
1458
- : `${model}-${date}-${run}-${forecastHour}-${variable}`;
1459
-
1460
- const cachedData = preloadedDataCache.current.get(oldCacheKey);
1461
-
1462
- if (cachedData && cachedData.originalScale !== undefined && cachedData.originalOffset !== undefined) {
1463
- const { baseUnit } = core._getColormapForVariable(variable);
1464
- const toUnit = core._getTargetUnit(baseUnit, units);
1465
- const fieldInfo = DICTIONARIES?.fld?.[variable] || {};
1466
- const serverDataUnit = fieldInfo.defaultUnit || baseUnit;
1467
-
1468
- let dataScale = cachedData.originalScale;
1469
- let dataOffset = cachedData.originalOffset;
1470
-
1471
- if (serverDataUnit !== baseUnit) {
1472
- const conversionFunc = getUnitConversionFunction(serverDataUnit, baseUnit);
1473
- if (conversionFunc) {
1474
- if (cachedData.scaleType === 'sqrt') {
1475
- const physicalAtOffset = dataOffset * dataOffset;
1476
- const physicalAtOffsetPlusScale = (dataOffset + dataScale) * (dataOffset + dataScale);
1477
- const convertedPhysicalAtOffset = conversionFunc(physicalAtOffset);
1478
- const convertedPhysicalAtOffsetPlusScale = conversionFunc(physicalAtOffsetPlusScale);
1479
- const newOffset = Math.sqrt(Math.abs(convertedPhysicalAtOffset)) * Math.sign(convertedPhysicalAtOffset);
1480
- const newOffsetPlusScale = Math.sqrt(Math.abs(convertedPhysicalAtOffsetPlusScale)) * Math.sign(convertedPhysicalAtOffsetPlusScale);
1481
- dataScale = newOffsetPlusScale - newOffset;
1482
- dataOffset = newOffset;
1483
- } else {
1484
- const convertedOffset = conversionFunc(dataOffset);
1485
- const convertedOffsetPlusScale = conversionFunc(dataOffset + dataScale);
1486
- dataScale = convertedOffsetPlusScale - convertedOffset;
1487
- dataOffset = convertedOffset;
1488
- }
1489
- }
1490
- }
1491
-
1492
- if (baseUnit !== toUnit) {
1493
- const conversionFunc = getUnitConversionFunction(baseUnit, toUnit);
1494
- if (conversionFunc) {
1495
- if (cachedData.scaleType === 'sqrt') {
1496
- const physicalAtOffset = dataOffset * dataOffset;
1497
- const physicalAtOffsetPlusScale = (dataOffset + dataScale) * (dataOffset + dataScale);
1498
- const convertedPhysicalAtOffset = conversionFunc(physicalAtOffset);
1499
- const convertedPhysicalAtOffsetPlusScale = conversionFunc(physicalAtOffsetPlusScale);
1500
- const newOffset = Math.sqrt(Math.abs(convertedPhysicalAtOffset)) * Math.sign(convertedPhysicalAtOffset);
1501
- const newOffsetPlusScale = Math.sqrt(Math.abs(convertedPhysicalAtOffsetPlusScale)) * Math.sign(convertedPhysicalAtOffsetPlusScale);
1502
- dataScale = newOffsetPlusScale - newOffset;
1503
- dataOffset = newOffset;
1504
- } else {
1505
- const convertedOffset = conversionFunc(dataOffset);
1506
- const convertedOffsetPlusScale = conversionFunc(dataOffset + dataScale);
1507
- dataScale = convertedOffsetPlusScale - convertedOffset;
1508
- dataOffset = convertedOffset;
1509
- }
1510
- }
1511
- }
1512
-
1513
- const { colormap } = core._getColormapForVariable(variable);
1514
- const finalColormap = core._convertColormapUnits(colormap, baseUnit, toUnit);
1515
- let dataRange = (variable === 'ptypeRefl' || variable === 'ptypeRate') ? [5, 380] : [finalColormap[0], finalColormap[finalColormap.length - 2]];
1516
- const colormapBytes = _generateColormapBytes(finalColormap);
1517
- const colormapAsBase64 = fromByteArray(colormapBytes);
1518
-
1519
- gridLayerRef.current.updateColormapTexture(colormapAsBase64);
1520
- cachedColormap.current = { key: `${variable}-${units}` };
1521
- cachedDataRange.current = dataRange;
1522
- setRenderProps(prev => ({ ...prev, dataRange, opacity: newState.opacity }));
1523
-
1524
- if (gridLayerRef.current && gridLayerRef.current.updateDataParameters) {
1525
- const scaleTypeValue = cachedData.scaleType === 'sqrt' ? 1 : 0;
1526
- gridLayerRef.current.updateDataParameters(dataScale, dataOffset, cachedData.missing, scaleTypeValue);
1527
- }
1528
-
1529
- const newCacheKey = isMRMS ? `mrms-${mrmsTimestamp}-${variable}` : `${model}-${date}-${run}-${forecastHour}-${variable}`;
1530
- preloadedDataCache.current.set(newCacheKey, { ...cachedData, scale: dataScale, offset: dataOffset });
1531
- }
1532
-
1533
- previousStateRef.current = newState;
1534
- return;
1535
- }
1536
-
1537
- const prev = previousStateRef.current;
1538
- const needsFullLoad =
1539
- !hasInitialLoad.current ||
1540
- newState.isNexrad !== prev?.isNexrad ||
1541
- newState.isSatellite !== prev?.isSatellite ||
1542
- (newState.isNexrad &&
1543
- prev?.isNexrad &&
1544
- (newState.nexradSite !== prev.nexradSite ||
1545
- newState.nexradDataSource !== prev.nexradDataSource ||
1546
- newState.nexradProduct !== prev.nexradProduct)) ||
1547
- (newState.isSatellite &&
1548
- prev?.isSatellite &&
1549
- (newState.satelliteInstrumentId !== prev.satelliteInstrumentId ||
1550
- newState.satelliteSectorLabel !== prev.satelliteSectorLabel ||
1551
- newState.satelliteChannel !== prev.satelliteChannel)) ||
1552
- (!newState.isNexrad &&
1553
- !newState.isSatellite &&
1554
- (newState.model !== prev?.model ||
1555
- newState.isMRMS !== prev?.isMRMS ||
1556
- newState.variable !== prev?.variable ||
1557
- newState.date !== prev?.date ||
1558
- newState.run !== prev?.run));
1559
-
1560
- if (needsFullLoad) {
1561
- wxGridVerbose('needsFullLoad', {
1562
- variable: newState.variable,
1563
- isMRMS: newState.isMRMS,
1564
- model: newState.model,
1565
- isInitial: !hasInitialLoad.current,
1566
- prevVariable: prev?.variable,
1567
- prevModel: prev?.model,
1568
- });
1569
- const nexradIdentityChange =
1570
- NEXRAD_NATIVE &&
1571
- (newState.isNexrad !== prev?.isNexrad ||
1572
- (newState.isNexrad &&
1573
- prev?.isNexrad &&
1574
- (newState.nexradSite !== prev.nexradSite ||
1575
- newState.nexradDataSource !== prev.nexradDataSource ||
1576
- newState.nexradProduct !== prev.nexradProduct)));
1577
- const satelliteIdentityChange =
1578
- SATELLITE_NATIVE &&
1579
- (newState.isSatellite !== prev?.isSatellite ||
1580
- (newState.isSatellite &&
1581
- prev?.isSatellite &&
1582
- (newState.satelliteInstrumentId !== prev.satelliteInstrumentId ||
1583
- newState.satelliteSectorLabel !== prev.satelliteSectorLabel ||
1584
- newState.satelliteChannel !== prev.satelliteChannel)));
1585
- if (nexradIdentityChange) {
1586
- nexradPreloadInteractionRef.current?.cancel?.();
1587
- nexradPreloadInteractionRef.current = null;
1588
- nexradControllerRef.current?.destroy();
1589
- nexradControllerRef.current = null;
1590
- }
1591
- if (satelliteIdentityChange) {
1592
- satelliteControllerRef.current?.destroy();
1593
- satelliteControllerRef.current = null;
1594
- }
1595
- if (gridLayerRef.current) {
1596
- gridLayerRef.current.setVariable(newState.variable);
1597
- gridLayerRef.current.clear();
1598
- if (Platform.OS === 'ios' && gridLayerRef.current.clearGpuCache) {
1599
- gridLayerRef.current.clearGpuCache();
1600
- }
1601
- }
1602
- hasPreloadedRef.current = false;
1603
- preloadedDataCache.current.clear();
1604
- cachedGeometry.current = null;
1605
- cachedColormap.current = null;
1606
- currentGridDataRef.current = null;
1607
- WeatherFrameProcessorModule.cancelAllFrames();
1608
- wxGridVerbose('cancelAllFrames', { reason: 'needsFullLoad', variable: newState.variable });
1609
-
1610
- if (!newState.variable) {
1611
- previousStateRef.current = newState;
1612
- return;
1613
- }
1614
- if (!newState.isNexrad && !newState.isSatellite) {
1615
- preloadAllFramesToDisk(newState);
1616
- } else if (NEXRAD_NATIVE && newState.isNexrad) {
1617
- hasInitialLoad.current = true;
1618
- } else if (SATELLITE_NATIVE && newState.isSatellite) {
1619
- hasInitialLoad.current = true;
1620
- }
1621
- } else if (
1622
- !newState.isNexrad &&
1623
- (newState.forecastHour !== previousStateRef.current.forecastHour ||
1624
- (newState.isMRMS && newState.mrmsTimestamp !== previousStateRef.current.mrmsTimestamp))
1625
- ) {
1626
- const success = updateGPUWithCachedData(newState);
1627
- if (!success) {
1628
- wxGridWarn('timelineGpuUpdateMiss', {
1629
- isMRMS: newState.isMRMS,
1630
- variable: newState.variable,
1631
- mrmsTimestamp: newState.mrmsTimestamp,
1632
- forecastHour: newState.forecastHour,
1633
- });
1634
- }
1635
- if (success && newState.opacity !== renderProps.opacity) {
1636
- setRenderProps(prev => ({ ...prev, opacity: newState.opacity }));
1637
- }
1638
- }
1639
-
1640
- if (NEXRAD_NATIVE && newState.isNexrad && newState.nexradSite && newState.nexradTimestamp != null) {
1641
- const ctl = ensureNexradController();
1642
- if (ctl) {
1643
- hasInitialLoad.current = true;
1644
- void ctl.sync(newState);
1645
- nexradPreloadInteractionRef.current?.cancel?.();
1646
- const preloadCancelled = { v: false };
1647
- nexradPreloadInteractionRef.current = {
1648
- cancel: () => {
1649
- preloadCancelled.v = true;
1650
- },
1651
- };
1652
- const nexradTimelineSnapshot = Array.isArray(newState.availableNexradTimestamps)
1653
- ? newState.availableNexradTimestamps
1654
- : [];
1655
- const kickPreload = () => {
1656
- nexradPreloadInteractionRef.current = null;
1657
- if (preloadCancelled.v) {
1658
- return;
1659
- }
1660
- const cur = nexradControllerRef.current;
1661
- if (!cur) {
1662
- return;
1663
- }
1664
- const s = core.state;
1665
- if (!s.isNexrad || !s.nexradSite || s.nexradTimestamp == null) {
1666
- return;
1667
- }
1668
- const coreTs = Array.isArray(s.availableNexradTimestamps) ? s.availableNexradTimestamps : [];
1669
- const mergedTimestamps =
1670
- coreTs.length > 0 ? coreTs : nexradTimelineSnapshot.length > 0 ? nexradTimelineSnapshot : [];
1671
- const preloadState = { ...s, availableNexradTimestamps: mergedTimestamps };
1672
- cur.preloadAllAvailable(preloadState);
1673
- };
1674
- if (typeof queueMicrotask === 'function') {
1675
- queueMicrotask(kickPreload);
1676
- } else {
1677
- setTimeout(kickPreload, 0);
1678
- }
1679
- }
1680
- } else if (NEXRAD_NATIVE && (!newState.isNexrad || !newState.nexradSite)) {
1681
- nexradPreloadInteractionRef.current?.cancel?.();
1682
- nexradPreloadInteractionRef.current = null;
1683
- nexradControllerRef.current?.destroy();
1684
- nexradControllerRef.current = null;
1685
- }
1686
-
1687
- if (SATELLITE_NATIVE && newState.isSatellite) {
1688
- satBridgeWarn('handleStateChange satellite', {
1689
- satelliteInstrumentId: newState.satelliteInstrumentId ?? null,
1690
- satelliteSectorLabel: newState.satelliteSectorLabel ?? null,
1691
- satelliteChannel: newState.satelliteChannel ?? null,
1692
- timelineKeys: Object.keys(newState.satelliteTimeToFileMap || {}).length,
1693
- refHasSync: Boolean(satelliteLayerRef.current?.syncSatellite),
1694
- });
1695
- const satCtl = ensureSatelliteController();
1696
- if (satCtl) {
1697
- hasInitialLoad.current = true;
1698
- void satCtl.sync(newState);
1699
- } else {
1700
- satBridgeWarn('handleStateChange satellite: ensureSatelliteController returned null', {});
1701
- }
1702
- if (!newState.satelliteInstrumentId && typeof __DEV__ !== 'undefined' && __DEV__) {
1703
- console.warn(
1704
- '[AguaceroWX][satellite] isSatellite is true but satelliteInstrumentId is missing — native sync still runs; timeline may be empty until core sets instrument.',
1705
- );
1706
- }
1707
- } else if (SATELLITE_NATIVE && !newState.isSatellite) {
1708
- satelliteControllerRef.current?.destroy();
1709
- satelliteControllerRef.current = null;
1710
- }
1711
-
1712
- previousStateRef.current = newState;
1713
- };
1714
-
1715
- handleStateChangeRef.current = handleStateChange;
1716
-
1717
- const stableHandler = (newState) => {
1718
- lastEmittedStateForInspectRef.current = newState;
1719
- setNexradSitesMapVisible(Boolean(newState.isNexrad && newState.nexradShowSitesPicker !== false));
1720
- // OPTIMIZATION: If playing (high speed), prioritize MAP update and skip debounce
1721
- if (newState.isPlaying) {
1722
- // 1. Update Map FIRST (Native Enqueue)
1723
- if (handleStateChangeRef.current) {
1724
- handleStateChangeRef.current(newState);
1725
- }
1726
-
1727
- // 2. Update UI Slider SECOND
1728
- // This ensures the heavy map frame is processing while React reconciles the slider
1729
- props.onStateChange?.(newState);
1730
-
1731
- if (debounceTimeoutRef.current) {
1732
- clearTimeout(debounceTimeoutRef.current);
1733
- debounceTimeoutRef.current = null;
1734
- }
1735
- return;
1736
- }
1737
-
1738
- // --- Existing Logic for scrubbing/paused ---
1739
-
1740
- // 1. Immediate Slider Update for responsiveness
1741
- props.onStateChange?.(newState);
1742
-
1743
- if (debounceTimeoutRef.current) {
1744
- clearTimeout(debounceTimeoutRef.current);
1745
- }
1746
-
1747
- const prevStable = previousStateRef.current;
1748
- const sameTimelineStable =
1749
- prevStable &&
1750
- (prevStable.isNexrad
1751
- ? newState.nexradTimestamp === prevStable.nexradTimestamp &&
1752
- newState.nexradSite === prevStable.nexradSite &&
1753
- newState.nexradProduct === prevStable.nexradProduct &&
1754
- Number(newState.nexradTilt) === Number(prevStable.nexradTilt) &&
1755
- newState.nexradDurationValue === prevStable.nexradDurationValue &&
1756
- nexradObsTimelineSig(newState) === nexradObsTimelineSig(prevStable)
1757
- : prevStable.isSatellite
1758
- ? newState.satelliteTimestamp === prevStable.satelliteTimestamp &&
1759
- newState.satelliteInstrumentId === prevStable.satelliteInstrumentId &&
1760
- newState.satelliteSectorLabel === prevStable.satelliteSectorLabel &&
1761
- newState.satelliteChannel === prevStable.satelliteChannel &&
1762
- newState.satelliteDurationValue === prevStable.satelliteDurationValue &&
1763
- satelliteObsTimelineSig(newState) === satelliteObsTimelineSig(prevStable)
1764
- : newState.forecastHour === prevStable.forecastHour &&
1765
- newState.mrmsTimestamp === prevStable.mrmsTimestamp);
1766
-
1767
- const sameModeStable =
1768
- prevStable &&
1769
- (newState.isSatellite
1770
- ? newState.satelliteInstrumentId === prevStable.satelliteInstrumentId &&
1771
- newState.satelliteSectorLabel === prevStable.satelliteSectorLabel &&
1772
- newState.satelliteChannel === prevStable.satelliteChannel
1773
- : newState.model === prevStable.model && newState.isMRMS === prevStable.isMRMS);
1774
-
1775
- // Opacity and Play state changes should be immediate for the native layer too
1776
- const isOpacityOnlyChange =
1777
- prevStable &&
1778
- sameTimelineStable &&
1779
- sameModeStable &&
1780
- newState.opacity !== prevStable.opacity &&
1781
- newState.variable === prevStable.variable &&
1782
- newState.units === prevStable.units;
1783
-
1784
- const isPlayStateOnlyChange =
1785
- prevStable &&
1786
- sameTimelineStable &&
1787
- sameModeStable &&
1788
- newState.isPlaying !== prevStable.isPlaying &&
1789
- newState.variable === prevStable.variable &&
1790
- newState.units === prevStable.units &&
1791
- newState.opacity === prevStable.opacity;
1792
-
1793
- if (isOpacityOnlyChange || isPlayStateOnlyChange || !previousStateRef.current) {
1794
- if (handleStateChangeRef.current) {
1795
- handleStateChangeRef.current(newState);
1796
- }
1797
- return;
1798
- }
1799
-
1800
- debounceTimeoutRef.current = setTimeout(() => {
1801
- if (handleStateChangeRef.current) {
1802
- handleStateChangeRef.current(newState);
1803
- }
1804
- debounceTimeoutRef.current = null;
1805
- }, 16); // ~60fps map updates
1806
- };
1807
-
1808
- core.on('state:change', stableHandler);
1809
-
1810
- return () => {
1811
- core.off('state:change', stableHandler);
1812
- if (debounceTimeoutRef.current) {
1813
- clearTimeout(debounceTimeoutRef.current);
1814
- }
1815
- if (NEXRAD_NATIVE) {
1816
- nexradPreloadInteractionRef.current?.cancel?.();
1817
- nexradPreloadInteractionRef.current = null;
1818
- nexradControllerRef.current?.destroy();
1819
- nexradControllerRef.current = null;
1820
- }
1821
- if (SATELLITE_NATIVE) {
1822
- satelliteControllerRef.current?.destroy();
1823
- satelliteControllerRef.current = null;
1824
- }
1825
- };
1826
- }, [core, ensureNexradController, ensureSatelliteController]);
1827
-
1828
- useEffect(() => {
1829
- return () => {
1830
- preloadedDataCache.current.clear(); // This drops JS references
1831
- hasInitialLoad.current = false;
1832
- lastProcessedState.current = null;
1833
- // Native cleanup
1834
- if (gridLayerRef.current && Platform.OS === 'ios') {
1835
- gridLayerRef.current.clearGpuCache();
1836
- }
1837
- if (NEXRAD_NATIVE) {
1838
- nexradPreloadInteractionRef.current?.cancel?.();
1839
- nexradPreloadInteractionRef.current = null;
1840
- nexradControllerRef.current?.destroy();
1841
- nexradControllerRef.current = null;
1842
- }
1843
- if (SATELLITE_NATIVE) {
1844
- satelliteControllerRef.current?.destroy();
1845
- satelliteControllerRef.current = null;
1846
- }
1847
- };
1848
- }, []);
1849
-
1850
- const lastInspectorUpdateRef = useRef(0);
1851
- const INSPECTOR_THROTTLE_MS = 50;
1852
-
1853
- useEffect(() => {
1854
- if (!core || !inspectorEnabled) {
1855
- return;
1856
- }
1857
-
1858
- const handleMapMove = (center) => {
1859
- if (!center || !Array.isArray(center) || center.length !== 2) {
1860
- return;
1861
- }
1862
-
1863
- // Throttle updates
1864
- const now = Date.now();
1865
- if (now - lastInspectorUpdateRef.current < INSPECTOR_THROTTLE_MS) {
1866
- return;
1867
- }
1868
- lastInspectorUpdateRef.current = now;
1869
-
1870
- const [longitude, latitude] = center;
1871
-
1872
- if (NEXRAD_NATIVE && core.state?.isNexrad) {
1873
- onInspect?.(getNexradInspectPayloadAtRef.current(longitude, latitude));
1874
- return;
1875
- }
1876
-
1877
- void getValueAtPointRef.current(longitude, latitude).then((payload) => {
1878
- onInspect?.(payload);
1879
- });
1880
- };
1881
-
1882
- core.on('map:move', handleMapMove);
1883
-
1884
- if (context && context.getCenter) {
1885
- const center = context.getCenter();
1886
- if (center) {
1887
- handleMapMove(center);
1888
- }
1889
- }
1890
-
1891
- return () => {
1892
- core.off('map:move', handleMapMove);
1893
- };
1894
- }, [inspectorEnabled, onInspect, core, context]);
1895
-
1896
- useEffect(() => {
1897
- if (!core || !inspectorEnabled) {
1898
- return;
1899
- }
1900
-
1901
- const triggerReinspection = () => {
1902
- const mapRef = mapRegistry.getMap();
1903
- const center = mapRef?._currentCenter;
1904
-
1905
- if (center && Array.isArray(center) && center.length === 2) {
1906
- const [longitude, latitude] = center;
1907
-
1908
- if (NEXRAD_NATIVE && core?.state?.isNexrad) {
1909
- onInspect?.(getNexradInspectPayloadAtRef.current(longitude, latitude));
1910
- } else {
1911
- getValueAtPointRef.current(longitude, latitude).then((payload) => {
1912
- onInspect?.(payload);
1913
- });
1914
- }
1915
- }
1916
- };
1917
-
1918
- // Small delay to ensure data is loaded before re-inspecting
1919
- const timer = setTimeout(triggerReinspection, 100);
1920
-
1921
- return () => clearTimeout(timer);
1922
- }, [
1923
- core?.state?.nexradTimestamp,
1924
- core?.state?.isNexrad,
1925
- core?.state?.nexradSite,
1926
- core?.state?.nexradProduct,
1927
- core?.state?.nexradTilt,
1928
- core?.state?.nexradDataSource,
1929
- core?.state?.nexradStormRelative,
1930
- core?.state?.variable,
1931
- core?.state?.model,
1932
- core?.state?.forecastHour,
1933
- core?.state?.mrmsTimestamp,
1934
- core?.state?.units,
1935
- core?.state?.opacity,
1936
- inspectorEnabled,
1937
- onInspect,
1938
- ]);
1939
-
1940
- useEffect(() => {
1941
- if (!core) {
1942
- return;
1943
- }
1944
-
1945
- const handleCameraChange = (center) => {
1946
- if (core && center) {
1947
- core.setMapCenter(center);
1948
- }
1949
- };
1950
-
1951
- // Register with the global registry
1952
- mapRegistry.addCameraListener(handleCameraChange);
1953
-
1954
- // Try to get initial center
1955
- const mapRef = mapRegistry.getMap();
1956
- if (mapRef?._currentCenter) {
1957
- handleCameraChange(mapRef._currentCenter);
1958
- }
1959
-
1960
- return () => {
1961
- mapRegistry.removeCameraListener(handleCameraChange);
1962
- };
1963
- }, [core]);
1964
-
1965
- useEffect(() => {
1966
- core.initialize({ autoRefresh: false }); // <-- add the argument
1967
- return () => {
1968
- core.destroy();
1969
- };
1970
- }, [core]);
1971
-
1972
- const gridBelowId =
1973
- belowIDFromProps ??
1974
- context?.weatherBeforeLayerId ??
1975
- 'AML_-_terrain';
1976
-
1977
- return (
1978
- <>
1979
- <GridRenderLayer
1980
- ref={gridLayerRef}
1981
- opacity={renderProps.opacity}
1982
- dataRange={renderProps.dataRange}
1983
- belowID={gridBelowId}
1984
- />
1985
- {NEXRAD_NATIVE ? <NexradRadarLayer ref={nexradLayerRef} belowID={gridBelowId} /> : null}
1986
- {SATELLITE_NATIVE ? <SatelliteLayer ref={satelliteLayerRef} belowID={gridBelowId} /> : null}
1987
- <NwsAlertsOverlay core={core} watchesWarnings={watchesWarningsOptions} onNwsAlertClick={onNwsAlertClick} />
1988
- {nexradSitesMapVisible ? (
1989
- <NexradSitesMapLayer
1990
- visible
1991
- belowLayerID={gridBelowId}
1992
- onSelectSite={(siteId) => void core.setNexradSite(siteId)}
1993
- />
1994
- ) : null}
1995
- </>
1996
- );
1997
- });
1998
-
1999
- WeatherLayerManager.getAvailableVariables = (options) => {
2000
- if (!options || !options.apiKey) {
2001
- return [];
2002
- }
2003
- const core = new AguaceroCore({ apiKey: options.apiKey });
2004
- return core.getAvailableVariables('mrms');
1
+ // packages/react-native/src/WeatherLayerManager.js
2
+
3
+ import {
4
+ AguaceroCore,
5
+ DICTIONARIES,
6
+ formatTimelineDurationValue,
7
+ getUnitConversionFunction,
8
+ parseTimelineDurationHours,
9
+ } from '@aguacerowx/javascript-sdk';
10
+ import { fromByteArray } from 'base64-js';
11
+ import React, { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
12
+ import { NativeModules, Platform, UIManager } from 'react-native';
13
+ import { AguaceroContext } from './AguaceroContext';
14
+ import { GridRenderLayer } from './GridRenderLayer';
15
+ import NexradRadarLayer from './NexradRadarLayer';
16
+ import SatelliteLayer from './SatelliteLayer';
17
+ import { NexradSitesMapLayer } from './NexradSitesMapLayer';
18
+ import { NexradAndroidController } from './nexrad/nexradAndroidController';
19
+ import { SatelliteAndroidController } from './satellite/satelliteAndroidController';
20
+ import NwsAlertsOverlay from './nws/NwsAlertsOverlay';
21
+ import { mapRegistry } from './MapRegistry';
22
+ import { satBridgeWarn } from './satelliteBridgeDiag';
23
+ import {
24
+ augmentProcessFrameOptionsForDebug,
25
+ aguaceroDebugWarn,
26
+ configureAguaceroRnDebug,
27
+ getAguaceroAuthDiagnosticSnapshot,
28
+ isAguaceroRnDebugEnabled,
29
+ } from './aguaceroRnDebug';
30
+ import { installAguaceroCoreDebugHooks, logProcessFrameAuthMismatch } from './aguaceroCoreDebugHooks';
31
+ import { resolveGridRequestSiteOrigin } from './gridCdnAuth';
32
+ import { auditSatelliteIntegration, installSatelliteDiagnosticListener } from './satelliteRnDebug';
33
+
34
+ const NEXRAD_NATIVE = Platform.OS === 'android' || Platform.OS === 'ios';
35
+ const SATELLITE_NATIVE = Platform.OS === 'android' || Platform.OS === 'ios';
36
+
37
+ satBridgeWarn('SDK fingerprint', {
38
+ platform: Platform.OS,
39
+ SATELLITE_NATIVE,
40
+ note: 'If you never see sat-bridge logs, the app bundle is not loading this WeatherLayerManager build.',
41
+ });
42
+
43
+ /**
44
+ * Same filtering as {@link AguaceroCore#_getFilteredMrmsTimestampsForVariable} for older cores
45
+ * where that helper (or {@code setMRMSDurationValue}) is missing from the prototype chain.
46
+ */
47
+ function getFilteredMrmsTimestampsCompat(core, variable) {
48
+ const raw = core.mrmsStatus?.[variable];
49
+ if (!raw || !raw.length) return [];
50
+ const hours = parseTimelineDurationHours(core.state.mrmsDurationValue);
51
+ let list = [...raw]
52
+ .map((t) => Number(t))
53
+ .filter((t) => !Number.isNaN(t))
54
+ .sort((a, b) => a - b);
55
+ if (hours > 0 && list.length > 0) {
56
+ const latest = list[list.length - 1];
57
+ const cutoff = latest - hours * 3600;
58
+ list = list.filter((t) => t >= cutoff);
59
+ }
60
+ return list;
61
+ }
62
+
63
+ /**
64
+ * Older npm installs may resolve an {@link AguaceroCore} without {@code setMRMSDurationValue} /
65
+ * {@code setNexradDurationValue}; mirror those methods here using the same logic as the SDK.
66
+ */
67
+ async function applyMrmsDurationValue(core, value) {
68
+ if (typeof core.setMRMSDurationValue === 'function') {
69
+ await core.setMRMSDurationValue(value);
70
+ return;
71
+ }
72
+ const v = formatTimelineDurationValue(value);
73
+ await core.setState({ mrmsDurationValue: v });
74
+ if (!core.state.isMRMS || !core.state.variable) return;
75
+ const filtered =
76
+ typeof core._getFilteredMrmsTimestampsForVariable === 'function'
77
+ ? core._getFilteredMrmsTimestampsForVariable(core.state.variable)
78
+ : getFilteredMrmsTimestampsCompat(core, core.state.variable);
79
+ if (!filtered || filtered.length === 0) return;
80
+ const curN = core.state.mrmsTimestamp == null ? null : Number(core.state.mrmsTimestamp);
81
+ if (curN == null || !filtered.includes(curN)) {
82
+ await core.setState({ mrmsTimestamp: filtered[filtered.length - 1] });
83
+ }
84
+ }
85
+
86
+ async function applyNexradDurationValue(core, value) {
87
+ if (typeof core.setNexradDurationValue === 'function') {
88
+ await core.setNexradDurationValue(value);
89
+ return;
90
+ }
91
+ const v = formatTimelineDurationValue(value);
92
+ await core.setState({ nexradDurationValue: v });
93
+ if (!core.state.isNexrad || !core.state.nexradSite) return;
94
+ if (typeof core.refreshNexradTimes === 'function') {
95
+ await core.refreshNexradTimes();
96
+ }
97
+ const nk = typeof core._nexradTimesCacheKey === 'function' ? core._nexradTimesCacheKey() : null;
98
+ const raw = nk ? core.nexradTimesByStation?.[nk]?.unixTimes || [] : [];
99
+ const filtered =
100
+ typeof core._getFilteredNexradTimestampsForVariable === 'function'
101
+ ? core._getFilteredNexradTimestampsForVariable(raw)
102
+ : [...raw].map((t) => Number(t)).filter((t) => !Number.isNaN(t)).sort((a, b) => a - b);
103
+ if (!filtered || filtered.length === 0) return;
104
+ const curN = core.state.nexradTimestamp == null ? null : Number(core.state.nexradTimestamp);
105
+ if (curN == null || !filtered.includes(curN)) {
106
+ await core.setState({ nexradTimestamp: filtered[filtered.length - 1] });
107
+ }
108
+ }
109
+
110
+ function findLatestModelRun(modelsData, modelName) {
111
+ const model = modelsData?.[modelName];
112
+ if (!model) return null;
113
+ const availableDates = Object.keys(model).sort((a, b) => b.localeCompare(a));
114
+ for (const date of availableDates) {
115
+ const runs = model[date];
116
+ if (!runs) continue;
117
+ const availableRuns = Object.keys(runs).sort((a, b) => b.localeCompare(a));
118
+ if (availableRuns.length > 0) return { date: date, run: availableRuns[0] };
119
+ }
120
+ return null;
121
+ }
122
+ const { WeatherFrameProcessorModule, InspectorModule } = NativeModules;
123
+
124
+ /**
125
+ * `state:change` payloads can briefly have {@code isNexrad} before {@code nexradSite} / {@code nexradTimestamp}
126
+ * are populated; {@code core.state} is updated first. Merge so readouts match the active radar.
127
+ *
128
+ * @param {object | null} emitted
129
+ * @param {object | null} coreState
130
+ */
131
+ function mergeNexradEmittedWithCore(emitted, coreState) {
132
+ if (!emitted || !coreState) return null;
133
+ return {
134
+ ...emitted,
135
+ nexradSite: coreState.nexradSite ?? emitted.nexradSite,
136
+ nexradTimestamp: coreState.nexradTimestamp ?? emitted.nexradTimestamp,
137
+ nexradProduct: coreState.nexradProduct ?? emitted.nexradProduct,
138
+ nexradTilt: coreState.nexradTilt ?? emitted.nexradTilt,
139
+ nexradDataSource: coreState.nexradDataSource ?? emitted.nexradDataSource,
140
+ nexradStormRelative: coreState.nexradStormRelative ?? emitted.nexradStormRelative,
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Compact timeline identity for deduping {@code state:change}: extending the NEXRAD/satellite window
146
+ * often keeps the same selected unix — without this, {@link WeatherLayerManager} short-circuits and
147
+ * never calls native {@code sync} / preload with the expanded frame list.
148
+ */
149
+ function nexradObsTimelineSig(state) {
150
+ const arr = [...(state?.availableNexradTimestamps || [])]
151
+ .map(Number)
152
+ .filter((t) => Number.isFinite(t))
153
+ .sort((a, b) => a - b);
154
+ if (!arr.length) return '0';
155
+ return `${arr.length}:${arr[0]}:${arr[arr.length - 1]}`;
156
+ }
157
+
158
+ function satelliteObsTimelineSig(state) {
159
+ const keys = Object.keys(state?.satelliteTimeToFileMap || {})
160
+ .map(Number)
161
+ .filter((t) => Number.isFinite(t))
162
+ .sort((a, b) => a - b);
163
+ if (!keys.length) return '0';
164
+ return `${keys.length}:${keys[0]}:${keys[keys.length - 1]}`;
165
+ }
166
+
167
+ /** True when {@link WeatherLayerManager} `debug` / {@link configureAguaceroRnDebug}, or legacy `__AGUACERO_WX_GRID_DEBUG__`. */
168
+ function wxGridDebugEnabled() {
169
+ if (isAguaceroRnDebugEnabled()) return true;
170
+ try {
171
+ if (typeof __DEV__ !== 'undefined' && __DEV__) return true;
172
+ return Boolean(typeof globalThis !== 'undefined' && globalThis.__AGUACERO_WX_GRID_DEBUG__);
173
+ } catch {
174
+ return false;
175
+ }
176
+ }
177
+
178
+ function wxGridVerbose(tag, detail) {
179
+ if (!wxGridDebugEnabled()) return;
180
+ if (detail !== undefined) {
181
+ console.log(`[AguaceroWX][grid][${tag}]`, detail);
182
+ } else {
183
+ console.log(`[AguaceroWX][grid][${tag}]`);
184
+ }
185
+ }
186
+
187
+ /** Always logs (Metro / device logs) — use for failures and unusual early exits. */
188
+ function wxGridWarn(tag, detail) {
189
+ if (detail !== undefined) {
190
+ console.warn(`[AguaceroWX][grid][${tag}]`, detail);
191
+ } else {
192
+ console.warn(`[AguaceroWX][grid][${tag}]`);
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Native {@code processFrame} must mirror browser/AguaceroCore grid auth: encoded {@code apiKey}
198
+ * in the query string and optional {@code Origin} / {@code Referer} (many CloudFront setups require them).
199
+ *
200
+ * @param {string} baseGridUrl
201
+ * @param {string} resourcePath
202
+ * @param {string} apiKey
203
+ * @param {string | null | undefined} bundleId
204
+ * @param {string | undefined} gridRequestSiteOrigin - prop or core origin (see {@link resolveGridRequestSiteOrigin})
205
+ * @param {import('@aguacerowx/javascript-sdk').AguaceroCore | null | undefined} core
206
+ */
207
+ function buildGridFrameProcessOptions(baseGridUrl, resourcePath, apiKey, bundleId, gridRequestSiteOrigin, core) {
208
+ const trimmedKey = typeof apiKey === 'string' ? apiKey.trim() : apiKey;
209
+ const url = `${baseGridUrl}${resourcePath}?apiKey=${encodeURIComponent(trimmedKey || '')}`;
210
+ const options = { url, apiKey: trimmedKey, bundleId };
211
+ const origin = resolveGridRequestSiteOrigin(gridRequestSiteOrigin, core);
212
+ if (origin) {
213
+ options.gridRequestSiteOrigin = origin;
214
+ }
215
+ return options;
216
+ }
217
+
218
+ /**
219
+ * A helper function to generate the raw RGBA byte buffer for the colormap texture.
220
+ */
221
+ const _generateColormapBytes = (colormap) => {
222
+ const width = 256;
223
+ const data = new Uint8Array(width * 4);
224
+ const stops = colormap.reduce((acc, _, i) => (i % 2 === 0 ? [...acc, { value: colormap[i], color: colormap[i + 1] }] : acc), []);
225
+
226
+ if (stops.length === 0) return data;
227
+
228
+ const minVal = stops[0].value;
229
+ const maxVal = stops[stops.length - 1].value;
230
+
231
+ const hexToRgb = (hex) => {
232
+ const r = parseInt(hex.slice(1, 3), 16);
233
+ const g = parseInt(hex.slice(3, 5), 16);
234
+ const b = parseInt(hex.slice(5, 7), 16);
235
+ return [r, g, b];
236
+ };
237
+
238
+ for (let i = 0; i < width; i++) {
239
+ const val = minVal + (i / (width - 1)) * (maxVal - minVal);
240
+ let lower = stops[0];
241
+ let upper = stops[stops.length - 1];
242
+ for (let j = 0; j < stops.length - 1; j++) {
243
+ if (val >= stops[j].value && val <= stops[j + 1].value) {
244
+ lower = stops[j];
245
+ upper = stops[j + 1];
246
+ break;
247
+ }
248
+ }
249
+ const t = (val - lower.value) / (upper.value - lower.value || 1);
250
+ const lowerRgb = hexToRgb(lower.color);
251
+ const upperRgb = hexToRgb(upper.color);
252
+ const rgb = lowerRgb.map((c, idx) => c * (1 - t) + upperRgb[idx] * t);
253
+
254
+ const offset = i * 4;
255
+ data[offset + 0] = Math.round(rgb[0]);
256
+ data[offset + 1] = Math.round(rgb[1]);
257
+ data[offset + 2] = Math.round(rgb[2]);
258
+ data[offset + 3] = 255;
259
+ }
260
+ return data;
261
+ };
262
+
263
+ AguaceroCore.prototype.setMapCenter = function (center) {
264
+ this.emit('map:move', center);
265
+ };
266
+
267
+ export const WeatherLayerManager = forwardRef((props, ref) => {
268
+ const {
269
+ inspectorEnabled,
270
+ onInspect,
271
+ apiKey,
272
+ customColormaps,
273
+ initialMode,
274
+ initialVariable,
275
+ /** GOES instrument when {@link initialMode} is `"satellite"` (default `GOES19-EAST`). */
276
+ initialSatelliteId,
277
+ /** Sector token or label when {@link initialMode} is `"satellite"` (default `conus` → GOES-EAST CONUS). */
278
+ initialSatelliteSector,
279
+ /** Product/band when {@link initialMode} is `"satellite"` (e.g. `geocolor`, `C13`). Falls back to {@link initialVariable}. */
280
+ initialSatelliteProduct,
281
+ autoRefresh,
282
+ autoRefreshInterval,
283
+ initialModel,
284
+ belowID: belowIDFromProps,
285
+ interpolateNexradColormap = true,
286
+ nexradGateSmoothing = false,
287
+ watchesWarnings: watchesWarningsProp,
288
+ onNwsAlertClick,
289
+ /** Same semantics as mapsgl / AguaceroCore: Origin + Referer for grid CDN (required by many CloudFront rules). */
290
+ gridRequestSiteOrigin,
291
+ /** When true, logs auth/HTTP diagnostics under `[AguaceroRN][debug]` (Metro / Logcat / Xcode). */
292
+ debug = false,
293
+ ...restProps
294
+ } = props;
295
+
296
+ useEffect(() => {
297
+ configureAguaceroRnDebug({ enabled: Boolean(debug) });
298
+ }, [debug]);
299
+
300
+ useEffect(() => {
301
+ if (!debug) return undefined;
302
+ auditSatelliteIntegration({ core, satelliteLayerRef });
303
+ return installSatelliteDiagnosticListener();
304
+ }, [debug, core]);
305
+ const context = useContext(AguaceroContext);
306
+
307
+ // Create the core here instead of getting it from context
308
+ const core = useMemo(
309
+ () =>
310
+ new AguaceroCore({
311
+ apiKey: apiKey,
312
+ customColormaps: customColormaps,
313
+ gridRequestSiteOrigin: gridRequestSiteOrigin,
314
+ layerOptions: {
315
+ mode: initialMode,
316
+ variable: initialVariable,
317
+ model: initialModel,
318
+ ...(initialMode === 'satellite'
319
+ ? {
320
+ satelliteId: initialSatelliteId,
321
+ sector: initialSatelliteSector,
322
+ satelliteProduct: initialSatelliteProduct ?? initialVariable,
323
+ }
324
+ : {}),
325
+ },
326
+ autoRefresh: false, // <-- add this
327
+ }),
328
+ [apiKey, gridRequestSiteOrigin],
329
+ );
330
+
331
+ useEffect(() => {
332
+ if (!core) return;
333
+ installAguaceroCoreDebugHooks(core, {
334
+ gridRequestSiteOriginProp: gridRequestSiteOrigin ?? null,
335
+ debugProp: Boolean(debug),
336
+ });
337
+ if (isAguaceroRnDebugEnabled() && !apiKey) {
338
+ aguaceroDebugWarn('WeatherLayerManager.missingApiKey', {
339
+ hint: 'apiKey prop is empty — all CDN requests will fail or return 403',
340
+ });
341
+ }
342
+ if (isAguaceroRnDebugEnabled() && apiKey && !gridRequestSiteOrigin) {
343
+ aguaceroDebugWarn('WeatherLayerManager.missingGridOrigin', {
344
+ hint: 'gridRequestSiteOrigin is not set — CloudFront often returns 403 without Origin/Referer on React Native',
345
+ snapshot: getAguaceroAuthDiagnosticSnapshot(core),
346
+ });
347
+ }
348
+ }, [core, debug, gridRequestSiteOrigin, apiKey]);
349
+
350
+ const [watchesWarningsOptions, setWatchesWarningsOptions] = useState(() => ({
351
+ alertInteractionEnabled: true,
352
+ ...(watchesWarningsProp ?? {}),
353
+ }));
354
+
355
+ useEffect(() => {
356
+ setWatchesWarningsOptions((prev) => ({
357
+ ...prev,
358
+ ...(watchesWarningsProp ?? {}),
359
+ }));
360
+ }, [watchesWarningsProp]);
361
+
362
+ useEffect(() => {
363
+ setNexradSitesMapVisible(Boolean(core.state.isNexrad && core.state.nexradShowSitesPicker !== false));
364
+ }, [core]);
365
+
366
+ const gridLayerRef = useRef(null);
367
+ const nexradLayerRef = useRef(null);
368
+ const nexradControllerRef = useRef(null);
369
+ /** Latest {@code state:change} payload (includes {@code colormap}, NEXRAD maps) — required for readouts; {@code core.state} omits those. */
370
+ const lastEmittedStateForInspectRef = useRef(null);
371
+ const satelliteLayerRef = useRef(null);
372
+ const satelliteControllerRef = useRef(null);
373
+ /** @type {React.MutableRefObject<{ cancel?: () => void } | null>} */
374
+ const nexradPreloadInteractionRef = useRef(null);
375
+ const currentGridDataRef = useRef(null);
376
+ const autoRefreshIntervalId = useRef(null);
377
+
378
+ const ensureNexradController = useCallback(() => {
379
+ if (!NEXRAD_NATIVE) return null;
380
+ if (!nexradControllerRef.current) {
381
+ nexradControllerRef.current = new NexradAndroidController(core, nexradLayerRef, {
382
+ interpolateNexradColormap,
383
+ nexradGateSmoothing,
384
+ });
385
+ }
386
+ return nexradControllerRef.current;
387
+ }, [core, interpolateNexradColormap, nexradGateSmoothing]);
388
+
389
+ /** Synchronous NEXRAD readout (mapsgl-style); no await — avoids jank on map move. */
390
+ const getNexradInspectPayloadAt = useCallback(
391
+ (lng, lat) => {
392
+ if (!NEXRAD_NATIVE || !core?.state?.isNexrad) return null;
393
+ const ctl = ensureNexradController();
394
+ if (!ctl) {
395
+ return null;
396
+ }
397
+ const emitted = lastEmittedStateForInspectRef.current;
398
+ if (!emitted) {
399
+ return null;
400
+ }
401
+ const st = mergeNexradEmittedWithCore(emitted, core.state);
402
+ if (!st?.nexradSite || st.nexradTimestamp == null) {
403
+ return null;
404
+ }
405
+ return ctl.getInspectPayload(lng, lat, st) ?? null;
406
+ },
407
+ [core, ensureNexradController],
408
+ );
409
+
410
+ const ensureSatelliteController = useCallback(() => {
411
+ if (!SATELLITE_NATIVE) return null;
412
+ if (!satelliteControllerRef.current) {
413
+ satelliteControllerRef.current = new SatelliteAndroidController(core, satelliteLayerRef);
414
+ }
415
+ return satelliteControllerRef.current;
416
+ }, [core]);
417
+
418
+ useEffect(() => {
419
+ if (!SATELLITE_NATIVE) return;
420
+ let gridCmdKeys = [];
421
+ let satCmdKeys = [];
422
+ try {
423
+ gridCmdKeys = Object.keys(UIManager.getViewManagerConfig?.('GridRenderLayer')?.Commands ?? {});
424
+ } catch {
425
+ gridCmdKeys = ['error'];
426
+ }
427
+ try {
428
+ satCmdKeys = Object.keys(UIManager.getViewManagerConfig?.('SatelliteLayer')?.Commands ?? {});
429
+ } catch {
430
+ satCmdKeys = ['error'];
431
+ }
432
+ satBridgeWarn('mount UIManager command registration', {
433
+ GridRenderLayerCommands: gridCmdKeys,
434
+ SatelliteLayerCommands: satCmdKeys,
435
+ satelliteMissing: satCmdKeys.length === 0,
436
+ });
437
+ }, []);
438
+
439
+ useEffect(() => {
440
+ const ctl = nexradControllerRef.current;
441
+ if (NEXRAD_NATIVE && ctl) {
442
+ ctl.updateStyleOptions({
443
+ interpolateNexradColormap,
444
+ nexradGateSmoothing,
445
+ });
446
+ if (core?.state?.isNexrad) {
447
+ ctl.applyStyleFromState(core.state);
448
+ }
449
+ }
450
+ }, [interpolateNexradColormap, nexradGateSmoothing, core]);
451
+
452
+ // Cache for preloaded grid data - stores the processed data ready for GPU upload
453
+ const preloadedDataCache = useRef(new Map());
454
+
455
+ // Store geometry and colormap that don't change with forecast hour
456
+ const cachedGeometry = useRef(null);
457
+ const cachedColormap = useRef(null);
458
+ const cachedDataRange = useRef([0, 1]);
459
+
460
+ // Track if we've done the initial load
461
+ const hasInitialLoad = useRef(false);
462
+ const hasPreloadedRef = useRef(false);
463
+ /** Bumped on {@code cancelAllFrames} / full reload so stale {@code processFrame} results are ignored. */
464
+ const preloadGenerationRef = useRef(0);
465
+
466
+ // Track the last state we processed to avoid redundant updates
467
+ const lastProcessedState = useRef(null);
468
+ const previousStateRef = useRef(null);
469
+
470
+ const [renderProps, setRenderProps] = useState({
471
+ opacity: 1,
472
+ dataRange: [0, 1]
473
+ });
474
+ /** Drives {@link NexradSitesMapLayer}; must re-render when core NEXRAD / picker flags change. */
475
+ const [nexradSitesMapVisible, setNexradSitesMapVisible] = useState(false);
476
+
477
+ useImperativeHandle(ref, () => {
478
+ const setAutoRefresh = (enabled, intervalSeconds) => {
479
+ if (autoRefreshIntervalId.current) {
480
+ clearInterval(autoRefreshIntervalId.current);
481
+ autoRefreshIntervalId.current = null;
482
+ }
483
+ if (enabled) {
484
+ const effectiveInterval = (intervalSeconds || autoRefreshInterval || 30) * 1000;
485
+ // Run once immediately, then start the interval
486
+ _checkForUpdates();
487
+ autoRefreshIntervalId.current = setInterval(_checkForUpdates, effectiveInterval);
488
+ }
489
+ };
490
+ return {
491
+ play: () => {
492
+ core.play();
493
+ },
494
+ pause: () => {
495
+ core.pause();
496
+ },
497
+ togglePlay: () => {
498
+ core.togglePlay();
499
+ },
500
+ step: (direction) => {
501
+ core.step(direction);
502
+ },
503
+ setPlaybackSpeed: (speed) => {
504
+ if (speed > 0) {
505
+ core.playbackSpeed = speed;
506
+ if (core.isPlaying) {
507
+ core.pause();
508
+ core.play();
509
+ }
510
+ }
511
+ },
512
+ setOpacity: (opacity) => core.setOpacity(opacity),
513
+ setUnits: (units) => core.setUnits(units),
514
+ switchMode: (options) => core.switchMode(options),
515
+ getAvailableVariables: (model) => core.getAvailableVariables(model),
516
+ getVariableDisplayName: (code) => core.getVariableDisplayName(code),
517
+ setRun: (runString) => core.setState({ run: runString.split(':')[1] }),
518
+ setState: (newState) => core.setState(newState),
519
+ setMRMSTimestamp: (timestamp) => core.setMRMSTimestamp(timestamp),
520
+ setMRMSDurationValue: (value) => applyMrmsDurationValue(core, value),
521
+ setNexradSite: (siteId) => core.setNexradSite(siteId),
522
+ setNexradProduct: (product) => core.setNexradProduct(product),
523
+ setNexradTilt: (tilt) => core.setNexradTilt(tilt),
524
+ setNexradStormRelative: (enabled) => core.setNexradStormRelative(enabled),
525
+ setNexradTimestamp: (ts) => core.setNexradTimestamp(ts),
526
+ setNexradDurationValue: (value) => applyNexradDurationValue(core, value),
527
+ setSatelliteTimestamp: (timestamp) => core.setSatelliteTimestamp(timestamp),
528
+ setSatelliteDurationValue: (value) => core.setSatelliteDurationValue(value),
529
+ setSatelliteSelection: (opts) => core.setSatelliteSelection(opts),
530
+ setShaderSmoothing: async (enabled) => {
531
+ await core.setShaderSmoothing(enabled);
532
+ if (gridLayerRef.current) {
533
+ gridLayerRef.current.setSmoothing(enabled);
534
+ }
535
+ },
536
+ setSmoothing: (enabled) => {
537
+ if (gridLayerRef.current) {
538
+ gridLayerRef.current.setSmoothing(enabled);
539
+ }
540
+ },
541
+ setAutoRefresh,
542
+ refreshData: () => {
543
+ _checkForUpdates();
544
+ },
545
+ /**
546
+ * NWS watches/warnings (native map, iOS + Android): same options as mapsgl {@link WeatherLayerManager#configureWatchesWarnings}.
547
+ * @param {object} partial
548
+ */
549
+ configureWatchesWarnings: (partial) => {
550
+ setWatchesWarningsOptions((prev) => ({ ...prev, ...partial }));
551
+ },
552
+ };
553
+ }, [core, autoRefreshInterval, _checkForUpdates]);
554
+
555
+ const preloadAllFramesToDisk = async (state) => {
556
+ if (state.isNexrad || state.isSatellite) {
557
+ hasPreloadedRef.current = false;
558
+ wxGridVerbose('preloadSkip', { reason: 'nexrad_or_satellite' });
559
+ return;
560
+ }
561
+
562
+ if (hasPreloadedRef.current) {
563
+ wxGridVerbose('preloadSkip', { reason: 'hasPreloadedRef_gate', isMRMS: state.isMRMS, variable: state.variable });
564
+ return;
565
+ }
566
+
567
+ const { isMRMS, model, date, run, variable, units, availableHours, availableTimestamps, forecastHour, mrmsTimestamp } = state;
568
+
569
+ // CRITICAL: Don't start preloading if we don't have a valid current frame
570
+ if (isMRMS && (mrmsTimestamp == null || !availableTimestamps || availableTimestamps.length === 0)) {
571
+ hasPreloadedRef.current = false;
572
+ wxGridWarn('preloadAbort', {
573
+ reason: 'mrms_missing_frame_or_timestamps',
574
+ mrmsTimestamp,
575
+ timestampCount: availableTimestamps?.length ?? 0,
576
+ variable,
577
+ });
578
+ return;
579
+ }
580
+
581
+ if (!isMRMS && (forecastHour == null || !availableHours || availableHours.length === 0)) {
582
+ hasPreloadedRef.current = false;
583
+ wxGridWarn('preloadAbort', {
584
+ reason: 'model_missing_hour_or_hours',
585
+ forecastHour,
586
+ hourCount: availableHours?.length ?? 0,
587
+ model,
588
+ variable,
589
+ });
590
+ return;
591
+ }
592
+
593
+ // Only mark as "has preloaded" after validation passes
594
+ hasPreloadedRef.current = true;
595
+ const generation = preloadGenerationRef.current;
596
+ const isStaleGeneration = () => generation !== preloadGenerationRef.current;
597
+
598
+ wxGridVerbose('preloadStart', {
599
+ isMRMS,
600
+ model,
601
+ date,
602
+ run,
603
+ variable,
604
+ mrmsTimestamp: isMRMS ? mrmsTimestamp : undefined,
605
+ forecastHour: !isMRMS ? forecastHour : undefined,
606
+ baseGridUrl: core?.baseGridUrl,
607
+ hasApiKey: Boolean(core?.apiKey),
608
+ bundleId: core?.bundleId ?? null,
609
+ gridRequestSiteOrigin: gridRequestSiteOrigin || null,
610
+ });
611
+
612
+ // Fix the current forecast hour if it's invalid for this variable/model combo
613
+ let effectiveForecastHour = forecastHour;
614
+ if (!isMRMS && variable === 'ptypeRefl' && model === 'hrrr' && forecastHour === 0) {
615
+ const validHours = availableHours.filter(hour => hour !== 0);
616
+ effectiveForecastHour = validHours.length > 0 ? validHours[0] : 0;
617
+ }
618
+
619
+ if (!cachedGeometry.current || !cachedColormap.current) {
620
+ const gridModel = isMRMS ? 'mrms' : model;
621
+ const { corners, gridDef } = core._getGridCornersAndDef(gridModel);
622
+ gridLayerRef.current.updateGeometry(corners, gridDef);
623
+ cachedGeometry.current = { model: gridModel, variable };
624
+
625
+ wxGridVerbose('geometryColormapInit', {
626
+ gridModel,
627
+ variable,
628
+ nx: gridDef?.grid_params?.nx,
629
+ ny: gridDef?.grid_params?.ny,
630
+ });
631
+
632
+ const { colormap, baseUnit } = core._getColormapForVariable(variable);
633
+ const toUnit = core._getTargetUnit(baseUnit, units);
634
+ const finalColormap = core._convertColormapUnits(colormap, baseUnit, toUnit);
635
+ let dataRange;
636
+ if (variable === 'ptypeRefl' || variable === 'ptypeRate') {
637
+ dataRange = isMRMS ? [5, 380] : [5, 380];
638
+ } else {
639
+ dataRange = [finalColormap[0], finalColormap[finalColormap.length - 2]];
640
+ }
641
+ const colormapBytes = _generateColormapBytes(finalColormap);
642
+ const colormapAsBase64 = fromByteArray(colormapBytes);
643
+
644
+ gridLayerRef.current.updateColormapTexture(colormapAsBase64);
645
+ cachedColormap.current = { key: `${variable}-${units}` };
646
+ cachedDataRange.current = dataRange;
647
+
648
+ setRenderProps({ opacity: state.opacity, dataRange: dataRange });
649
+ hasInitialLoad.current = true;
650
+ }
651
+
652
+ // Apply the same filtering logic as in AguaceroCore._emitStateChange
653
+ let filteredHours = availableHours;
654
+ if (!isMRMS && variable === 'ptypeRefl' && model === 'hrrr' && availableHours && availableHours.length > 0) {
655
+ filteredHours = availableHours.filter(hour => hour !== 0);
656
+ }
657
+
658
+ const allFrames = isMRMS ? availableTimestamps : filteredHours;
659
+ if (!allFrames || allFrames.length === 0) {
660
+ hasPreloadedRef.current = false;
661
+ wxGridWarn('preloadAbort', { reason: 'no_frames_after_filter', isMRMS, variable });
662
+ return;
663
+ }
664
+
665
+ const currentFrame = isMRMS ? mrmsTimestamp : effectiveForecastHour;
666
+
667
+ // Double-check currentFrame is valid
668
+ if (currentFrame == null) {
669
+ hasPreloadedRef.current = false;
670
+ wxGridWarn('preloadAbort', { reason: 'currentFrame_null', isMRMS, variable });
671
+ return;
672
+ }
673
+
674
+ // Reverse the frame order to load from last to first
675
+ const reversedFrames = [...allFrames].reverse();
676
+ const framesToPreload = reversedFrames.filter(frame => frame !== currentFrame);
677
+
678
+ const { corners, gridDef } = core._getGridCornersAndDef(isMRMS ? 'mrms' : model);
679
+ const { nx, ny } = gridDef.grid_params;
680
+
681
+ // Load the current frame FIRST and WAIT for it before continuing
682
+ const currentCacheKey = isMRMS ? `mrms-${currentFrame}-${variable}` : `${model}-${date}-${run}-${currentFrame}-${variable}`;
683
+
684
+ if (!preloadedDataCache.current.has(currentCacheKey)) {
685
+ let resourcePath;
686
+ if (isMRMS) {
687
+ const frameDate = new Date(currentFrame * 1000);
688
+ const y = frameDate.getUTCFullYear();
689
+ const m = (frameDate.getUTCMonth() + 1).toString().padStart(2, '0');
690
+ const d = frameDate.getUTCDate().toString().padStart(2, '0');
691
+ resourcePath = `/grids/mrms/${y}${m}${d}/${currentFrame}/0/${variable}/0`;
692
+ } else {
693
+ resourcePath = `/grids/${model}/${date}/${run}/${currentFrame}/${variable}/0`;
694
+ }
695
+
696
+ const options = augmentProcessFrameOptionsForDebug(
697
+ buildGridFrameProcessOptions(
698
+ core.baseGridUrl,
699
+ resourcePath,
700
+ core.apiKey,
701
+ core.bundleId,
702
+ gridRequestSiteOrigin,
703
+ core,
704
+ ),
705
+ core,
706
+ );
707
+ logProcessFrameAuthMismatch(core, options, { phase: 'preloadCurrent', currentCacheKey });
708
+
709
+ try {
710
+ wxGridVerbose('processFrameRequest', { currentCacheKey, resourcePath, frame: currentFrame });
711
+ const result = await WeatherFrameProcessorModule.processFrame(options);
712
+
713
+ if (isStaleGeneration()) {
714
+ wxGridVerbose('preloadCurrentFrameStale', { currentCacheKey, generation });
715
+ return;
716
+ }
717
+
718
+ if (!result || !result.filePath) {
719
+ hasPreloadedRef.current = false;
720
+ wxGridWarn('preloadAbort', {
721
+ reason: 'processFrame_empty_result',
722
+ currentCacheKey,
723
+ resultKeys: result ? Object.keys(result) : [],
724
+ });
725
+ return;
726
+ }
727
+
728
+ const { baseUnit } = core._getColormapForVariable(variable);
729
+
730
+ const toUnit = core._getTargetUnit(baseUnit, units);
731
+
732
+ const fieldInfo = DICTIONARIES?.fld?.[variable] || {};
733
+ const serverDataUnit = fieldInfo.defaultUnit || baseUnit;
734
+
735
+ let dataScale = result.scale;
736
+ let dataOffset = result.offset;
737
+
738
+ let convertedScale = dataScale;
739
+ let convertedOffset = dataOffset;
740
+
741
+ if (serverDataUnit !== baseUnit) {
742
+ const conversionFunc = getUnitConversionFunction(serverDataUnit, baseUnit);
743
+ if (conversionFunc) {
744
+ if (result.scaleType === 'sqrt') {
745
+ const physicalAtOffset = dataOffset * dataOffset;
746
+ const physicalAtOffsetPlusScale = (dataOffset + dataScale) * (dataOffset + dataScale);
747
+ const convertedPhysicalAtOffset = conversionFunc(physicalAtOffset);
748
+ const convertedPhysicalAtOffsetPlusScale = conversionFunc(physicalAtOffsetPlusScale);
749
+ convertedOffset = Math.sqrt(Math.abs(convertedPhysicalAtOffset)) * Math.sign(convertedPhysicalAtOffset);
750
+ const newOffsetPlusScale = Math.sqrt(Math.abs(convertedPhysicalAtOffsetPlusScale)) * Math.sign(convertedPhysicalAtOffsetPlusScale);
751
+ convertedScale = newOffsetPlusScale - convertedOffset;
752
+ } else {
753
+ convertedOffset = conversionFunc(dataOffset);
754
+ const convertedOffsetPlusScale = conversionFunc(dataOffset + dataScale);
755
+ convertedScale = convertedOffsetPlusScale - convertedOffset;
756
+ }
757
+ dataScale = convertedScale;
758
+ dataOffset = convertedOffset;
759
+ }
760
+ }
761
+
762
+ if (baseUnit !== toUnit) {
763
+ const conversionFunc = getUnitConversionFunction(baseUnit, toUnit);
764
+ if (conversionFunc) {
765
+ if (result.scaleType === 'sqrt') {
766
+ const physicalAtOffset = dataOffset * dataOffset;
767
+ const physicalAtOffsetPlusScale = (dataOffset + dataScale) * (dataOffset + dataScale);
768
+ const convertedPhysicalAtOffset = conversionFunc(physicalAtOffset);
769
+ const convertedPhysicalAtOffsetPlusScale = conversionFunc(physicalAtOffsetPlusScale);
770
+ convertedOffset = Math.sqrt(Math.abs(convertedPhysicalAtOffset)) * Math.sign(convertedPhysicalAtOffset);
771
+ const newOffsetPlusScale = Math.sqrt(Math.abs(convertedPhysicalAtOffsetPlusScale)) * Math.sign(convertedPhysicalAtOffsetPlusScale);
772
+ convertedScale = newOffsetPlusScale - convertedOffset;
773
+ } else {
774
+ convertedOffset = conversionFunc(dataOffset);
775
+ const convertedOffsetPlusScale = conversionFunc(dataOffset + dataScale);
776
+ convertedScale = convertedOffsetPlusScale - convertedOffset;
777
+ }
778
+ }
779
+ }
780
+
781
+ const frameData = {
782
+ filePath: result.filePath,
783
+ nx, ny,
784
+ scale: convertedScale,
785
+ offset: convertedOffset,
786
+ missing: result.missing,
787
+ corners,
788
+ gridDef,
789
+ scaleType: result.scaleType,
790
+ originalScale: result.scale,
791
+ originalOffset: result.offset
792
+ };
793
+
794
+ preloadedDataCache.current.set(currentCacheKey, frameData);
795
+
796
+ // Update the GPU with the current frame
797
+ gridLayerRef.current.updateDataTextureFromFile(
798
+ frameData.filePath,
799
+ frameData.nx, frameData.ny,
800
+ frameData.scale, frameData.offset, frameData.missing,
801
+ frameData.scaleType
802
+ );
803
+
804
+ currentGridDataRef.current = {
805
+ nx: frameData.nx,
806
+ ny: frameData.ny,
807
+ scale: frameData.scale,
808
+ offset: frameData.offset,
809
+ missing: frameData.missing,
810
+ gridDef: frameData.gridDef,
811
+ variable: variable,
812
+ units: units,
813
+ scaleType: frameData.scaleType
814
+ };
815
+
816
+ wxGridVerbose('preloadCurrentFrameOk', {
817
+ currentCacheKey,
818
+ nx: frameData.nx,
819
+ ny: frameData.ny,
820
+ scale: frameData.scale,
821
+ offset: frameData.offset,
822
+ filePathSuffix: String(frameData.filePath).split('/').pop(),
823
+ });
824
+ } catch (e) {
825
+ const cancelled =
826
+ e &&
827
+ (e.code === 'E_CANCELLED' ||
828
+ e?.userInfo?.code === 'E_CANCELLED' ||
829
+ (typeof e?.message === 'string' && e.message.includes('superseded')));
830
+ if (!cancelled) {
831
+ hasPreloadedRef.current = false;
832
+ const errDetail = {
833
+ currentCacheKey,
834
+ code: e?.code,
835
+ message: e?.message,
836
+ userInfo: e?.userInfo,
837
+ };
838
+ wxGridWarn('preloadProcessFrameError', errDetail);
839
+ if (isAguaceroRnDebugEnabled()) {
840
+ aguaceroDebugWarn('preloadProcessFrameError', {
841
+ ...errDetail,
842
+ auth: getAguaceroAuthDiagnosticSnapshot(core),
843
+ is403:
844
+ e?.code === 'HTTP_ERROR' &&
845
+ typeof e?.message === 'string' &&
846
+ e.message.includes('403'),
847
+ });
848
+ }
849
+ } else {
850
+ wxGridVerbose('preloadProcessFrameCancelled', { currentCacheKey });
851
+ }
852
+ }
853
+ }
854
+
855
+ // NOW preload the rest of the frames asynchronously
856
+ framesToPreload.forEach((frame) => {
857
+ const cacheKey = isMRMS ? `mrms-${frame}-${variable}` : `${model}-${date}-${run}-${frame}-${variable}`;
858
+ if (preloadedDataCache.current.has(cacheKey)) {
859
+ return;
860
+ }
861
+
862
+ let resourcePath;
863
+ if (isMRMS) {
864
+ const frameDate = new Date(frame * 1000);
865
+ const y = frameDate.getUTCFullYear();
866
+ const m = (frameDate.getUTCMonth() + 1).toString().padStart(2, '0');
867
+ const d = frameDate.getUTCDate().toString().padStart(2, '0');
868
+ resourcePath = `/grids/mrms/${y}${m}${d}/${frame}/0/${variable}/0`;
869
+ } else {
870
+ resourcePath = `/grids/${model}/${date}/${run}/${frame}/${variable}/0`;
871
+ }
872
+
873
+ const options = augmentProcessFrameOptionsForDebug(
874
+ buildGridFrameProcessOptions(
875
+ core.baseGridUrl,
876
+ resourcePath,
877
+ core.apiKey,
878
+ core.bundleId,
879
+ gridRequestSiteOrigin,
880
+ core,
881
+ ),
882
+ core,
883
+ );
884
+ logProcessFrameAuthMismatch(core, options, { phase: 'preloadBackground', cacheKey });
885
+
886
+ WeatherFrameProcessorModule.processFrame(options)
887
+ .then(result => {
888
+ if (isStaleGeneration()) {
889
+ return;
890
+ }
891
+ if (!result || !result.filePath) {
892
+ return;
893
+ }
894
+
895
+ // ADD: Same two-step conversion as the current frame
896
+ const { baseUnit } = core._getColormapForVariable(variable);
897
+ const toUnit = core._getTargetUnit(baseUnit, units);
898
+ const fieldInfo = DICTIONARIES?.fld?.[variable] || {};
899
+ const serverDataUnit = fieldInfo.defaultUnit || baseUnit;
900
+
901
+ let dataScale = result.scale;
902
+ let dataOffset = result.offset;
903
+
904
+ let convertedScale = dataScale;
905
+ let convertedOffset = dataOffset;
906
+
907
+ // Step 1: Convert from server unit to colormap base unit
908
+ if (serverDataUnit !== baseUnit) {
909
+ const conversionFunc = getUnitConversionFunction(serverDataUnit, baseUnit);
910
+ if (conversionFunc) {
911
+ if (result.scaleType === 'sqrt') {
912
+ const physicalAtOffset = dataOffset * dataOffset;
913
+ const physicalAtOffsetPlusScale = (dataOffset + dataScale) * (dataOffset + dataScale);
914
+ const convertedPhysicalAtOffset = conversionFunc(physicalAtOffset);
915
+ const convertedPhysicalAtOffsetPlusScale = conversionFunc(physicalAtOffsetPlusScale);
916
+ convertedOffset = Math.sqrt(Math.abs(convertedPhysicalAtOffset)) * Math.sign(convertedPhysicalAtOffset);
917
+ const newOffsetPlusScale = Math.sqrt(Math.abs(convertedPhysicalAtOffsetPlusScale)) * Math.sign(convertedPhysicalAtOffsetPlusScale);
918
+ convertedScale = newOffsetPlusScale - convertedOffset;
919
+ } else {
920
+ convertedOffset = conversionFunc(dataOffset);
921
+ const convertedOffsetPlusScale = conversionFunc(dataOffset + dataScale);
922
+ convertedScale = convertedOffsetPlusScale - convertedOffset;
923
+ }
924
+ dataScale = convertedScale;
925
+ dataOffset = convertedOffset;
926
+ }
927
+ }
928
+
929
+ // Step 2: Convert from colormap base unit to target display unit
930
+ if (baseUnit !== toUnit) {
931
+ const conversionFunc = getUnitConversionFunction(baseUnit, toUnit);
932
+ if (conversionFunc) {
933
+ if (result.scaleType === 'sqrt') {
934
+ const physicalAtOffset = dataOffset * dataOffset;
935
+ const physicalAtOffsetPlusScale = (dataOffset + dataScale) * (dataOffset + dataScale);
936
+ const convertedPhysicalAtOffset = conversionFunc(physicalAtOffset);
937
+ const convertedPhysicalAtOffsetPlusScale = conversionFunc(physicalAtOffsetPlusScale);
938
+ convertedOffset = Math.sqrt(Math.abs(convertedPhysicalAtOffset)) * Math.sign(convertedPhysicalAtOffset);
939
+ const newOffsetPlusScale = Math.sqrt(Math.abs(convertedPhysicalAtOffsetPlusScale)) * Math.sign(convertedPhysicalAtOffsetPlusScale);
940
+ convertedScale = newOffsetPlusScale - convertedOffset;
941
+ } else {
942
+ convertedOffset = conversionFunc(dataOffset);
943
+ const convertedOffsetPlusScale = conversionFunc(dataOffset + dataScale);
944
+ convertedScale = convertedOffsetPlusScale - convertedOffset;
945
+ }
946
+ }
947
+ }
948
+
949
+ const frameData = {
950
+ filePath: result.filePath,
951
+ nx, ny,
952
+ scale: convertedScale,
953
+ offset: convertedOffset,
954
+ missing: result.missing,
955
+ corners,
956
+ gridDef,
957
+ scaleType: result.scaleType,
958
+ originalScale: result.scale,
959
+ originalOffset: result.offset
960
+ };
961
+
962
+ preloadedDataCache.current.set(cacheKey, frameData);
963
+
964
+ if (Platform.OS === 'ios' && gridLayerRef.current.primeGpuCache) {
965
+ const frameInfoForGpu = {
966
+ [cacheKey]: {
967
+ filePath: frameData.filePath,
968
+ nx: frameData.nx,
969
+ ny: frameData.ny,
970
+ scale: frameData.scale,
971
+ offset: frameData.offset,
972
+ missing: frameData.missing,
973
+ scaleType: frameData.scaleType || 'linear',
974
+ originalScale: frameData.originalScale,
975
+ originalOffset: frameData.originalOffset
976
+ }
977
+ };
978
+ gridLayerRef.current.primeGpuCache(frameInfoForGpu);
979
+ }
980
+ })
981
+ .catch((e) => {
982
+ const cancelled =
983
+ e &&
984
+ (e.code === 'E_CANCELLED' ||
985
+ e?.userInfo?.code === 'E_CANCELLED' ||
986
+ (typeof e?.message === 'string' && e.message.includes('superseded')));
987
+ if (!cancelled) {
988
+ wxGridWarn('backgroundFrameError', { cacheKey, code: e?.code, message: e?.message });
989
+ }
990
+ });
991
+ });
992
+ };
993
+
994
+ useEffect(() => {
995
+ // This effect manages the auto-refresh based on props
996
+ if (autoRefresh) {
997
+ const interval = (autoRefreshInterval || 30) * 1000;
998
+ _checkForUpdates(); // Run immediately on enable
999
+ autoRefreshIntervalId.current = setInterval(_checkForUpdates, interval);
1000
+ }
1001
+
1002
+ // Cleanup function: this runs when the component unmounts or props change
1003
+ return () => {
1004
+ if (autoRefreshIntervalId.current) {
1005
+ clearInterval(autoRefreshIntervalId.current);
1006
+ autoRefreshIntervalId.current = null;
1007
+ }
1008
+ };
1009
+ }, [autoRefresh, autoRefreshInterval, _checkForUpdates]);
1010
+
1011
+ const updateGPUWithCachedData = (state) => {
1012
+ const { model, date, run, forecastHour, variable, units, isMRMS, mrmsTimestamp } = state;
1013
+
1014
+ const cacheKey = isMRMS
1015
+ ? `mrms-${mrmsTimestamp}-${variable}`
1016
+ : `${model}-${date}-${run}-${forecastHour}-${variable}`;
1017
+
1018
+ if (Platform.OS === 'ios' && gridLayerRef.current.setActiveFrame) {
1019
+ // Get the cached data BEFORE calling setActiveFrame
1020
+ const cachedData = preloadedDataCache.current.get(cacheKey);
1021
+
1022
+ if (cachedData) {
1023
+ currentGridDataRef.current = {
1024
+ nx: cachedData.nx,
1025
+ ny: cachedData.ny,
1026
+ scale: cachedData.scale,
1027
+ offset: cachedData.offset,
1028
+ missing: cachedData.missing,
1029
+ gridDef: cachedData.gridDef,
1030
+ variable: variable,
1031
+ units: units,
1032
+ scaleType: cachedData.scaleType
1033
+ };
1034
+ } else {
1035
+ wxGridVerbose('gpuIOS_setActiveFrame_no_row', {
1036
+ cacheKey,
1037
+ cacheSize: preloadedDataCache.current.size,
1038
+ });
1039
+ }
1040
+
1041
+ gridLayerRef.current.setActiveFrame(cacheKey);
1042
+ wxGridVerbose('gpuIOS_setActiveFrame', { cacheKey, hadCachedRow: Boolean(cachedData) });
1043
+ return true;
1044
+ }
1045
+
1046
+ const cachedData = preloadedDataCache.current.get(cacheKey);
1047
+
1048
+ if (!cachedData) {
1049
+ wxGridWarn('updateGPUNoCache', {
1050
+ cacheKey,
1051
+ cacheSize: preloadedDataCache.current.size,
1052
+ sampleKeys: Array.from(preloadedDataCache.current.keys()).slice(0, 5),
1053
+ });
1054
+ return false;
1055
+ }
1056
+
1057
+ if (!gridLayerRef.current) {
1058
+ wxGridWarn('updateGPUNoGridLayerRef', { cacheKey });
1059
+ return false;
1060
+ }
1061
+
1062
+ if (!cachedGeometry.current || cachedGeometry.current.model !== (isMRMS ? 'mrms' : model) || cachedGeometry.current.variable !== variable) {
1063
+ gridLayerRef.current.updateGeometry(cachedData.corners, cachedData.gridDef);
1064
+ cachedGeometry.current = { model: (isMRMS ? 'mrms' : model), variable };
1065
+ }
1066
+
1067
+ const colormapKey = `${variable}-${units}`;
1068
+ if (!cachedColormap.current || cachedColormap.current.key !== colormapKey) {
1069
+ const { colormap, baseUnit } = core._getColormapForVariable(variable);
1070
+ const toUnit = core._getTargetUnit(baseUnit, units);
1071
+ const finalColormap = core._convertColormapUnits(colormap, baseUnit, toUnit);
1072
+ let dataRange;
1073
+ if (variable === 'ptypeRefl' || variable === 'ptypeRate') {
1074
+ if (isMRMS) {
1075
+ dataRange = [5, 380];
1076
+ } else {
1077
+ dataRange = [5, 380];
1078
+ }
1079
+ } else {
1080
+ dataRange = [finalColormap[0], finalColormap[finalColormap.length - 2]];
1081
+ }
1082
+ const colormapBytes = _generateColormapBytes(finalColormap);
1083
+ const colormapAsBase64 = fromByteArray(colormapBytes);
1084
+
1085
+ gridLayerRef.current.updateColormapTexture(colormapAsBase64);
1086
+ cachedColormap.current = { key: colormapKey };
1087
+ cachedDataRange.current = dataRange;
1088
+
1089
+ setRenderProps(prev => ({ ...prev, dataRange }));
1090
+ }
1091
+
1092
+ if (cachedData.filePath) {
1093
+ gridLayerRef.current.updateDataTextureFromFile(
1094
+ cachedData.filePath,
1095
+ cachedData.nx, cachedData.ny,
1096
+ cachedData.scale, cachedData.offset, cachedData.missing,
1097
+ cachedData.scaleType
1098
+ );
1099
+ currentGridDataRef.current = {
1100
+ nx: cachedData.nx,
1101
+ ny: cachedData.ny,
1102
+ scale: cachedData.scale,
1103
+ offset: cachedData.offset,
1104
+ missing: cachedData.missing,
1105
+ gridDef: cachedData.gridDef,
1106
+ variable: variable,
1107
+ units: units,
1108
+ scaleType: cachedData.scaleType
1109
+ };
1110
+ wxGridVerbose('gpuUploadFromFile', {
1111
+ cacheKey,
1112
+ nx: cachedData.nx,
1113
+ ny: cachedData.ny,
1114
+ filePathSuffix: String(cachedData.filePath).split('/').pop(),
1115
+ });
1116
+ } else {
1117
+ wxGridWarn('updateGPUNoFilePath', { cacheKey });
1118
+ return false;
1119
+ }
1120
+
1121
+ // Update inspector parameters for file-based data too
1122
+ if (gridLayerRef.current && gridLayerRef.current.updateDataParameters) {
1123
+ gridLayerRef.current.updateDataParameters(cachedData.scale, cachedData.offset, cachedData.missing);
1124
+ }
1125
+ return true;
1126
+ };
1127
+
1128
+ const handleStateChangeRef = useRef(null);
1129
+ const debounceTimeoutRef = useRef(null);
1130
+
1131
+ useEffect(() => {
1132
+ if (core && props.customColormaps) {
1133
+ core.customColormaps = props.customColormaps;
1134
+ // Trigger a re-render if we already have data loaded
1135
+ if (hasInitialLoad.current) {
1136
+ core._emitStateChange();
1137
+ }
1138
+ }
1139
+ }, [core, props.customColormaps]);
1140
+
1141
+ const getValueAtPoint = async (lng, lat) => {
1142
+ if (!core) {
1143
+ return null;
1144
+ }
1145
+
1146
+ if (NEXRAD_NATIVE && core.state?.isNexrad) {
1147
+ return getNexradInspectPayloadAt(lng, lat);
1148
+ }
1149
+
1150
+ // ADD THIS: Check if we have valid data before attempting inspection
1151
+ if (!currentGridDataRef.current) {
1152
+ return null;
1153
+ }
1154
+
1155
+ try {
1156
+ const gridIndices = core._getGridIndexFromLngLat(lng, lat);
1157
+ if (!gridIndices) return null;
1158
+
1159
+ const { i, j } = gridIndices;
1160
+
1161
+ const value = await InspectorModule.getValueAtGridIndex(i, j);
1162
+
1163
+ if (value === null) {
1164
+ return null;
1165
+ }
1166
+
1167
+ const { colormap, baseUnit } = core._getColormapForVariable(core.state.variable);
1168
+ const displayUnit = core._getTargetUnit(baseUnit, core.state.units);
1169
+ const finalColormap = core._convertColormapUnits(colormap, baseUnit, displayUnit);
1170
+ const minThreshold = finalColormap[0];
1171
+
1172
+ if (value < minThreshold) {
1173
+ return null;
1174
+ }
1175
+
1176
+ // Filter out values below the minimum threshold (matching shader behavior)
1177
+ if (value < minThreshold) {
1178
+ return null;
1179
+ }
1180
+
1181
+ // Also check if value is NaN or effectively missing
1182
+ if (!isFinite(value)) {
1183
+ return null;
1184
+ }
1185
+
1186
+ return {
1187
+ value: value,
1188
+ unit: displayUnit,
1189
+ variable: {
1190
+ code: core.state.variable,
1191
+ name: core.getVariableDisplayName(core.state.variable)
1192
+ },
1193
+ lngLat: { lng, lat }
1194
+ };
1195
+ } catch {
1196
+ return null;
1197
+ }
1198
+ };
1199
+
1200
+ const getValueAtPointRef = useRef(getValueAtPoint);
1201
+ getValueAtPointRef.current = getValueAtPoint;
1202
+ const getNexradInspectPayloadAtRef = useRef(getNexradInspectPayloadAt);
1203
+ getNexradInspectPayloadAtRef.current = getNexradInspectPayloadAt;
1204
+
1205
+ const _checkForUpdates = useMemo(() => async () => {
1206
+ if (!core) return;
1207
+ const s = core.state;
1208
+ const { isMRMS, isSatellite, isNexrad, model: currentModel, variable: currentVariable, date, run } = s;
1209
+
1210
+ if (isSatellite) {
1211
+ const prevTimeline = core._computeSatelliteTimeline();
1212
+ const prevTimes = [...(prevTimeline.unixTimes || [])]
1213
+ .map((t) => Number(t))
1214
+ .filter((t) => !Number.isNaN(t))
1215
+ .sort((a, b) => a - b);
1216
+ const prevMax = prevTimes.length ? prevTimes[prevTimes.length - 1] : null;
1217
+ const curSat = s.satelliteTimestamp == null ? null : Number(s.satelliteTimestamp);
1218
+
1219
+ await core.fetchSatelliteListing(true);
1220
+ core._emitStateChange();
1221
+
1222
+ const nextTimeline = core._computeSatelliteTimeline();
1223
+ const nextTimes = [...(nextTimeline.unixTimes || [])]
1224
+ .map((t) => Number(t))
1225
+ .filter((t) => !Number.isNaN(t))
1226
+ .sort((a, b) => a - b);
1227
+ const newMax = nextTimes.length ? nextTimes[nextTimes.length - 1] : null;
1228
+
1229
+ if (prevMax != null && curSat != null && curSat === prevMax && newMax != null && newMax > prevMax) {
1230
+ await core.setSatelliteTimestamp(newMax);
1231
+ } else if (curSat != null && newMax != null && nextTimes.length && !nextTimes.includes(curSat)) {
1232
+ await core.setSatelliteTimestamp(newMax);
1233
+ }
1234
+ return;
1235
+ }
1236
+
1237
+ if (isNexrad && s.nexradSite) {
1238
+ const nk = core._nexradTimesCacheKey();
1239
+ const rawBefore = nk ? core.nexradTimesByStation[nk]?.unixTimes : [];
1240
+ const filteredBefore = core._getFilteredNexradTimestampsForVariable(rawBefore || []);
1241
+ const prevMax = filteredBefore.length ? filteredBefore[filteredBefore.length - 1] : null;
1242
+ const curNx = s.nexradTimestamp == null ? null : Number(s.nexradTimestamp);
1243
+
1244
+ await core.refreshNexradTimes();
1245
+
1246
+ const rawAfter = nk ? core.nexradTimesByStation[nk]?.unixTimes : [];
1247
+ const filteredAfter = core._getFilteredNexradTimestampsForVariable(rawAfter || []);
1248
+ const newMax = filteredAfter.length ? filteredAfter[filteredAfter.length - 1] : null;
1249
+
1250
+ if (prevMax != null && curNx != null && curNx === prevMax && newMax != null && newMax > prevMax) {
1251
+ await core.setNexradTimestamp(newMax);
1252
+ } else if (curNx != null && newMax != null && filteredAfter.length && !filteredAfter.includes(curNx)) {
1253
+ await core.setNexradTimestamp(newMax);
1254
+ }
1255
+ return;
1256
+ }
1257
+
1258
+ if (isMRMS) {
1259
+ // --- MRMS LOGIC (Keep existing logic) ---
1260
+ const oldTimestamps = new Set(core.mrmsStatus?.[currentVariable] || []);
1261
+ const mrmsStatus = await core.fetchMRMSStatus(true);
1262
+ const newTimestamps = mrmsStatus?.[currentVariable] || [];
1263
+ if (newTimestamps.length === 0) return;
1264
+
1265
+ const newTimestampsToPreload = newTimestamps.filter(ts => !oldTimestamps.has(ts));
1266
+
1267
+ if (newTimestampsToPreload.length > 0) {
1268
+ core.mrmsStatus = mrmsStatus;
1269
+ core._emitStateChange(); // Update UI slider without changing selection
1270
+
1271
+ // ... (Keep your existing preloading logic here) ...
1272
+ const { corners, gridDef } = core._getGridCornersAndDef('mrms');
1273
+ const { nx, ny } = gridDef.grid_params;
1274
+
1275
+ newTimestampsToPreload.forEach(frame => {
1276
+ const cacheKey = `mrms-${frame}-${currentVariable}`;
1277
+ if (preloadedDataCache.current.has(cacheKey)) return;
1278
+
1279
+ const frameDate = new Date(frame * 1000);
1280
+ const y = frameDate.getUTCFullYear(), m = (frameDate.getUTCMonth() + 1).toString().padStart(2, '0'), d = frameDate.getUTCDate().toString().padStart(2, '0');
1281
+ const resourcePath = `/grids/mrms/${y}${m}${d}/${frame}/0/${currentVariable}/0`;
1282
+ const options = augmentProcessFrameOptionsForDebug(
1283
+ buildGridFrameProcessOptions(
1284
+ core.baseGridUrl,
1285
+ resourcePath,
1286
+ core.apiKey,
1287
+ core.bundleId,
1288
+ gridRequestSiteOrigin,
1289
+ core,
1290
+ ),
1291
+ core,
1292
+ );
1293
+ logProcessFrameAuthMismatch(core, options, { phase: 'mrmsRefresh', cacheKey });
1294
+
1295
+ WeatherFrameProcessorModule.processFrame(options)
1296
+ .then(result => {
1297
+ if (!result || !result.filePath) return;
1298
+ const frameData = { filePath: result.filePath, nx, ny, scale: result.scale, offset: result.offset, missing: result.missing, corners, gridDef, scaleType: result.scaleType, originalScale: result.scale, originalOffset: result.offset };
1299
+ preloadedDataCache.current.set(cacheKey, frameData);
1300
+ if (Platform.OS === 'ios' && gridLayerRef.current?.primeGpuCache) {
1301
+ gridLayerRef.current.primeGpuCache({ [cacheKey]: frameData });
1302
+ }
1303
+ })
1304
+ .catch((e) => {
1305
+ const cancelled =
1306
+ e &&
1307
+ (e.code === 'E_CANCELLED' ||
1308
+ e?.userInfo?.code === 'E_CANCELLED' ||
1309
+ (typeof e?.message === 'string' && e.message.includes('superseded')));
1310
+ if (!cancelled) {
1311
+ wxGridWarn('mrmsRefreshFrameError', { cacheKey, code: e?.code, message: e?.message });
1312
+ }
1313
+ });
1314
+ });
1315
+
1316
+ const newTimestampsSet = new Set(newTimestamps);
1317
+ oldTimestamps.forEach(oldTs => {
1318
+ if (!newTimestampsSet.has(oldTs)) {
1319
+ const cacheKey = `mrms-${oldTs}-${currentVariable}`;
1320
+ preloadedDataCache.current.delete(cacheKey);
1321
+ }
1322
+ });
1323
+ }
1324
+ } else {
1325
+ const previousStatus = core.modelStatus;
1326
+ const modelStatus = await core.fetchModelStatus(true);
1327
+ const statusChanged = JSON.stringify(previousStatus) !== JSON.stringify(modelStatus);
1328
+ if (statusChanged) {
1329
+ core._emitStateChange();
1330
+ }
1331
+
1332
+ const latestRun = findLatestModelRun(modelStatus, currentModel);
1333
+ if (!latestRun) return;
1334
+ }
1335
+ }, [core]);
1336
+
1337
+ useEffect(() => {
1338
+ if (!core) {
1339
+ return;
1340
+ }
1341
+
1342
+ const handleStateChange = (newState) => {
1343
+ if (!previousStateRef.current) {
1344
+ previousStateRef.current = core.state;
1345
+ }
1346
+
1347
+ const variableChanged = !previousStateRef.current || newState.variable !== previousStateRef.current.variable;
1348
+
1349
+ if (variableChanged && gridLayerRef.current?.setVariable) {
1350
+ gridLayerRef.current.setVariable(newState.variable);
1351
+ }
1352
+
1353
+ const stateKey = newState.isNexrad
1354
+ ? `nx-${newState.nexradSite}-${newState.nexradProduct}-${newState.nexradDataSource}-${newState.nexradTimestamp}-${newState.nexradTilt}-${newState.units}-${newState.model}-${newState.variable}-${newState.mrmsTimestamp}-${newState.forecastHour}-dur:${newState.nexradDurationValue ?? ''}-tl:${nexradObsTimelineSig(newState)}`
1355
+ : newState.isSatellite
1356
+ ? `sat-${newState.satelliteInstrumentId}-${newState.satelliteSectorLabel}-${newState.satelliteChannel}-${newState.satelliteTimestamp}-${newState.variable}-${newState.units}-${newState.opacity}-dur:${newState.satelliteDurationValue ?? ''}-tl:${satelliteObsTimelineSig(newState)}`
1357
+ : `${newState.model}-${newState.variable}-${newState.date}-${newState.run}-${newState.forecastHour}-${newState.units}-${newState.mrmsTimestamp}`;
1358
+
1359
+ const sameTimeline = newState.isNexrad
1360
+ ? newState.nexradTimestamp === previousStateRef.current?.nexradTimestamp &&
1361
+ newState.nexradSite === previousStateRef.current?.nexradSite &&
1362
+ newState.nexradProduct === previousStateRef.current?.nexradProduct &&
1363
+ Number(newState.nexradTilt) === Number(previousStateRef.current?.nexradTilt) &&
1364
+ newState.nexradDurationValue === previousStateRef.current?.nexradDurationValue &&
1365
+ nexradObsTimelineSig(newState) === nexradObsTimelineSig(previousStateRef.current)
1366
+ : newState.isSatellite
1367
+ ? newState.satelliteTimestamp === previousStateRef.current?.satelliteTimestamp &&
1368
+ newState.satelliteInstrumentId === previousStateRef.current?.satelliteInstrumentId &&
1369
+ newState.satelliteSectorLabel === previousStateRef.current?.satelliteSectorLabel &&
1370
+ newState.satelliteChannel === previousStateRef.current?.satelliteChannel &&
1371
+ newState.satelliteDurationValue === previousStateRef.current?.satelliteDurationValue &&
1372
+ satelliteObsTimelineSig(newState) === satelliteObsTimelineSig(previousStateRef.current)
1373
+ : newState.forecastHour === previousStateRef.current?.forecastHour &&
1374
+ newState.mrmsTimestamp === previousStateRef.current?.mrmsTimestamp;
1375
+
1376
+ const sameModeAnchor =
1377
+ newState.isSatellite
1378
+ ? newState.satelliteInstrumentId === previousStateRef.current?.satelliteInstrumentId &&
1379
+ newState.satelliteSectorLabel === previousStateRef.current?.satelliteSectorLabel &&
1380
+ newState.satelliteChannel === previousStateRef.current?.satelliteChannel
1381
+ : newState.model === previousStateRef.current?.model &&
1382
+ newState.isMRMS === previousStateRef.current?.isMRMS;
1383
+
1384
+ const isOpacityOnlyChange =
1385
+ hasInitialLoad.current &&
1386
+ newState.opacity !== renderProps.opacity &&
1387
+ newState.variable === previousStateRef.current?.variable &&
1388
+ sameTimeline &&
1389
+ sameModeAnchor &&
1390
+ newState.units === previousStateRef.current?.units;
1391
+
1392
+ const isPlayStateOnlyChange =
1393
+ hasInitialLoad.current &&
1394
+ newState.isPlaying !== previousStateRef.current?.isPlaying &&
1395
+ newState.variable === previousStateRef.current?.variable &&
1396
+ sameTimeline &&
1397
+ sameModeAnchor &&
1398
+ newState.units === previousStateRef.current?.units &&
1399
+ newState.opacity === previousStateRef.current?.opacity;
1400
+
1401
+ if (!isOpacityOnlyChange && !isPlayStateOnlyChange && lastProcessedState.current === stateKey) {
1402
+ previousStateRef.current = newState;
1403
+ return;
1404
+ }
1405
+
1406
+ if (
1407
+ wxGridDebugEnabled() &&
1408
+ Platform.OS === 'ios' &&
1409
+ ((newState.isNexrad && previousStateRef.current?.isNexrad) ||
1410
+ (newState.isSatellite && previousStateRef.current?.isSatellite))
1411
+ ) {
1412
+ const prev = previousStateRef.current;
1413
+ if (newState.isNexrad && prev?.isNexrad) {
1414
+ if (
1415
+ newState.nexradDurationValue !== prev.nexradDurationValue ||
1416
+ nexradObsTimelineSig(newState) !== nexradObsTimelineSig(prev)
1417
+ ) {
1418
+ wxGridVerbose('iosNexradTimelineWindowChanged', {
1419
+ duration: { from: prev.nexradDurationValue, to: newState.nexradDurationValue },
1420
+ timelineSig: { from: nexradObsTimelineSig(prev), to: nexradObsTimelineSig(newState) },
1421
+ willSyncNative: true,
1422
+ });
1423
+ }
1424
+ }
1425
+ if (newState.isSatellite && prev?.isSatellite) {
1426
+ if (
1427
+ newState.satelliteDurationValue !== prev.satelliteDurationValue ||
1428
+ satelliteObsTimelineSig(newState) !== satelliteObsTimelineSig(prev)
1429
+ ) {
1430
+ wxGridVerbose('iosSatelliteTimelineWindowChanged', {
1431
+ duration: { from: prev.satelliteDurationValue, to: newState.satelliteDurationValue },
1432
+ timelineSig: { from: satelliteObsTimelineSig(prev), to: satelliteObsTimelineSig(newState) },
1433
+ willSyncNative: true,
1434
+ });
1435
+ }
1436
+ }
1437
+ }
1438
+
1439
+ if (!isOpacityOnlyChange && !isPlayStateOnlyChange) {
1440
+ lastProcessedState.current = stateKey;
1441
+ }
1442
+
1443
+ if (isOpacityOnlyChange) {
1444
+ setRenderProps(prev => ({ ...prev, opacity: newState.opacity }));
1445
+ if (NEXRAD_NATIVE && newState.isNexrad) {
1446
+ nexradControllerRef.current?.applyStyleFromState?.(newState);
1447
+ }
1448
+ if (SATELLITE_NATIVE && newState.isSatellite) {
1449
+ satelliteLayerRef.current?.updateSatelliteStyle?.(
1450
+ JSON.stringify({
1451
+ visible: newState.visible !== false,
1452
+ opacity: newState.opacity ?? 1,
1453
+ fillSmoothing: 0,
1454
+ }),
1455
+ );
1456
+ }
1457
+ previousStateRef.current = newState;
1458
+ return;
1459
+ }
1460
+
1461
+ if (isPlayStateOnlyChange) {
1462
+ previousStateRef.current = newState;
1463
+ return;
1464
+ }
1465
+
1466
+ const isUnitsOnlyChange =
1467
+ hasInitialLoad.current &&
1468
+ newState.model === previousStateRef.current.model &&
1469
+ newState.isMRMS === previousStateRef.current.isMRMS &&
1470
+ newState.variable === previousStateRef.current.variable &&
1471
+ newState.date === previousStateRef.current.date &&
1472
+ newState.run === previousStateRef.current.run &&
1473
+ (newState.isNexrad
1474
+ ? newState.nexradTimestamp === previousStateRef.current.nexradTimestamp &&
1475
+ newState.nexradSite === previousStateRef.current.nexradSite &&
1476
+ newState.nexradProduct === previousStateRef.current.nexradProduct
1477
+ : newState.forecastHour === previousStateRef.current.forecastHour &&
1478
+ newState.mrmsTimestamp === previousStateRef.current.mrmsTimestamp) &&
1479
+ newState.units !== previousStateRef.current.units;
1480
+
1481
+ if (isUnitsOnlyChange) {
1482
+ if (NEXRAD_NATIVE && newState.isNexrad) {
1483
+ setRenderProps(prev => ({ ...prev, opacity: newState.opacity }));
1484
+ nexradControllerRef.current?.applyStyleFromState?.(newState);
1485
+ previousStateRef.current = newState;
1486
+ return;
1487
+ }
1488
+ const { variable, units, isMRMS, mrmsTimestamp, model, date, run, forecastHour } = newState;
1489
+ const oldCacheKey = isMRMS
1490
+ ? `mrms-${mrmsTimestamp}-${variable}`
1491
+ : `${model}-${date}-${run}-${forecastHour}-${variable}`;
1492
+
1493
+ const cachedData = preloadedDataCache.current.get(oldCacheKey);
1494
+
1495
+ if (cachedData && cachedData.originalScale !== undefined && cachedData.originalOffset !== undefined) {
1496
+ const { baseUnit } = core._getColormapForVariable(variable);
1497
+ const toUnit = core._getTargetUnit(baseUnit, units);
1498
+ const fieldInfo = DICTIONARIES?.fld?.[variable] || {};
1499
+ const serverDataUnit = fieldInfo.defaultUnit || baseUnit;
1500
+
1501
+ let dataScale = cachedData.originalScale;
1502
+ let dataOffset = cachedData.originalOffset;
1503
+
1504
+ if (serverDataUnit !== baseUnit) {
1505
+ const conversionFunc = getUnitConversionFunction(serverDataUnit, baseUnit);
1506
+ if (conversionFunc) {
1507
+ if (cachedData.scaleType === 'sqrt') {
1508
+ const physicalAtOffset = dataOffset * dataOffset;
1509
+ const physicalAtOffsetPlusScale = (dataOffset + dataScale) * (dataOffset + dataScale);
1510
+ const convertedPhysicalAtOffset = conversionFunc(physicalAtOffset);
1511
+ const convertedPhysicalAtOffsetPlusScale = conversionFunc(physicalAtOffsetPlusScale);
1512
+ const newOffset = Math.sqrt(Math.abs(convertedPhysicalAtOffset)) * Math.sign(convertedPhysicalAtOffset);
1513
+ const newOffsetPlusScale = Math.sqrt(Math.abs(convertedPhysicalAtOffsetPlusScale)) * Math.sign(convertedPhysicalAtOffsetPlusScale);
1514
+ dataScale = newOffsetPlusScale - newOffset;
1515
+ dataOffset = newOffset;
1516
+ } else {
1517
+ const convertedOffset = conversionFunc(dataOffset);
1518
+ const convertedOffsetPlusScale = conversionFunc(dataOffset + dataScale);
1519
+ dataScale = convertedOffsetPlusScale - convertedOffset;
1520
+ dataOffset = convertedOffset;
1521
+ }
1522
+ }
1523
+ }
1524
+
1525
+ if (baseUnit !== toUnit) {
1526
+ const conversionFunc = getUnitConversionFunction(baseUnit, toUnit);
1527
+ if (conversionFunc) {
1528
+ if (cachedData.scaleType === 'sqrt') {
1529
+ const physicalAtOffset = dataOffset * dataOffset;
1530
+ const physicalAtOffsetPlusScale = (dataOffset + dataScale) * (dataOffset + dataScale);
1531
+ const convertedPhysicalAtOffset = conversionFunc(physicalAtOffset);
1532
+ const convertedPhysicalAtOffsetPlusScale = conversionFunc(physicalAtOffsetPlusScale);
1533
+ const newOffset = Math.sqrt(Math.abs(convertedPhysicalAtOffset)) * Math.sign(convertedPhysicalAtOffset);
1534
+ const newOffsetPlusScale = Math.sqrt(Math.abs(convertedPhysicalAtOffsetPlusScale)) * Math.sign(convertedPhysicalAtOffsetPlusScale);
1535
+ dataScale = newOffsetPlusScale - newOffset;
1536
+ dataOffset = newOffset;
1537
+ } else {
1538
+ const convertedOffset = conversionFunc(dataOffset);
1539
+ const convertedOffsetPlusScale = conversionFunc(dataOffset + dataScale);
1540
+ dataScale = convertedOffsetPlusScale - convertedOffset;
1541
+ dataOffset = convertedOffset;
1542
+ }
1543
+ }
1544
+ }
1545
+
1546
+ const { colormap } = core._getColormapForVariable(variable);
1547
+ const finalColormap = core._convertColormapUnits(colormap, baseUnit, toUnit);
1548
+ let dataRange = (variable === 'ptypeRefl' || variable === 'ptypeRate') ? [5, 380] : [finalColormap[0], finalColormap[finalColormap.length - 2]];
1549
+ const colormapBytes = _generateColormapBytes(finalColormap);
1550
+ const colormapAsBase64 = fromByteArray(colormapBytes);
1551
+
1552
+ gridLayerRef.current.updateColormapTexture(colormapAsBase64);
1553
+ cachedColormap.current = { key: `${variable}-${units}` };
1554
+ cachedDataRange.current = dataRange;
1555
+ setRenderProps(prev => ({ ...prev, dataRange, opacity: newState.opacity }));
1556
+
1557
+ if (gridLayerRef.current && gridLayerRef.current.updateDataParameters) {
1558
+ const scaleTypeValue = cachedData.scaleType === 'sqrt' ? 1 : 0;
1559
+ gridLayerRef.current.updateDataParameters(dataScale, dataOffset, cachedData.missing, scaleTypeValue);
1560
+ }
1561
+
1562
+ const newCacheKey = isMRMS ? `mrms-${mrmsTimestamp}-${variable}` : `${model}-${date}-${run}-${forecastHour}-${variable}`;
1563
+ preloadedDataCache.current.set(newCacheKey, { ...cachedData, scale: dataScale, offset: dataOffset });
1564
+ }
1565
+
1566
+ previousStateRef.current = newState;
1567
+ return;
1568
+ }
1569
+
1570
+ const prev = previousStateRef.current;
1571
+ const needsFullLoad =
1572
+ !hasInitialLoad.current ||
1573
+ newState.isNexrad !== prev?.isNexrad ||
1574
+ newState.isSatellite !== prev?.isSatellite ||
1575
+ (newState.isNexrad &&
1576
+ prev?.isNexrad &&
1577
+ (newState.nexradSite !== prev.nexradSite ||
1578
+ newState.nexradDataSource !== prev.nexradDataSource ||
1579
+ newState.nexradProduct !== prev.nexradProduct)) ||
1580
+ (newState.isSatellite &&
1581
+ prev?.isSatellite &&
1582
+ (newState.satelliteInstrumentId !== prev.satelliteInstrumentId ||
1583
+ newState.satelliteSectorLabel !== prev.satelliteSectorLabel ||
1584
+ newState.satelliteChannel !== prev.satelliteChannel)) ||
1585
+ (!newState.isNexrad &&
1586
+ !newState.isSatellite &&
1587
+ (newState.model !== prev?.model ||
1588
+ newState.isMRMS !== prev?.isMRMS ||
1589
+ newState.variable !== prev?.variable ||
1590
+ newState.date !== prev?.date ||
1591
+ newState.run !== prev?.run));
1592
+
1593
+ if (needsFullLoad) {
1594
+ wxGridVerbose('needsFullLoad', {
1595
+ variable: newState.variable,
1596
+ isMRMS: newState.isMRMS,
1597
+ model: newState.model,
1598
+ isInitial: !hasInitialLoad.current,
1599
+ prevVariable: prev?.variable,
1600
+ prevModel: prev?.model,
1601
+ });
1602
+ const nexradIdentityChange =
1603
+ NEXRAD_NATIVE &&
1604
+ (newState.isNexrad !== prev?.isNexrad ||
1605
+ (newState.isNexrad &&
1606
+ prev?.isNexrad &&
1607
+ (newState.nexradSite !== prev.nexradSite ||
1608
+ newState.nexradDataSource !== prev.nexradDataSource ||
1609
+ newState.nexradProduct !== prev.nexradProduct)));
1610
+ const satelliteIdentityChange =
1611
+ SATELLITE_NATIVE &&
1612
+ (newState.isSatellite !== prev?.isSatellite ||
1613
+ (newState.isSatellite &&
1614
+ prev?.isSatellite &&
1615
+ (newState.satelliteInstrumentId !== prev.satelliteInstrumentId ||
1616
+ newState.satelliteSectorLabel !== prev.satelliteSectorLabel ||
1617
+ newState.satelliteChannel !== prev.satelliteChannel)));
1618
+ if (nexradIdentityChange) {
1619
+ nexradPreloadInteractionRef.current?.cancel?.();
1620
+ nexradPreloadInteractionRef.current = null;
1621
+ nexradControllerRef.current?.destroy();
1622
+ nexradControllerRef.current = null;
1623
+ }
1624
+ if (satelliteIdentityChange) {
1625
+ satelliteControllerRef.current?.destroy();
1626
+ satelliteControllerRef.current = null;
1627
+ }
1628
+ if (gridLayerRef.current) {
1629
+ gridLayerRef.current.setVariable(newState.variable);
1630
+ gridLayerRef.current.clear();
1631
+ if (Platform.OS === 'ios' && gridLayerRef.current.clearGpuCache) {
1632
+ gridLayerRef.current.clearGpuCache();
1633
+ }
1634
+ }
1635
+ hasPreloadedRef.current = false;
1636
+ preloadGenerationRef.current += 1;
1637
+ preloadedDataCache.current.clear();
1638
+ cachedGeometry.current = null;
1639
+ cachedColormap.current = null;
1640
+ currentGridDataRef.current = null;
1641
+ WeatherFrameProcessorModule.cancelAllFrames();
1642
+ wxGridVerbose('cancelAllFrames', {
1643
+ reason: 'needsFullLoad',
1644
+ variable: newState.variable,
1645
+ generation: preloadGenerationRef.current,
1646
+ });
1647
+
1648
+ if (!newState.variable) {
1649
+ previousStateRef.current = newState;
1650
+ return;
1651
+ }
1652
+ if (!newState.isNexrad && !newState.isSatellite) {
1653
+ preloadAllFramesToDisk(newState);
1654
+ } else if (NEXRAD_NATIVE && newState.isNexrad) {
1655
+ hasInitialLoad.current = true;
1656
+ } else if (SATELLITE_NATIVE && newState.isSatellite) {
1657
+ hasInitialLoad.current = true;
1658
+ }
1659
+ } else if (
1660
+ !newState.isNexrad &&
1661
+ (newState.forecastHour !== previousStateRef.current.forecastHour ||
1662
+ (newState.isMRMS && newState.mrmsTimestamp !== previousStateRef.current.mrmsTimestamp))
1663
+ ) {
1664
+ const success = updateGPUWithCachedData(newState);
1665
+ if (!success) {
1666
+ wxGridWarn('timelineGpuUpdateMiss', {
1667
+ isMRMS: newState.isMRMS,
1668
+ variable: newState.variable,
1669
+ mrmsTimestamp: newState.mrmsTimestamp,
1670
+ forecastHour: newState.forecastHour,
1671
+ });
1672
+ hasPreloadedRef.current = false;
1673
+ void preloadAllFramesToDisk(newState);
1674
+ }
1675
+ if (success && newState.opacity !== renderProps.opacity) {
1676
+ setRenderProps(prev => ({ ...prev, opacity: newState.opacity }));
1677
+ }
1678
+ }
1679
+
1680
+ if (NEXRAD_NATIVE && newState.isNexrad && newState.nexradSite && newState.nexradTimestamp != null) {
1681
+ const ctl = ensureNexradController();
1682
+ if (ctl) {
1683
+ hasInitialLoad.current = true;
1684
+ void ctl.sync(newState);
1685
+ nexradPreloadInteractionRef.current?.cancel?.();
1686
+ const preloadCancelled = { v: false };
1687
+ nexradPreloadInteractionRef.current = {
1688
+ cancel: () => {
1689
+ preloadCancelled.v = true;
1690
+ },
1691
+ };
1692
+ const nexradTimelineSnapshot = Array.isArray(newState.availableNexradTimestamps)
1693
+ ? newState.availableNexradTimestamps
1694
+ : [];
1695
+ const kickPreload = () => {
1696
+ nexradPreloadInteractionRef.current = null;
1697
+ if (preloadCancelled.v) {
1698
+ return;
1699
+ }
1700
+ const cur = nexradControllerRef.current;
1701
+ if (!cur) {
1702
+ return;
1703
+ }
1704
+ const s = core.state;
1705
+ if (!s.isNexrad || !s.nexradSite || s.nexradTimestamp == null) {
1706
+ return;
1707
+ }
1708
+ const coreTs = Array.isArray(s.availableNexradTimestamps) ? s.availableNexradTimestamps : [];
1709
+ const mergedTimestamps =
1710
+ coreTs.length > 0 ? coreTs : nexradTimelineSnapshot.length > 0 ? nexradTimelineSnapshot : [];
1711
+ const preloadState = { ...s, availableNexradTimestamps: mergedTimestamps };
1712
+ cur.preloadAllAvailable(preloadState);
1713
+ };
1714
+ if (typeof queueMicrotask === 'function') {
1715
+ queueMicrotask(kickPreload);
1716
+ } else {
1717
+ setTimeout(kickPreload, 0);
1718
+ }
1719
+ }
1720
+ } else if (NEXRAD_NATIVE && (!newState.isNexrad || !newState.nexradSite)) {
1721
+ nexradPreloadInteractionRef.current?.cancel?.();
1722
+ nexradPreloadInteractionRef.current = null;
1723
+ nexradControllerRef.current?.destroy();
1724
+ nexradControllerRef.current = null;
1725
+ }
1726
+
1727
+ if (SATELLITE_NATIVE && newState.isSatellite) {
1728
+ satBridgeWarn('handleStateChange satellite', {
1729
+ satelliteInstrumentId: newState.satelliteInstrumentId ?? null,
1730
+ satelliteSectorLabel: newState.satelliteSectorLabel ?? null,
1731
+ satelliteChannel: newState.satelliteChannel ?? null,
1732
+ timelineKeys: Object.keys(newState.satelliteTimeToFileMap || {}).length,
1733
+ refHasSync: Boolean(satelliteLayerRef.current?.syncSatellite),
1734
+ });
1735
+ const satCtl = ensureSatelliteController();
1736
+ if (satCtl) {
1737
+ hasInitialLoad.current = true;
1738
+ void satCtl.sync(newState);
1739
+ } else {
1740
+ satBridgeWarn('handleStateChange satellite: ensureSatelliteController returned null', {});
1741
+ }
1742
+ if (!newState.satelliteInstrumentId && typeof __DEV__ !== 'undefined' && __DEV__) {
1743
+ console.warn(
1744
+ '[AguaceroWX][satellite] isSatellite is true but satelliteInstrumentId is missing — native sync still runs; timeline may be empty until core sets instrument.',
1745
+ );
1746
+ }
1747
+ } else if (SATELLITE_NATIVE && !newState.isSatellite) {
1748
+ satelliteControllerRef.current?.destroy();
1749
+ satelliteControllerRef.current = null;
1750
+ }
1751
+
1752
+ previousStateRef.current = newState;
1753
+ };
1754
+
1755
+ handleStateChangeRef.current = handleStateChange;
1756
+
1757
+ const stableHandler = (newState) => {
1758
+ lastEmittedStateForInspectRef.current = newState;
1759
+ setNexradSitesMapVisible(Boolean(newState.isNexrad && newState.nexradShowSitesPicker !== false));
1760
+ // OPTIMIZATION: If playing (high speed), prioritize MAP update and skip debounce
1761
+ if (newState.isPlaying) {
1762
+ // 1. Update Map FIRST (Native Enqueue)
1763
+ if (handleStateChangeRef.current) {
1764
+ handleStateChangeRef.current(newState);
1765
+ }
1766
+
1767
+ // 2. Update UI Slider SECOND
1768
+ // This ensures the heavy map frame is processing while React reconciles the slider
1769
+ props.onStateChange?.(newState);
1770
+
1771
+ if (debounceTimeoutRef.current) {
1772
+ clearTimeout(debounceTimeoutRef.current);
1773
+ debounceTimeoutRef.current = null;
1774
+ }
1775
+ return;
1776
+ }
1777
+
1778
+ // --- Existing Logic for scrubbing/paused ---
1779
+
1780
+ // 1. Immediate Slider Update for responsiveness
1781
+ props.onStateChange?.(newState);
1782
+
1783
+ if (debounceTimeoutRef.current) {
1784
+ clearTimeout(debounceTimeoutRef.current);
1785
+ }
1786
+
1787
+ const prevStable = previousStateRef.current;
1788
+ const sameTimelineStable =
1789
+ prevStable &&
1790
+ (prevStable.isNexrad
1791
+ ? newState.nexradTimestamp === prevStable.nexradTimestamp &&
1792
+ newState.nexradSite === prevStable.nexradSite &&
1793
+ newState.nexradProduct === prevStable.nexradProduct &&
1794
+ Number(newState.nexradTilt) === Number(prevStable.nexradTilt) &&
1795
+ newState.nexradDurationValue === prevStable.nexradDurationValue &&
1796
+ nexradObsTimelineSig(newState) === nexradObsTimelineSig(prevStable)
1797
+ : prevStable.isSatellite
1798
+ ? newState.satelliteTimestamp === prevStable.satelliteTimestamp &&
1799
+ newState.satelliteInstrumentId === prevStable.satelliteInstrumentId &&
1800
+ newState.satelliteSectorLabel === prevStable.satelliteSectorLabel &&
1801
+ newState.satelliteChannel === prevStable.satelliteChannel &&
1802
+ newState.satelliteDurationValue === prevStable.satelliteDurationValue &&
1803
+ satelliteObsTimelineSig(newState) === satelliteObsTimelineSig(prevStable)
1804
+ : newState.forecastHour === prevStable.forecastHour &&
1805
+ newState.mrmsTimestamp === prevStable.mrmsTimestamp);
1806
+
1807
+ const sameModeStable =
1808
+ prevStable &&
1809
+ (newState.isSatellite
1810
+ ? newState.satelliteInstrumentId === prevStable.satelliteInstrumentId &&
1811
+ newState.satelliteSectorLabel === prevStable.satelliteSectorLabel &&
1812
+ newState.satelliteChannel === prevStable.satelliteChannel
1813
+ : newState.model === prevStable.model && newState.isMRMS === prevStable.isMRMS);
1814
+
1815
+ // Opacity and Play state changes should be immediate for the native layer too
1816
+ const isOpacityOnlyChange =
1817
+ prevStable &&
1818
+ sameTimelineStable &&
1819
+ sameModeStable &&
1820
+ newState.opacity !== prevStable.opacity &&
1821
+ newState.variable === prevStable.variable &&
1822
+ newState.units === prevStable.units;
1823
+
1824
+ const isPlayStateOnlyChange =
1825
+ prevStable &&
1826
+ sameTimelineStable &&
1827
+ sameModeStable &&
1828
+ newState.isPlaying !== prevStable.isPlaying &&
1829
+ newState.variable === prevStable.variable &&
1830
+ newState.units === prevStable.units &&
1831
+ newState.opacity === prevStable.opacity;
1832
+
1833
+ if (isOpacityOnlyChange || isPlayStateOnlyChange || !previousStateRef.current) {
1834
+ if (handleStateChangeRef.current) {
1835
+ handleStateChangeRef.current(newState);
1836
+ }
1837
+ return;
1838
+ }
1839
+
1840
+ debounceTimeoutRef.current = setTimeout(() => {
1841
+ if (handleStateChangeRef.current) {
1842
+ handleStateChangeRef.current(newState);
1843
+ }
1844
+ debounceTimeoutRef.current = null;
1845
+ }, 16); // ~60fps map updates
1846
+ };
1847
+
1848
+ core.on('state:change', stableHandler);
1849
+
1850
+ return () => {
1851
+ core.off('state:change', stableHandler);
1852
+ if (debounceTimeoutRef.current) {
1853
+ clearTimeout(debounceTimeoutRef.current);
1854
+ }
1855
+ if (NEXRAD_NATIVE) {
1856
+ nexradPreloadInteractionRef.current?.cancel?.();
1857
+ nexradPreloadInteractionRef.current = null;
1858
+ nexradControllerRef.current?.destroy();
1859
+ nexradControllerRef.current = null;
1860
+ }
1861
+ if (SATELLITE_NATIVE) {
1862
+ satelliteControllerRef.current?.destroy();
1863
+ satelliteControllerRef.current = null;
1864
+ }
1865
+ };
1866
+ }, [core, ensureNexradController, ensureSatelliteController]);
1867
+
1868
+ useEffect(() => {
1869
+ return () => {
1870
+ preloadedDataCache.current.clear(); // This drops JS references
1871
+ hasInitialLoad.current = false;
1872
+ lastProcessedState.current = null;
1873
+ // Native cleanup
1874
+ if (gridLayerRef.current && Platform.OS === 'ios') {
1875
+ gridLayerRef.current.clearGpuCache();
1876
+ }
1877
+ if (NEXRAD_NATIVE) {
1878
+ nexradPreloadInteractionRef.current?.cancel?.();
1879
+ nexradPreloadInteractionRef.current = null;
1880
+ nexradControllerRef.current?.destroy();
1881
+ nexradControllerRef.current = null;
1882
+ }
1883
+ if (SATELLITE_NATIVE) {
1884
+ satelliteControllerRef.current?.destroy();
1885
+ satelliteControllerRef.current = null;
1886
+ }
1887
+ };
1888
+ }, []);
1889
+
1890
+ const lastInspectorUpdateRef = useRef(0);
1891
+ const INSPECTOR_THROTTLE_MS = 50;
1892
+
1893
+ useEffect(() => {
1894
+ if (!core || !inspectorEnabled) {
1895
+ return;
1896
+ }
1897
+
1898
+ const handleMapMove = (center) => {
1899
+ if (!center || !Array.isArray(center) || center.length !== 2) {
1900
+ return;
1901
+ }
1902
+
1903
+ // Throttle updates
1904
+ const now = Date.now();
1905
+ if (now - lastInspectorUpdateRef.current < INSPECTOR_THROTTLE_MS) {
1906
+ return;
1907
+ }
1908
+ lastInspectorUpdateRef.current = now;
1909
+
1910
+ const [longitude, latitude] = center;
1911
+
1912
+ if (NEXRAD_NATIVE && core.state?.isNexrad) {
1913
+ onInspect?.(getNexradInspectPayloadAtRef.current(longitude, latitude));
1914
+ return;
1915
+ }
1916
+
1917
+ void getValueAtPointRef.current(longitude, latitude).then((payload) => {
1918
+ onInspect?.(payload);
1919
+ });
1920
+ };
1921
+
1922
+ core.on('map:move', handleMapMove);
1923
+
1924
+ if (context && context.getCenter) {
1925
+ const center = context.getCenter();
1926
+ if (center) {
1927
+ handleMapMove(center);
1928
+ }
1929
+ }
1930
+
1931
+ return () => {
1932
+ core.off('map:move', handleMapMove);
1933
+ };
1934
+ }, [inspectorEnabled, onInspect, core, context]);
1935
+
1936
+ useEffect(() => {
1937
+ if (!core || !inspectorEnabled) {
1938
+ return;
1939
+ }
1940
+
1941
+ const triggerReinspection = () => {
1942
+ const mapRef = mapRegistry.getMap();
1943
+ const center = mapRef?._currentCenter;
1944
+
1945
+ if (center && Array.isArray(center) && center.length === 2) {
1946
+ const [longitude, latitude] = center;
1947
+
1948
+ if (NEXRAD_NATIVE && core?.state?.isNexrad) {
1949
+ onInspect?.(getNexradInspectPayloadAtRef.current(longitude, latitude));
1950
+ } else {
1951
+ getValueAtPointRef.current(longitude, latitude).then((payload) => {
1952
+ onInspect?.(payload);
1953
+ });
1954
+ }
1955
+ }
1956
+ };
1957
+
1958
+ // Small delay to ensure data is loaded before re-inspecting
1959
+ const timer = setTimeout(triggerReinspection, 100);
1960
+
1961
+ return () => clearTimeout(timer);
1962
+ }, [
1963
+ core?.state?.nexradTimestamp,
1964
+ core?.state?.isNexrad,
1965
+ core?.state?.nexradSite,
1966
+ core?.state?.nexradProduct,
1967
+ core?.state?.nexradTilt,
1968
+ core?.state?.nexradDataSource,
1969
+ core?.state?.nexradStormRelative,
1970
+ core?.state?.variable,
1971
+ core?.state?.model,
1972
+ core?.state?.forecastHour,
1973
+ core?.state?.mrmsTimestamp,
1974
+ core?.state?.units,
1975
+ core?.state?.opacity,
1976
+ inspectorEnabled,
1977
+ onInspect,
1978
+ ]);
1979
+
1980
+ useEffect(() => {
1981
+ if (!core) {
1982
+ return;
1983
+ }
1984
+
1985
+ const handleCameraChange = (center) => {
1986
+ if (core && center) {
1987
+ core.setMapCenter(center);
1988
+ }
1989
+ };
1990
+
1991
+ // Register with the global registry
1992
+ mapRegistry.addCameraListener(handleCameraChange);
1993
+
1994
+ // Try to get initial center
1995
+ const mapRef = mapRegistry.getMap();
1996
+ if (mapRef?._currentCenter) {
1997
+ handleCameraChange(mapRef._currentCenter);
1998
+ }
1999
+
2000
+ return () => {
2001
+ mapRegistry.removeCameraListener(handleCameraChange);
2002
+ };
2003
+ }, [core]);
2004
+
2005
+ useEffect(() => {
2006
+ core.initialize({ autoRefresh: false }); // <-- add the argument
2007
+ return () => {
2008
+ core.destroy();
2009
+ };
2010
+ }, [core]);
2011
+
2012
+ const gridBelowId =
2013
+ belowIDFromProps ??
2014
+ context?.weatherBeforeLayerId ??
2015
+ 'AML_-_terrain';
2016
+
2017
+ return (
2018
+ <>
2019
+ <GridRenderLayer
2020
+ ref={gridLayerRef}
2021
+ opacity={renderProps.opacity}
2022
+ dataRange={renderProps.dataRange}
2023
+ belowID={gridBelowId}
2024
+ />
2025
+ {NEXRAD_NATIVE ? <NexradRadarLayer ref={nexradLayerRef} belowID={gridBelowId} /> : null}
2026
+ {SATELLITE_NATIVE ? <SatelliteLayer ref={satelliteLayerRef} belowID={gridBelowId} /> : null}
2027
+ <NwsAlertsOverlay core={core} watchesWarnings={watchesWarningsOptions} onNwsAlertClick={onNwsAlertClick} />
2028
+ {nexradSitesMapVisible ? (
2029
+ <NexradSitesMapLayer
2030
+ visible
2031
+ belowLayerID={gridBelowId}
2032
+ onSelectSite={(siteId) => void core.setNexradSite(siteId)}
2033
+ />
2034
+ ) : null}
2035
+ </>
2036
+ );
2037
+ });
2038
+
2039
+ WeatherLayerManager.getAvailableVariables = (options) => {
2040
+ if (!options || !options.apiKey) {
2041
+ return [];
2042
+ }
2043
+ const core = new AguaceroCore({ apiKey: options.apiKey });
2044
+ return core.getAvailableVariables('mrms');
2005
2045
  };