@aguacerowx/mapsgl 0.0.41 → 0.0.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aguacerowx/mapsgl",
3
- "version": "0.0.41",
3
+ "version": "0.0.43",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -8,7 +8,8 @@
8
8
  "main": "index.js",
9
9
  "type": "module",
10
10
  "scripts": {
11
- "bundle-nexrad": "esbuild src/nexrad/radarDecode.worker.ts --bundle --format=esm --platform=browser --outfile=src/nexrad/radarDecode.worker.bundled.js && esbuild src/nexrad/radarArchiveCore.ts --bundle --format=esm --platform=browser --outfile=src/nexrad/radarArchiveCore.bundled.js --external:@aguacerowx/javascript-sdk && esbuild src/nexrad/MapboxRadarLayer.ts --bundle --format=esm --platform=browser --outfile=src/nexrad/MapboxRadarLayer.bundled.js --external:mapbox-gl --external:@aguacerowx/javascript-sdk",
11
+ "prepublishOnly": "npm run bundle-nexrad",
12
+ "bundle-nexrad": "esbuild src/nexrad/radarDecode.worker.ts --bundle --format=esm --platform=browser --outfile=src/nexrad/radarDecode.worker.bundled.js && esbuild src/nexrad/radarArchiveCore.ts --bundle --format=esm --platform=browser --outfile=src/nexrad/radarArchiveCore.bundled.js --external:@aguacerowx/javascript-sdk && esbuild src/nexrad/MapboxRadarLayer.ts --bundle --format=esm --platform=browser --outfile=src/nexrad/MapboxRadarLayer.bundled.js --external:mapbox-gl --external:@aguacerowx/javascript-sdk && esbuild src/nexrad/nexradCrossSectionSampleAtLatLon.ts --format=esm --platform=browser --outfile=src/nexrad/nexradCrossSectionSampleAtLatLon.bundled.js && esbuild src/nexrad/radarFrameGpuMatch.ts --format=esm --platform=browser --outfile=src/nexrad/radarFrameGpuMatch.bundled.js",
12
13
  "gen:nws-key": "esbuild ../../../aguacero-frontend/src/components/WarningsMenu/nwsWarningCustomizationKey.ts --bundle --format=esm --platform=neutral --outfile=src/nwsWarningCustomizationKey.gen.js"
13
14
  },
14
15
  "files": [
@@ -6,8 +6,8 @@ import { getUnitConversionFunction, getDefaultRadarTilt } from '@aguacerowx/java
6
6
  import { fetchAndParseArchive, objectKeyToUrl, setNexradArchiveApiKey } from './nexrad/radarArchiveCore.bundled.js';
7
7
  import { MapboxRadarLayer } from './nexrad/MapboxRadarLayer.bundled.js';
8
8
  import { nexradBinGroupIdForKey, variableToNexradGroup } from '@aguacerowx/javascript-sdk';
9
- import { sampleNexradFrameAtLatLon } from './nexrad/nexradCrossSectionSampleAtLatLon.ts';
10
- import { prepareRadarFrameForGpuReadout } from './nexrad/radarFrameGpuMatch.ts';
9
+ import { sampleNexradFrameAtLatLon } from './nexrad/nexradCrossSectionSampleAtLatLon.bundled.js';
10
+ import { prepareRadarFrameForGpuReadout } from './nexrad/radarFrameGpuMatch.bundled.js';
11
11
  import { mapboxFrameUploadOptionsForNexradState } from './nexrad/nexradMapboxFrameOpts.js';
12
12
 
13
13
  function pickNearestLevel3ObjectKey(unixTime, timeToKeyMap, maxDeltaSec = 600) {
@@ -8,6 +8,48 @@ import { NexradSitesOverlay } from './NexradSitesOverlay.js';
8
8
  import { NwsWatchesWarningsOverlay } from './NwsWatchesWarningsOverlay.js';
9
9
  import WorkerPool from './WorkerPool.js';
10
10
 
11
+ const DEBUG_NS = '[WeatherLayerManager:debug]';
12
+
13
+ /**
14
+ * Redact secrets for console output.
15
+ * @param {object} options
16
+ */
17
+ function _debugSanitizeOptions(options) {
18
+ if (!options || typeof options !== 'object') return options;
19
+ const o = { ...options };
20
+ if (typeof o.apiKey === 'string' && o.apiKey.length > 0) {
21
+ o.apiKey = `${o.apiKey.slice(0, 4)}…(${o.apiKey.length} chars)`;
22
+ }
23
+ return o;
24
+ }
25
+
26
+ /**
27
+ * Compact summary of timestamp / hour arrays (MRMS timeline debugging).
28
+ * @param {unknown[]} arr
29
+ */
30
+ function _debugSummarizeNumericSeries(arr) {
31
+ if (!Array.isArray(arr) || arr.length === 0) {
32
+ return { length: 0, min: null, max: null, spanSec: null, head: [], tail: undefined };
33
+ }
34
+ const nums = arr.map((x) => Number(x)).filter((n) => !Number.isNaN(n));
35
+ if (nums.length === 0) {
36
+ return { length: 0, min: null, max: null, spanSec: null, head: [], tail: undefined };
37
+ }
38
+ const sorted = [...nums].sort((a, b) => a - b);
39
+ const min = sorted[0];
40
+ const max = sorted[sorted.length - 1];
41
+ const head = sorted.slice(0, Math.min(8, sorted.length));
42
+ const tail = sorted.length > 16 ? sorted.slice(-8) : [];
43
+ return {
44
+ length: sorted.length,
45
+ min,
46
+ max,
47
+ spanSec: max != null && min != null ? max - min : null,
48
+ head,
49
+ tail: tail.length ? tail : undefined,
50
+ };
51
+ }
52
+
11
53
  function findLatestModelRun(modelsData, modelName) {
12
54
  const model = modelsData?.[modelName];
13
55
  if (!model) return null;
@@ -33,12 +75,17 @@ function findLatestModelRun(modelsData, modelName) {
33
75
  * @param {string} [options.belowID] - Style layer id to insert Aguacero weather layers **below** (default `AML_-_terrain` when present). Alias of `weatherBeforeLayerId`.
34
76
  * @param {string} [options.weatherBeforeLayerId] - Same as `belowID`.
35
77
  * @param {string} [options.nexradLayerId] - Override Mapbox id for the NEXRAD custom layer (default: derived from `layerId`).
78
+ * @param {boolean} [options.debug] - When `true`, logs detailed diagnostics to the console (prefix `[WeatherLayerManager:debug]`). Off by default.
36
79
  */
37
80
  export class WeatherLayerManager extends EventEmitter {
38
81
  constructor(map, options = {}) {
39
82
  super();
40
83
  if (!map) throw new Error('A Mapbox GL map instance is required.');
41
84
  this.map = map;
85
+ /** @private When true, emit verbose `[WeatherLayerManager:debug]` logs. */
86
+ this._debug = options.debug === true;
87
+ /** @private Monotonic counter for correlating state:change logs. */
88
+ this._debugStateSeq = 0;
42
89
  this.layerId =
43
90
  options.layerId ||
44
91
  options.id ||
@@ -109,6 +156,29 @@ export class WeatherLayerManager extends EventEmitter {
109
156
  // 1. CREATE an instance of the core engine
110
157
  this.core = new AguaceroCore(options);
111
158
 
159
+ if (this._debug) {
160
+ const layerOpts = options.layerOptions || {};
161
+ console.log(DEBUG_NS, 'constructor', {
162
+ layerId: this.layerId,
163
+ nexradLayerId: this._nexradLayerId,
164
+ mapLoaded: typeof this.map?.loaded === 'function' ? this.map.loaded() : undefined,
165
+ styleLoaded: this.map?.isStyleLoaded?.() ?? undefined,
166
+ weatherBeforeLayerId: this._weatherBeforeLayerId,
167
+ options: _debugSanitizeOptions(options),
168
+ layerOptions: layerOpts,
169
+ coreStateSnapshot: {
170
+ isMRMS: this.core.state?.isMRMS,
171
+ isSatellite: this.core.state?.isSatellite,
172
+ isNexrad: this.core.state?.isNexrad,
173
+ model: this.core.state?.model,
174
+ variable: this.core.state?.variable,
175
+ mrmsDurationValue: this.core.state?.mrmsDurationValue,
176
+ mrmsTimestamp: this.core.state?.mrmsTimestamp,
177
+ satelliteDurationValue: this.core.state?.satelliteDurationValue,
178
+ },
179
+ });
180
+ }
181
+
112
182
  // 2. LISTEN for events from the core engine
113
183
  this.core.on('state:change', (newState) => {
114
184
  this._lastEmittedState = newState;
@@ -128,6 +198,20 @@ export class WeatherLayerManager extends EventEmitter {
128
198
  this.map.on('mousemove', this._handleMouseMove);
129
199
  }
130
200
 
201
+ /**
202
+ * Structured debug log (no-op unless `options.debug === true` on construction).
203
+ * @param {string} scope
204
+ * @param {object} [data]
205
+ */
206
+ _debugLog(scope, data) {
207
+ if (!this._debug) return;
208
+ if (data !== undefined) {
209
+ console.log(DEBUG_NS, scope, data);
210
+ } else {
211
+ console.log(DEBUG_NS, scope);
212
+ }
213
+ }
214
+
131
215
  /**
132
216
  * Resolves which style layer id to pass as Mapbox `addLayer(..., beforeId)` for satellite / grid / NEXRAD.
133
217
  * Prefers an explicit id (custom styles), then the default Aguacero `AML_-_terrain` anchor when present.
@@ -174,9 +258,39 @@ export class WeatherLayerManager extends EventEmitter {
174
258
  * @private
175
259
  */
176
260
  _handleStateChange(state) {
261
+ const seq = this._debug ? ++this._debugStateSeq : 0;
177
262
  /** NEXRAD setup is async (`sites.show`, frame fetch). `finally` would run before the radar layer exists, so NWS fill stacks above terrain instead of under the custom layer — defer sync until the handler settles. */
178
263
  let deferNwsSyncUntilNexradReady = false;
179
264
  try {
265
+ if (this._debug) {
266
+ const tsSummary = state.isMRMS
267
+ ? _debugSummarizeNumericSeries(state.availableTimestamps || [])
268
+ : null;
269
+ this._debugLog('state:change', {
270
+ seq,
271
+ mode: state.isSatellite
272
+ ? 'satellite'
273
+ : state.isNexrad
274
+ ? 'nexrad'
275
+ : state.isMRMS
276
+ ? 'mrms'
277
+ : 'model',
278
+ model: state.model,
279
+ variable: state.variable,
280
+ runKey: `${state.model}-${state.date}-${state.run}-${state.variable}`,
281
+ timeKey: state.isMRMS
282
+ ? (state.mrmsTimestamp == null ? null : Number(state.mrmsTimestamp))
283
+ : Number(state.forecastHour),
284
+ mrmsDurationValue: state.mrmsDurationValue,
285
+ availableTimestampsSummary: tsSummary,
286
+ availableHoursLen: Array.isArray(state.availableHours) ? state.availableHours.length : 0,
287
+ shaderLayerRunKey: this.shaderLayer?.runKey ?? null,
288
+ currentLoadedTimeKey: this.currentLoadedTimeKey,
289
+ rebuildId: this.currentRebuildId,
290
+ initialGridLoadPending: this._initialGridLoadPending,
291
+ });
292
+ }
293
+
180
294
  if (state.isSatellite) {
181
295
  this._handleSatelliteStateChange(state);
182
296
  return;
@@ -206,6 +320,21 @@ export class WeatherLayerManager extends EventEmitter {
206
320
  : Number(state.forecastHour);
207
321
  const runKey = `${state.model}-${state.date}-${state.run}-${state.variable}`;
208
322
 
323
+ if (this._debug && state.isMRMS) {
324
+ this._debugLog('grid path (mrms/model)', {
325
+ seq,
326
+ prevMrmsDurationValue: prevMrmsDur,
327
+ mrmsDurationChanged,
328
+ willRebuild: !this.shaderLayer || this.shaderLayer.runKey !== runKey,
329
+ willUpdateData:
330
+ this.shaderLayer &&
331
+ this.shaderLayer.runKey === runKey &&
332
+ this.currentLoadedTimeKey !== timeKey,
333
+ shaderRunKey: this.shaderLayer?.runKey,
334
+ targetRunKey: runKey,
335
+ });
336
+ }
337
+
209
338
  if (!this.shaderLayer || this.shaderLayer.runKey !== runKey) {
210
339
  this._rebuildLayerAndPreload(state);
211
340
  } else if (this.currentLoadedTimeKey !== timeKey) {
@@ -215,6 +344,12 @@ export class WeatherLayerManager extends EventEmitter {
215
344
  timeKey === this._rebuildTargetTimeKey;
216
345
  if (!duplicateBeforeFirstPaint) {
217
346
  this._updateLayerData(state);
347
+ } else if (this._debug) {
348
+ this._debugLog('skip _updateLayerData (duplicate before first paint)', {
349
+ seq,
350
+ timeKey,
351
+ _rebuildTargetTimeKey: this._rebuildTargetTimeKey,
352
+ });
218
353
  }
219
354
  }
220
355
 
@@ -225,6 +360,13 @@ export class WeatherLayerManager extends EventEmitter {
225
360
  }
226
361
 
227
362
  if (state.isMRMS && this.shaderLayer && mrmsDurationChanged) {
363
+ if (this._debug) {
364
+ this._debugLog('_preloadAllTimeSteps (mrms duration changed)', {
365
+ seq,
366
+ from: prevMrmsDur,
367
+ to: state.mrmsDurationValue,
368
+ });
369
+ }
228
370
  this._preloadAllTimeSteps(state);
229
371
  }
230
372
  } finally {
@@ -638,10 +780,68 @@ export class WeatherLayerManager extends EventEmitter {
638
780
  // to the core engine. This keeps the API consistent for your users.
639
781
 
640
782
  async initialize(options) {
783
+ const t0 =
784
+ typeof performance !== 'undefined' && typeof performance.now === 'function'
785
+ ? performance.now()
786
+ : Date.now();
787
+ this._debugLog('initialize:start', {
788
+ autoRefreshEnabled: this.autoRefreshEnabled,
789
+ autoRefreshIntervalSeconds: this.autoRefreshIntervalSeconds,
790
+ passedOptions: options && typeof options === 'object' ? { ...options } : options,
791
+ coreStateBefore: {
792
+ isMRMS: this.core.state?.isMRMS,
793
+ variable: this.core.state?.variable,
794
+ mrmsTimestamp: this.core.state?.mrmsTimestamp,
795
+ mrmsDurationValue: this.core.state?.mrmsDurationValue,
796
+ },
797
+ mrmsStatusVariableLen: this.core.state?.variable
798
+ ? (this.core.mrmsStatus?.[this.core.state.variable]?.length ?? 'n/a')
799
+ : 'n/a',
800
+ });
641
801
  if (this.autoRefreshEnabled) {
642
802
  this.setAutoRefresh(true, this.autoRefreshIntervalSeconds);
643
803
  }
644
- return this.core.initialize({ ...options, autoRefresh: false }); // <-- add spread + autoRefresh: false
804
+ try {
805
+ const result = await this.core.initialize({ ...options, autoRefresh: false });
806
+ const t1 =
807
+ typeof performance !== 'undefined' && typeof performance.now === 'function'
808
+ ? performance.now()
809
+ : Date.now();
810
+ this._debugLog('initialize:done', {
811
+ elapsedMs: Math.round(t1 - t0),
812
+ coreStateAfter: {
813
+ isMRMS: this.core.state?.isMRMS,
814
+ variable: this.core.state?.variable,
815
+ model: this.core.state?.model,
816
+ date: this.core.state?.date,
817
+ run: this.core.state?.run,
818
+ forecastHour: this.core.state?.forecastHour,
819
+ mrmsTimestamp: this.core.state?.mrmsTimestamp,
820
+ mrmsDurationValue: this.core.state?.mrmsDurationValue,
821
+ },
822
+ availableTimestampsSummary: _debugSummarizeNumericSeries(
823
+ this._lastEmittedState?.availableTimestamps || [],
824
+ ),
825
+ availableHoursLen: Array.isArray(this._lastEmittedState?.availableHours)
826
+ ? this._lastEmittedState.availableHours.length
827
+ : 0,
828
+ modelStatusLoaded: this.core.modelStatus != null,
829
+ mrmsStatusKeys:
830
+ this.core.mrmsStatus && typeof this.core.mrmsStatus === 'object'
831
+ ? Object.keys(this.core.mrmsStatus).length
832
+ : 0,
833
+ mrmsStatusVariableLen: this.core.state?.variable
834
+ ? (this.core.mrmsStatus?.[this.core.state.variable]?.length ?? 0)
835
+ : 0,
836
+ });
837
+ return result;
838
+ } catch (err) {
839
+ this._debugLog('initialize:error', {
840
+ message: err?.message || String(err),
841
+ stack: err?.stack,
842
+ });
843
+ throw err;
844
+ }
645
845
  }
646
846
  async setState(newState) { return this.core.setState(newState); }
647
847
  play() { this.core.play(); }
@@ -722,7 +922,211 @@ export class WeatherLayerManager extends EventEmitter {
722
922
  // the active time.
723
923
  }
724
924
 
725
- async _rebuildLayerAndPreload(state) {
925
+ /**
926
+ * MRMS timestamps or model forecast hours for the active timeline (ordering preserved from source lists).
927
+ * @returns {number[]}
928
+ */
929
+ _collectNormalizedTimelineSteps(state) {
930
+ let fromCore = [];
931
+ try {
932
+ if (!state.isMRMS && typeof this.core.getAvailableForecastHours === 'function') {
933
+ fromCore = this.core.getAvailableForecastHours();
934
+ }
935
+ } catch (err) {
936
+ // ignore
937
+ }
938
+
939
+ const fromState = state.isMRMS
940
+ ? (state.availableTimestamps || [])
941
+ : (state.availableHours || []);
942
+
943
+ let timeSteps;
944
+ let mrmsTimelineSource = '';
945
+ if (state.isMRMS) {
946
+ if (fromState.length) {
947
+ timeSteps = fromState;
948
+ mrmsTimelineSource = 'state.availableTimestamps';
949
+ } else if (
950
+ state.variable &&
951
+ typeof this.core._getFilteredMrmsTimestampsForVariable === 'function'
952
+ ) {
953
+ /**
954
+ * Same list {@link AguaceroCore} puts on `state:change` as `availableTimestamps`.
955
+ * When that array is missing or empty on this snapshot (ordering / first paint),
956
+ * derive from core so rebuild preload is not stuck with only `currentFrameTime`.
957
+ */
958
+ try {
959
+ timeSteps = this.core._getFilteredMrmsTimestampsForVariable(state.variable);
960
+ mrmsTimelineSource = 'core._getFilteredMrmsTimestampsForVariable';
961
+ } catch {
962
+ timeSteps = fromCore;
963
+ mrmsTimelineSource = 'core forecast hours fallback (error)';
964
+ }
965
+ } else {
966
+ timeSteps = fromCore;
967
+ mrmsTimelineSource = 'empty';
968
+ }
969
+ } else {
970
+ timeSteps = fromCore.length > 0 ? fromCore : fromState;
971
+ }
972
+
973
+ const out = (timeSteps || [])
974
+ .map(t => Number(t))
975
+ .filter(t => !Number.isNaN(t));
976
+ if (this._debug && state.isMRMS) {
977
+ this._debugLog('_collectNormalizedTimelineSteps', {
978
+ variable: state.variable,
979
+ mrmsDurationValue: state.mrmsDurationValue,
980
+ fromStateLen: fromState.length,
981
+ fromCoreLen: fromCore.length,
982
+ chosenSource: mrmsTimelineSource || (state.isMRMS ? 'n/a' : 'model'),
983
+ normalizedLen: out.length,
984
+ summary: _debugSummarizeNumericSeries(out),
985
+ });
986
+ }
987
+ return out;
988
+ }
989
+
990
+ /**
991
+ * @param {object} state
992
+ * @param {number[]} times
993
+ * @param {{ rebuildId: number, mode: 'rebuild' | 'append' }} options
994
+ */
995
+ _runParallelGridFrameLoads(state, times, options) {
996
+ const { rebuildId, mode } = options;
997
+ if (!times.length || !this.shaderLayer) {
998
+ if (mode === 'rebuild') {
999
+ this._initialGridLoadPending = false;
1000
+ }
1001
+ this._debugLog('_runParallelGridFrameLoads:skip', {
1002
+ reason: !times.length ? 'no times' : 'no shaderLayer',
1003
+ mode,
1004
+ rebuildId,
1005
+ timesLen: times.length,
1006
+ });
1007
+ return;
1008
+ }
1009
+
1010
+ const currentFrameTime = Number(state.isMRMS ? state.mrmsTimestamp : state.forecastHour);
1011
+ /** When the active timestep is unset/NaN, paint the first timeline step (legacy single-fetch behavior). */
1012
+ let primaryTimeForRebuild = Number.NaN;
1013
+ if (mode === 'rebuild') {
1014
+ primaryTimeForRebuild = Number.isFinite(currentFrameTime)
1015
+ ? currentFrameTime
1016
+ : times[0];
1017
+ }
1018
+ const tsKey = state.isMRMS ? 'mrmsTimestamp' : 'forecastHour';
1019
+ const gridModel = state.isMRMS ? 'mrms' : state.model;
1020
+ const { gridDef } = this.core._getGridCornersAndDef(gridModel);
1021
+
1022
+ this._debugLog('_runParallelGridFrameLoads', {
1023
+ mode,
1024
+ rebuildId,
1025
+ gridModel,
1026
+ timesLen: times.length,
1027
+ timesSummary: _debugSummarizeNumericSeries(times),
1028
+ currentFrameTime,
1029
+ primaryTimeForRebuild:
1030
+ mode === 'rebuild'
1031
+ ? Number.isFinite(currentFrameTime)
1032
+ ? currentFrameTime
1033
+ : times[0]
1034
+ : undefined,
1035
+ tsKey,
1036
+ gridNxNy: gridDef?.grid_params
1037
+ ? { nx: gridDef.grid_params.nx, ny: gridDef.grid_params.ny }
1038
+ : null,
1039
+ });
1040
+
1041
+ times.forEach((time) => {
1042
+ const stateForTime = { ...state, [tsKey]: time };
1043
+ this.core._loadGridData(stateForTime)
1044
+ .then((grid) => {
1045
+ if (rebuildId !== this.currentRebuildId || !this.shaderLayer) {
1046
+ return;
1047
+ }
1048
+
1049
+ const isPrimaryFrame =
1050
+ mode === 'rebuild' &&
1051
+ Number.isFinite(primaryTimeForRebuild) &&
1052
+ time === primaryTimeForRebuild;
1053
+
1054
+ if (isPrimaryFrame) {
1055
+ const coreTimeKey = state.isMRMS
1056
+ ? (this.core.state.mrmsTimestamp == null
1057
+ ? null
1058
+ : Number(this.core.state.mrmsTimestamp))
1059
+ : Number(this.core.state.forecastHour);
1060
+ if (coreTimeKey !== this._rebuildTargetTimeKey) {
1061
+ this._debugLog('_loadGridData:skip primary (core time drifted vs rebuild target)', {
1062
+ time,
1063
+ coreTimeKey,
1064
+ _rebuildTargetTimeKey: this._rebuildTargetTimeKey,
1065
+ rebuildId,
1066
+ });
1067
+ return;
1068
+ }
1069
+ if (!grid?.data) {
1070
+ this._debugLog('_loadGridData:primary frame missing grid.data', {
1071
+ time,
1072
+ rebuildId,
1073
+ mode,
1074
+ });
1075
+ this._initialGridLoadPending = false;
1076
+ return;
1077
+ }
1078
+ this.shaderLayer.updateDataTexture(
1079
+ grid.data,
1080
+ grid.encoding,
1081
+ gridDef.grid_params.nx,
1082
+ gridDef.grid_params.ny,
1083
+ );
1084
+ this.currentLoadedTimeKey = time;
1085
+ this.shaderLayer.registerCurrentDataTextureAsPreloaded(time);
1086
+ this._initialGridLoadPending = false;
1087
+ this.map.triggerRepaint();
1088
+ return;
1089
+ }
1090
+
1091
+ if (grid?.data) {
1092
+ this.shaderLayer.storePreloadedTexture(
1093
+ time,
1094
+ grid.data,
1095
+ grid.encoding,
1096
+ gridDef.grid_params.nx,
1097
+ gridDef.grid_params.ny,
1098
+ );
1099
+ const s = this.core.state;
1100
+ const activeTime = s.isMRMS
1101
+ ? (s.mrmsTimestamp == null ? null : Number(s.mrmsTimestamp))
1102
+ : Number(s.forecastHour);
1103
+ if (time === activeTime && this.shaderLayer.switchToPreloadedTexture(time)) {
1104
+ this.currentLoadedTimeKey = time;
1105
+ this.map.triggerRepaint();
1106
+ }
1107
+ } else if (this._debug && mode === 'append') {
1108
+ this._debugLog('_loadGridData:empty grid (append)', { time, mode, rebuildId });
1109
+ }
1110
+ })
1111
+ .catch((err) => {
1112
+ this._debugLog('_loadGridData:error', {
1113
+ time,
1114
+ mode,
1115
+ rebuildId,
1116
+ message: err?.message || String(err),
1117
+ });
1118
+ if (
1119
+ mode === 'rebuild' &&
1120
+ Number.isFinite(primaryTimeForRebuild) &&
1121
+ time === primaryTimeForRebuild
1122
+ ) {
1123
+ this._initialGridLoadPending = false;
1124
+ }
1125
+ });
1126
+ });
1127
+ }
1128
+
1129
+ _rebuildLayerAndPreload(state) {
726
1130
  if (state.isSatellite) {
727
1131
  return;
728
1132
  }
@@ -775,119 +1179,69 @@ export class WeatherLayerManager extends EventEmitter {
775
1179
  : Number(state.forecastHour);
776
1180
  this._initialGridLoadPending = true;
777
1181
 
778
- let grid;
779
- try {
780
- grid = await this.core._loadGridData(state);
781
- } catch (e) {
782
- this._initialGridLoadPending = false;
783
- throw e;
784
- }
785
-
786
- if (rebuildId !== this.currentRebuildId) {
787
- this._initialGridLoadPending = false;
788
- return;
789
- }
790
-
791
- const coreTimeKey = state.isMRMS
792
- ? (this.core.state.mrmsTimestamp == null ? null : Number(this.core.state.mrmsTimestamp))
793
- : Number(this.core.state.forecastHour);
794
- if (coreTimeKey !== this._rebuildTargetTimeKey) {
1182
+ const normalized = this._collectNormalizedTimelineSteps(state);
1183
+ const currentFrameTime = Number(state.isMRMS ? state.mrmsTimestamp : state.forecastHour);
1184
+ const timesSet = new Set(normalized);
1185
+ if (!Number.isNaN(currentFrameTime)) {
1186
+ timesSet.add(currentFrameTime);
1187
+ }
1188
+ let timesToLoad = [...timesSet];
1189
+ if (timesToLoad.length === 0 && !Number.isNaN(currentFrameTime)) {
1190
+ timesToLoad = [currentFrameTime];
1191
+ }
1192
+ this._debugLog('_rebuildLayerAndPreload:timeline', {
1193
+ rebuildId,
1194
+ isMRMS: state.isMRMS,
1195
+ variable: state.variable,
1196
+ normalizedLen: normalized.length,
1197
+ normalizedSummary: _debugSummarizeNumericSeries(normalized),
1198
+ currentFrameTime,
1199
+ timesToLoadLen: timesToLoad.length,
1200
+ timesToLoadSummary: _debugSummarizeNumericSeries(timesToLoad),
1201
+ insertBeforeId: beforeId ?? '(stack top)',
1202
+ });
1203
+ if (timesToLoad.length === 0) {
795
1204
  this._initialGridLoadPending = false;
1205
+ this._debugLog('_rebuildLayerAndPreload:no times to load — check availableTimestamps / duration window', {
1206
+ rebuildId,
1207
+ availableTimestampsLen: Array.isArray(state.availableTimestamps)
1208
+ ? state.availableTimestamps.length
1209
+ : 0,
1210
+ mrmsDurationValue: state.mrmsDurationValue,
1211
+ });
796
1212
  return;
797
1213
  }
798
1214
 
799
- if (grid && grid.data) {
800
- const gridModel = state.isMRMS ? 'mrms' : state.model;
801
- const { gridDef } = this.core._getGridCornersAndDef(gridModel);
802
-
803
- this.shaderLayer.updateDataTexture(
804
- grid.data, grid.encoding,
805
- gridDef.grid_params.nx, gridDef.grid_params.ny
806
- );
807
-
808
- this.currentLoadedTimeKey = state.isMRMS
809
- ? (state.mrmsTimestamp == null ? null : Number(state.mrmsTimestamp))
810
- : Number(state.forecastHour);
811
- this.shaderLayer.registerCurrentDataTextureAsPreloaded(this.currentLoadedTimeKey);
812
- this.map.triggerRepaint();
813
- }
814
-
815
- this._initialGridLoadPending = false;
816
-
817
1215
  if (rebuildId === this.currentRebuildId) {
818
- this._preloadAllTimeSteps(state);
1216
+ this._runParallelGridFrameLoads(state, timesToLoad, { rebuildId, mode: 'rebuild' });
819
1217
  }
820
1218
  }
821
-
822
- _preloadAllTimeSteps(state) {
823
- let fromCore = [];
824
- try {
825
- if (!state.isMRMS && typeof this.core.getAvailableForecastHours === 'function') {
826
- fromCore = this.core.getAvailableForecastHours();
827
- }
828
- } catch (err) {
829
- // ignore
830
- }
831
-
832
- const fromState = state.isMRMS
833
- ? (state.availableTimestamps || [])
834
- : (state.availableHours || []);
835
-
836
- let timeSteps;
837
- if (state.isMRMS) {
838
- timeSteps = fromState.length ? fromState : fromCore;
839
- } else {
840
- timeSteps = fromCore.length > 0 ? fromCore : fromState;
841
- }
842
-
843
- const normalized = (timeSteps || [])
844
- .map(t => Number(t))
845
- .filter(t => !Number.isNaN(t));
846
1219
 
1220
+ _preloadAllTimeSteps(state) {
1221
+ const normalized = this._collectNormalizedTimelineSteps(state);
847
1222
  const currentFrameTime = Number(state.isMRMS ? state.mrmsTimestamp : state.forecastHour);
848
1223
  const stepsToPreload = normalized.filter(t => t !== currentFrameTime);
849
1224
 
1225
+ this._debugLog('_preloadAllTimeSteps', {
1226
+ normalizedLen: normalized.length,
1227
+ currentFrameTime,
1228
+ stepsToPreloadLen: stepsToPreload.length,
1229
+ stepsSummary: _debugSummarizeNumericSeries(stepsToPreload),
1230
+ });
1231
+
850
1232
  if (normalized.length === 0) {
851
1233
  return;
852
1234
  }
853
1235
 
854
1236
  if (stepsToPreload.length === 0) {
1237
+ this._debugLog('_preloadAllTimeSteps:skip (nothing to preload besides current)', {
1238
+ currentFrameTime,
1239
+ });
855
1240
  return;
856
1241
  }
857
1242
 
858
1243
  const capturedRebuildId = this.currentRebuildId;
859
-
860
- const gridModel = state.isMRMS ? 'mrms' : state.model;
861
- const { gridDef } = this.core._getGridCornersAndDef(gridModel);
862
-
863
- stepsToPreload.forEach(time => {
864
- const stateForTime = {
865
- ...state,
866
- [state.isMRMS ? 'mrmsTimestamp' : 'forecastHour']: time
867
- };
868
-
869
- this.core._loadGridData(stateForTime)
870
- .then(grid => {
871
- if (capturedRebuildId !== this.currentRebuildId) {
872
- return;
873
- }
874
- if (grid?.data && this.shaderLayer) {
875
- this.shaderLayer.storePreloadedTexture(
876
- time, grid.data, grid.encoding,
877
- gridDef.grid_params.nx, gridDef.grid_params.ny
878
- );
879
- const s = this.core.state;
880
- const activeTime = s.isMRMS
881
- ? (s.mrmsTimestamp == null ? null : Number(s.mrmsTimestamp))
882
- : Number(s.forecastHour);
883
- if (time === activeTime && this.shaderLayer.switchToPreloadedTexture(time)) {
884
- this.currentLoadedTimeKey = time;
885
- this.map.triggerRepaint();
886
- }
887
- }
888
- })
889
- .catch(() => {});
890
- });
1244
+ this._runParallelGridFrameLoads(state, stepsToPreload, { rebuildId: capturedRebuildId, mode: 'append' });
891
1245
  }
892
1246
 
893
1247
  _updateLayerData(state) {
@@ -1124,6 +1478,7 @@ export class WeatherLayerManager extends EventEmitter {
1124
1478
  * Cleans up all resources.
1125
1479
  */
1126
1480
  destroy() {
1481
+ this._debugLog('destroy');
1127
1482
  this.setAutoRefresh(false);
1128
1483
 
1129
1484
  // 1. Unbind the map's mousemove event listener
@@ -0,0 +1,91 @@
1
+ function wrapAzimuthDeltaDeg(azDeg, azimuthBaseDeg) {
2
+ let da = azDeg - azimuthBaseDeg;
3
+ da -= Math.floor(da / 360) * 360;
4
+ if (da < 0) da += 360;
5
+ return da;
6
+ }
7
+ function snapGateCoord(t, nCells) {
8
+ if (nCells <= 1) return 0;
9
+ const idx = Math.floor(t * (nCells - 1) + 0.5);
10
+ return idx / (nCells - 1);
11
+ }
12
+ function readGateRawSigned(frame, rayIdx, gateIdx) {
13
+ if (rayIdx < 0 || gateIdx < 0 || rayIdx >= frame.nRays || gateIdx >= frame.nGates) return null;
14
+ const byteOffset = (rayIdx * frame.nGates + gateIdx) * 2;
15
+ const hi = frame.gateData[byteOffset];
16
+ const lo = frame.gateData[byteOffset + 1];
17
+ const raw = lo + hi * 256;
18
+ const rawSigned = raw >= 32768 ? raw - 65536 : raw;
19
+ if (rawSigned <= -32768) return null;
20
+ return rawSigned;
21
+ }
22
+ function sampleGateRawBilinearDecoded(frame, gateX, gateY) {
23
+ const w = frame.nGates;
24
+ const h = frame.nRays;
25
+ if (w < 1 || h < 1) return null;
26
+ const gx = Math.min(1, Math.max(0, gateX));
27
+ const gy = Math.min(1, Math.max(0, gateY));
28
+ const sx = gx * Math.max(w - 1, 1);
29
+ const sy = gy * Math.max(h - 1, 1);
30
+ const i0 = Math.floor(sx);
31
+ const j0 = Math.floor(sy);
32
+ const i1 = Math.min(i0 + 1, w - 1);
33
+ const j1 = Math.min(j0 + 1, h - 1);
34
+ const fx = sx - i0;
35
+ const fy = sy - j0;
36
+ const w00 = (1 - fx) * (1 - fy);
37
+ const w10 = fx * (1 - fy);
38
+ const w01 = (1 - fx) * fy;
39
+ const w11 = fx * fy;
40
+ let acc = 0;
41
+ let wsum = 0;
42
+ const add = (r, wt) => {
43
+ if (r !== null) {
44
+ acc += r * wt;
45
+ wsum += wt;
46
+ }
47
+ };
48
+ add(readGateRawSigned(frame, j0, i0), w00);
49
+ add(readGateRawSigned(frame, j0, i1), w10);
50
+ add(readGateRawSigned(frame, j1, i0), w01);
51
+ add(readGateRawSigned(frame, j1, i1), w11);
52
+ if (wsum < 1e-6) return null;
53
+ return acc / wsum;
54
+ }
55
+ function sampleNexradFrameAtLatLon(frame, lat, lon, options) {
56
+ const DEG_TO_RAD = Math.PI / 180;
57
+ const EARTH_RADIUS_M = 6378137;
58
+ const dLatDeg = lat - frame.stationLat;
59
+ const dLonDeg = lon - frame.stationLon;
60
+ const cosLat = Math.max(Math.cos(lat * DEG_TO_RAD), 1e-6);
61
+ const xM = dLonDeg * DEG_TO_RAD * EARTH_RADIUS_M * cosLat;
62
+ const yM = dLatDeg * DEG_TO_RAD * EARTH_RADIUS_M;
63
+ const rangeM = Math.hypot(xM, yM);
64
+ const rangeKm = rangeM / 1e3;
65
+ if (rangeKm < frame.firstGateKm) return null;
66
+ const spanKm = frame.nGates * frame.gateWidthKm;
67
+ if (!(spanKm > 0)) return null;
68
+ const maxRangeKm = frame.firstGateKm + spanKm;
69
+ if (rangeKm >= maxRangeKm) return null;
70
+ let azDeg = Math.atan2(xM, yM) * (180 / Math.PI);
71
+ if (azDeg < 0) azDeg += 360;
72
+ const boundaries = frame.rayBoundariesDeg;
73
+ const azimuthBaseDeg = boundaries.length > 0 ? Number(boundaries[0]) : 0;
74
+ const da = wrapAzimuthDeltaDeg(azDeg, azimuthBaseDeg);
75
+ let gateY = da / 360;
76
+ gateY = Math.min(1, Math.max(0, gateY));
77
+ let gateX = (rangeKm - frame.firstGateKm) / spanKm;
78
+ gateX = Math.min(1, Math.max(0, gateX));
79
+ const smoothPolar = options?.smoothPolar === true;
80
+ if (!smoothPolar) {
81
+ gateX = snapGateCoord(gateX, frame.nGates);
82
+ gateY = snapGateCoord(gateY, frame.nRays);
83
+ }
84
+ const rawSigned = sampleGateRawBilinearDecoded(frame, gateX, gateY);
85
+ if (rawSigned === null) return null;
86
+ const physical = rawSigned * frame.valueScale + frame.valueOffset;
87
+ return { value: physical, groundRangeKm: rangeKm };
88
+ }
89
+ export {
90
+ sampleNexradFrameAtLatLon
91
+ };
@@ -0,0 +1,79 @@
1
+ function angularDistanceDeg(a, b) {
2
+ let d = Math.abs(a - b) % 360;
3
+ if (d > 180) d = 360 - d;
4
+ return d;
5
+ }
6
+ function canonicalBinsRadarFrame(frame) {
7
+ const nRays = frame.nRays;
8
+ const nGates = frame.nGates;
9
+ if (nRays <= 0 || nGates <= 0) return frame;
10
+ if (frame.rayBoundariesDeg.length < nRays + 1) return frame;
11
+ const degPerBin = 360 / nRays;
12
+ const bytesPerRay = nGates * 2;
13
+ const centers = new Float32Array(nRays);
14
+ for (let r = 0; r < nRays; r++) {
15
+ const lower = frame.rayBoundariesDeg[r];
16
+ const upper = frame.rayBoundariesDeg[r + 1];
17
+ const center = (lower + upper) * 0.5;
18
+ centers[r] = (center % 360 + 360) % 360;
19
+ }
20
+ const canonicalGateData = new Uint8Array(nRays * bytesPerRay);
21
+ for (let i = 0; i < canonicalGateData.length; i += 2) {
22
+ canonicalGateData[i] = 128;
23
+ }
24
+ const used = new Array(nRays).fill(false);
25
+ for (let bin = 0; bin < nRays; bin++) {
26
+ const targetDeg = ((bin + 0.5) * degPerBin % 360 + 360) % 360;
27
+ let bestR = -1;
28
+ let bestDist = Infinity;
29
+ for (let r = 0; r < nRays; r++) {
30
+ if (used[r]) continue;
31
+ const dist = angularDistanceDeg(centers[r], targetDeg);
32
+ if (dist < bestDist) {
33
+ bestDist = dist;
34
+ bestR = r;
35
+ }
36
+ }
37
+ if (bestR >= 0) {
38
+ used[bestR] = true;
39
+ canonicalGateData.set(
40
+ frame.gateData.subarray(bestR * bytesPerRay, (bestR + 1) * bytesPerRay),
41
+ bin * bytesPerRay
42
+ );
43
+ }
44
+ }
45
+ const canonicalBoundaries = new Float32Array(nRays + 1);
46
+ for (let i = 0; i <= nRays; i++) {
47
+ canonicalBoundaries[i] = i * degPerBin;
48
+ }
49
+ return { ...frame, gateData: canonicalGateData, rayBoundariesDeg: canonicalBoundaries };
50
+ }
51
+ function sortRadarFrameByAzimuth(frame) {
52
+ const nRays = frame.nRays;
53
+ const nGates = frame.nGates;
54
+ const bytesPerRay = nGates * 2;
55
+ const rayOrder = Array.from({ length: nRays }, (_, i) => i).sort(
56
+ (a, b) => frame.rayBoundariesDeg[a] - frame.rayBoundariesDeg[b]
57
+ );
58
+ const sortedGateData = new Uint8Array(nRays * bytesPerRay);
59
+ const sortedBoundaries = new Float32Array(nRays + 1);
60
+ for (let newIdx = 0; newIdx < nRays; newIdx++) {
61
+ const oldIdx = rayOrder[newIdx];
62
+ sortedGateData.set(
63
+ frame.gateData.subarray(oldIdx * bytesPerRay, (oldIdx + 1) * bytesPerRay),
64
+ newIdx * bytesPerRay
65
+ );
66
+ sortedBoundaries[newIdx] = frame.rayBoundariesDeg[oldIdx];
67
+ }
68
+ sortedBoundaries[nRays] = frame.rayBoundariesDeg[rayOrder[nRays - 1]] + (frame.rayBoundariesDeg[rayOrder[1]] - frame.rayBoundariesDeg[rayOrder[0]]);
69
+ return { ...frame, gateData: sortedGateData, rayBoundariesDeg: sortedBoundaries };
70
+ }
71
+ function prepareRadarFrameForGpuReadout(frame, options) {
72
+ const hasLayoutKey = options?.geometryLayoutKey != null && options.geometryLayoutKey !== "";
73
+ return hasLayoutKey ? canonicalBinsRadarFrame(frame) : sortRadarFrameByAzimuth(frame);
74
+ }
75
+ export {
76
+ canonicalBinsRadarFrame,
77
+ prepareRadarFrameForGpuReadout,
78
+ sortRadarFrameByAzimuth
79
+ };