@aguacerowx/mapsgl 0.0.31 → 0.0.41
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/index.js +34 -2
- package/package.json +13 -3
- package/src/GridRenderLayer.js +105 -86
- package/src/MapManager.js +47 -15
- package/src/NexradSitesOverlay.js +148 -0
- package/src/NexradWeatherController.js +491 -0
- package/src/NwsWatchesWarningsOverlay.js +768 -0
- package/src/SatelliteShaderManager.js +999 -0
- package/src/WeatherLayerManager.js +800 -110
- package/src/WorkerPool.js +340 -0
- package/src/nexrad/MapboxRadarLayer.bundled.js +810 -0
- package/src/nexrad/MapboxRadarLayer.ts +784 -0
- package/src/nexrad/PreprocessedSweepParser.ts +226 -0
- package/src/nexrad/buildRadarRayGeometry.ts +97 -0
- package/src/nexrad/level3StormRelative.ts +116 -0
- package/src/nexrad/loadNexradSites.ts +41 -0
- package/src/nexrad/nexradArchiveCache.ts +64 -0
- package/src/nexrad/nexradCrossSectionSampleAtLatLon.ts +121 -0
- package/src/nexrad/nexradLevel3Products.ts +549 -0
- package/src/nexrad/nexradMapboxFrameOpts.js +106 -0
- package/src/nexrad/radarArchiveCore.bundled.js +4206 -0
- package/src/nexrad/radarArchiveCore.bundled.js.map +7 -0
- package/src/nexrad/radarArchiveCore.ts +1737 -0
- package/src/nexrad/radarDecode.worker.bundled.js +809 -0
- package/src/nexrad/radarDecode.worker.ts +227 -0
- package/src/nexrad/radarFrameGpuMatch.ts +111 -0
- package/src/nwsAlertsSupport.js +860 -0
- package/src/nwsEventColorsDefaults.js +133 -0
- package/src/nwsSdkConstants.js +360 -0
- package/src/nwsWarningCustomizationKey.gen.js +496 -0
- package/src/satelliteDefaultColormaps.js +37 -0
- package/src/satelliteKtxWorker.js +225 -0
- package/src/satelliteShader.js +17 -0
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parser for the pre-processed sweep binary format produced by the NEXRAD Lambda.
|
|
3
|
+
*
|
|
4
|
+
* Binary layout (all little-endian — numpy default on x86):
|
|
5
|
+
* [0-3] int32 version = 1
|
|
6
|
+
* [4-7] float32 scale
|
|
7
|
+
* [8-11] float32 offset
|
|
8
|
+
* [12-15] float32 sweep_idx
|
|
9
|
+
* [16-19] float32 elev_angle (degrees)
|
|
10
|
+
* [20-23] float32 n_rays
|
|
11
|
+
* [24-27] float32 n_gates
|
|
12
|
+
* [28-31] float32 first_gate (km — range to center of first gate)
|
|
13
|
+
* [32-35] float32 gate_width (km — range sample interval)
|
|
14
|
+
* [36 ...] float32[n_rays] azimuths (degrees)
|
|
15
|
+
* [36+n_rays*4 ...] float32[n_rays] elevations (degrees, unused for 2-D rendering)
|
|
16
|
+
* [36+n_rays*8 ...] int16[n_rays * n_gates] gate values (-32768 = masked/NaN)
|
|
17
|
+
*
|
|
18
|
+
* Gate decoding: physical_value = raw_int16 * scale + offset (when raw != -32768)
|
|
19
|
+
* Masked gates are stored as -99 in the output dataMoments array to match the
|
|
20
|
+
* existing VertexDataGenerator convention.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const PREPROCESSED_VERSION = 1;
|
|
24
|
+
const HEADER_SIZE = 36; // 4 + 8 + 24 bytes
|
|
25
|
+
|
|
26
|
+
export interface NexradSite {
|
|
27
|
+
id: string;
|
|
28
|
+
lat: number;
|
|
29
|
+
lon: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Minimal moment interface consumed by VertexDataGenerator */
|
|
33
|
+
export interface SweepMoment {
|
|
34
|
+
dataMomentName: string;
|
|
35
|
+
azimuth: number;
|
|
36
|
+
/** Distance to center of first gate (km) */
|
|
37
|
+
dataMomentRange: number;
|
|
38
|
+
/** Gate width / range sample interval (km) */
|
|
39
|
+
dataMomentRangeSampleInterval: number;
|
|
40
|
+
/** Physical gate values; masked gates = -99 */
|
|
41
|
+
dataMoments: Float32Array;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SweepRay {
|
|
45
|
+
dataMoments: SweepMoment[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Minimal archive interface consumed by VertexDataGenerator and MapboxRadarLayer */
|
|
49
|
+
export interface RadarArchive {
|
|
50
|
+
latitude: number;
|
|
51
|
+
longitude: number;
|
|
52
|
+
fieldName: string;
|
|
53
|
+
elevAngle: number;
|
|
54
|
+
nRays: number;
|
|
55
|
+
nGates: number;
|
|
56
|
+
volume: {
|
|
57
|
+
sweeps: Array<{ rays: SweepRay[] }>;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Returns true if the buffer starts with the preprocessed sweep version header.
|
|
63
|
+
* Used in RadarLayer to dispatch to the correct parser.
|
|
64
|
+
*/
|
|
65
|
+
export function isPreprocessedFormat(buffer: ArrayBuffer): boolean {
|
|
66
|
+
if (buffer.byteLength < HEADER_SIZE) return false;
|
|
67
|
+
return new DataView(buffer).getInt32(0, true) === PREPROCESSED_VERSION;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parsePreprocessedSweep(
|
|
71
|
+
buffer: ArrayBuffer,
|
|
72
|
+
stationId: string,
|
|
73
|
+
fieldName: string,
|
|
74
|
+
nexradSites: NexradSite[],
|
|
75
|
+
): RadarArchive | null {
|
|
76
|
+
const LE = true;
|
|
77
|
+
const view = new DataView(buffer);
|
|
78
|
+
|
|
79
|
+
const version = view.getInt32(0, LE);
|
|
80
|
+
if (version !== PREPROCESSED_VERSION) {
|
|
81
|
+
console.error(`[PreprocessedSweepParser] Unknown version ${version}, expected ${PREPROCESSED_VERSION}`);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const scale = view.getFloat32(4, LE);
|
|
86
|
+
const offsetVal = view.getFloat32(8, LE);
|
|
87
|
+
// sweep_idx at bytes 12-15 (not needed for rendering)
|
|
88
|
+
const elevAngle = view.getFloat32(16, LE);
|
|
89
|
+
const nRays = Math.round(view.getFloat32(20, LE));
|
|
90
|
+
const nGates = Math.round(view.getFloat32(24, LE));
|
|
91
|
+
const firstGate = view.getFloat32(28, LE); // km
|
|
92
|
+
const gateWidth = view.getFloat32(32, LE); // km
|
|
93
|
+
|
|
94
|
+
const azOffset = HEADER_SIZE; // 36
|
|
95
|
+
const elOffset = azOffset + nRays * 4; // 36 + nRays*4
|
|
96
|
+
const gateOffset = elOffset + nRays * 4; // 36 + nRays*8
|
|
97
|
+
|
|
98
|
+
const expectedBytes = gateOffset + nRays * nGates * 2;
|
|
99
|
+
if (buffer.byteLength < expectedBytes) {
|
|
100
|
+
console.error(
|
|
101
|
+
`[PreprocessedSweepParser] Buffer too small: got ${buffer.byteLength} bytes, ` +
|
|
102
|
+
`need ${expectedBytes} (nRays=${nRays}, nGates=${nGates})`,
|
|
103
|
+
);
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const site = nexradSites.find(s => s.id === stationId);
|
|
108
|
+
if (!site) {
|
|
109
|
+
console.error(`[PreprocessedSweepParser] Station "${stationId}" not found in nexrad.json`);
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Slice into aligned copies for typed-array access
|
|
114
|
+
const azimuths = new Float32Array(buffer.slice(azOffset, azOffset + nRays * 4));
|
|
115
|
+
const gatesRaw = new Int16Array(buffer.slice(gateOffset, gateOffset + nRays * nGates * 2));
|
|
116
|
+
|
|
117
|
+
const rays: SweepRay[] = [];
|
|
118
|
+
for (let i = 0; i < nRays; i++) {
|
|
119
|
+
const physicalGates = new Float32Array(nGates);
|
|
120
|
+
const base = i * nGates;
|
|
121
|
+
for (let g = 0; g < nGates; g++) {
|
|
122
|
+
const raw = gatesRaw[base + g];
|
|
123
|
+
physicalGates[g] = raw === -32768 ? -99 : raw * scale + offsetVal;
|
|
124
|
+
}
|
|
125
|
+
rays.push({
|
|
126
|
+
dataMoments: [{
|
|
127
|
+
dataMomentName: fieldName,
|
|
128
|
+
azimuth: azimuths[i],
|
|
129
|
+
dataMomentRange: firstGate,
|
|
130
|
+
dataMomentRangeSampleInterval: gateWidth,
|
|
131
|
+
dataMoments: physicalGates,
|
|
132
|
+
}],
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
latitude: site.lat,
|
|
138
|
+
longitude: site.lon,
|
|
139
|
+
fieldName,
|
|
140
|
+
elevAngle,
|
|
141
|
+
nRays,
|
|
142
|
+
nGates,
|
|
143
|
+
volume: { sweeps: [{ rays }] },
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const SLOT_HDR_BYTES = 28;
|
|
148
|
+
|
|
149
|
+
export function parseSlotBuffer(
|
|
150
|
+
buffer: ArrayBuffer,
|
|
151
|
+
stationId: string,
|
|
152
|
+
fieldName: string,
|
|
153
|
+
nexradSites: NexradSite[],
|
|
154
|
+
// These come from the file header fetched separately
|
|
155
|
+
nRaysFile: number,
|
|
156
|
+
nGatesFile: number,
|
|
157
|
+
firstGateKm: number,
|
|
158
|
+
gateWidthKm: number,
|
|
159
|
+
azimuths: Float32Array,
|
|
160
|
+
): RadarArchive | null {
|
|
161
|
+
const BE = false; // DataView default is big-endian when false
|
|
162
|
+
const view = new DataView(buffer);
|
|
163
|
+
|
|
164
|
+
const scale = view.getFloat32(0, false); // big-endian
|
|
165
|
+
const offsetV = view.getFloat32(4, false);
|
|
166
|
+
const present = view.getUint32(8, false);
|
|
167
|
+
const nRays = view.getUint16(12, false);
|
|
168
|
+
const nGates = view.getUint16(14, false);
|
|
169
|
+
|
|
170
|
+
if (present === 0) {
|
|
171
|
+
console.warn(`[parseSlotBuffer] Slot for ${fieldName} marked not present`);
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const gateOffset = SLOT_HDR_BYTES;
|
|
176
|
+
const expectedBytes = gateOffset + nRays * nGates * 2;
|
|
177
|
+
if (buffer.byteLength < expectedBytes) {
|
|
178
|
+
console.error(
|
|
179
|
+
`[parseSlotBuffer] Buffer too small: got ${buffer.byteLength}, need ${expectedBytes}`
|
|
180
|
+
);
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const site = nexradSites.find(s => s.id === stationId);
|
|
185
|
+
if (!site) {
|
|
186
|
+
console.error(`[parseSlotBuffer] Station "${stationId}" not found`);
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Gate data is big-endian int16
|
|
191
|
+
const gateBytes = buffer.slice(gateOffset, gateOffset + nRays * nGates * 2);
|
|
192
|
+
const gatesRaw = new Int16Array(nRays * nGates);
|
|
193
|
+
const gateView = new DataView(gateBytes);
|
|
194
|
+
for (let i = 0; i < nRays * nGates; i++) {
|
|
195
|
+
gatesRaw[i] = gateView.getInt16(i * 2, false); // big-endian
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const rays: SweepRay[] = [];
|
|
199
|
+
for (let i = 0; i < nRays; i++) {
|
|
200
|
+
const physicalGates = new Float32Array(nGates);
|
|
201
|
+
const base = i * nGates;
|
|
202
|
+
for (let g = 0; g < nGates; g++) {
|
|
203
|
+
const raw = gatesRaw[base + g];
|
|
204
|
+
physicalGates[g] = raw === -32768 ? -99 : raw * scale + offsetV;
|
|
205
|
+
}
|
|
206
|
+
rays.push({
|
|
207
|
+
dataMoments: [{
|
|
208
|
+
dataMomentName: fieldName,
|
|
209
|
+
azimuth: azimuths[i],
|
|
210
|
+
dataMomentRange: firstGateKm,
|
|
211
|
+
dataMomentRangeSampleInterval: gateWidthKm,
|
|
212
|
+
dataMoments: physicalGates,
|
|
213
|
+
}],
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
latitude: site.lat,
|
|
219
|
+
longitude: site.lon,
|
|
220
|
+
fieldName,
|
|
221
|
+
elevAngle: 0, // not stored in slot, pass from file header if needed
|
|
222
|
+
nRays,
|
|
223
|
+
nGates,
|
|
224
|
+
volume: { sweeps: [{ rays }] },
|
|
225
|
+
};
|
|
226
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CPU-heavy polar wedge mesh for {@link MapboxRadarLayer}. Extracted for Web Worker offload.
|
|
3
|
+
*/
|
|
4
|
+
export type RadarRayGeometryFrame = {
|
|
5
|
+
nRays: number;
|
|
6
|
+
nGates: number;
|
|
7
|
+
stationLat: number;
|
|
8
|
+
stationLon: number;
|
|
9
|
+
firstGateKm: number;
|
|
10
|
+
gateWidthKm: number;
|
|
11
|
+
rayBoundariesDeg: Float32Array;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function buildRadarRayGeometryBuffer(frame: RadarRayGeometryFrame): {
|
|
15
|
+
buffer: Float32Array;
|
|
16
|
+
anchorMercator: { x: number; y: number };
|
|
17
|
+
} {
|
|
18
|
+
const nRays = frame.nRays;
|
|
19
|
+
const nGates = frame.nGates;
|
|
20
|
+
if (nRays <= 0 || nGates <= 0) {
|
|
21
|
+
return { buffer: new Float32Array(0), anchorMercator: { x: 0, y: 0 } };
|
|
22
|
+
}
|
|
23
|
+
if (frame.rayBoundariesDeg.length < nRays + 1) {
|
|
24
|
+
return { buffer: new Float32Array(0), anchorMercator: { x: 0, y: 0 } };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const nearRangeM = frame.firstGateKm * 1000;
|
|
28
|
+
const farRangeM = (frame.firstGateKm + frame.gateWidthKm * nGates) * 1000;
|
|
29
|
+
|
|
30
|
+
const floatsPerVertex = 4;
|
|
31
|
+
const vertsPerGate = 6;
|
|
32
|
+
const out = new Float32Array(nRays * nGates * vertsPerGate * floatsPerVertex);
|
|
33
|
+
let write = 0;
|
|
34
|
+
|
|
35
|
+
const mercatorXFromLonDeg = (lonDeg: number) => (lonDeg + 180) / 360;
|
|
36
|
+
const mercatorYFromLatRad = (latRad: number) =>
|
|
37
|
+
(1 - Math.log(Math.tan(Math.PI * 0.25 + latRad * 0.5)) / Math.PI) * 0.5;
|
|
38
|
+
|
|
39
|
+
const anchorX = mercatorXFromLonDeg(frame.stationLon);
|
|
40
|
+
const anchorY = mercatorYFromLatRad(frame.stationLat * (Math.PI / 180));
|
|
41
|
+
const anchorMercator = { x: anchorX, y: anchorY };
|
|
42
|
+
|
|
43
|
+
const getDestination = (lat: number, lon: number, distanceM: number, bearingDeg: number) => {
|
|
44
|
+
const R = 6378137;
|
|
45
|
+
const dRad = distanceM / R;
|
|
46
|
+
const bRad = bearingDeg * (Math.PI / 180);
|
|
47
|
+
const lat1 = lat * (Math.PI / 180);
|
|
48
|
+
const lon1 = lon * (Math.PI / 180);
|
|
49
|
+
const lat2 = Math.asin(Math.sin(lat1) * Math.cos(dRad) + Math.cos(lat1) * Math.sin(dRad) * Math.cos(bRad));
|
|
50
|
+
const lon2 =
|
|
51
|
+
lon1 +
|
|
52
|
+
Math.atan2(
|
|
53
|
+
Math.sin(bRad) * Math.sin(dRad) * Math.cos(lat1),
|
|
54
|
+
Math.cos(dRad) - Math.sin(lat1) * Math.sin(lat2),
|
|
55
|
+
);
|
|
56
|
+
return {
|
|
57
|
+
x: mercatorXFromLonDeg(lon2 * (180 / Math.PI)) - anchorX,
|
|
58
|
+
y: mercatorYFromLatRad(lat2) - anchorY,
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const writeVertex = (x: number, y: number, u: number, v: number) => {
|
|
63
|
+
out[write++] = x;
|
|
64
|
+
out[write++] = y;
|
|
65
|
+
out[write++] = u;
|
|
66
|
+
out[write++] = v;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
for (let rayIdx = 0; rayIdx < nRays; rayIdx++) {
|
|
70
|
+
const az1 = frame.rayBoundariesDeg[rayIdx]!;
|
|
71
|
+
const az2 = frame.rayBoundariesDeg[rayIdx + 1]!;
|
|
72
|
+
const v0 = rayIdx / nRays;
|
|
73
|
+
const v1 = (rayIdx + 1) / nRays;
|
|
74
|
+
|
|
75
|
+
for (let g = 0; g < nGates; g++) {
|
|
76
|
+
const r1 = nearRangeM + (farRangeM - nearRangeM) * (g / nGates);
|
|
77
|
+
const r2 = nearRangeM + (farRangeM - nearRangeM) * ((g + 1) / nGates);
|
|
78
|
+
const u0 = g / nGates;
|
|
79
|
+
const u1 = (g + 1) / nGates;
|
|
80
|
+
|
|
81
|
+
const nearLeft = getDestination(frame.stationLat, frame.stationLon, r1, az1);
|
|
82
|
+
const nearRight = getDestination(frame.stationLat, frame.stationLon, r1, az2);
|
|
83
|
+
const farLeft = getDestination(frame.stationLat, frame.stationLon, r2, az1);
|
|
84
|
+
const farRight = getDestination(frame.stationLat, frame.stationLon, r2, az2);
|
|
85
|
+
|
|
86
|
+
writeVertex(nearLeft.x, nearLeft.y, u0, v0);
|
|
87
|
+
writeVertex(nearRight.x, nearRight.y, u0, v1);
|
|
88
|
+
writeVertex(farLeft.x, farLeft.y, u1, v0);
|
|
89
|
+
|
|
90
|
+
writeVertex(nearRight.x, nearRight.y, u0, v1);
|
|
91
|
+
writeVertex(farRight.x, farRight.y, u1, v1);
|
|
92
|
+
writeVertex(farLeft.x, farLeft.y, u1, v0);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { buffer: out, anchorMercator };
|
|
97
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { DecodedRadarFrame } from './nexradArchiveCache.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Storm motion from N0S (ICD 56) product description dep8/dep9, same as AtticRadar
|
|
5
|
+
* (loaders_nexrad.js process_storm_relative_velocity).
|
|
6
|
+
* dep8: storm speed in tenths of knots; dep9: direction in tenths of degrees.
|
|
7
|
+
*/
|
|
8
|
+
export function parseLevel3StormMotionFromBuffer(buffer: ArrayBuffer): { speedMs: number; directionDeg: number } | null {
|
|
9
|
+
const bytes = new Uint8Array(buffer);
|
|
10
|
+
const marker = [83, 68, 85, 83];
|
|
11
|
+
let start = 0;
|
|
12
|
+
for (let i = 0; i <= bytes.length - 4; i++) {
|
|
13
|
+
if (bytes[i] === marker[0] && bytes[i + 1] === marker[1] && bytes[i + 2] === marker[2] && bytes[i + 3] === marker[3]) {
|
|
14
|
+
start = i;
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const dv = new DataView(buffer, start, bytes.length - start);
|
|
19
|
+
const textLen = 30;
|
|
20
|
+
const msgHdrLen = 18;
|
|
21
|
+
const pdbStart = textLen + msgHdrLen;
|
|
22
|
+
if (pdbStart + 86 > dv.byteLength) return null;
|
|
23
|
+
if (dv.getInt16(pdbStart, false) !== -1) return null;
|
|
24
|
+
const dep8Offset = pdbStart + 82;
|
|
25
|
+
const dep8 = dv.getInt16(dep8Offset, false);
|
|
26
|
+
const dep9 = dv.getInt16(dep8Offset + 2, false);
|
|
27
|
+
const stormSpeedKt = dep8 / 10;
|
|
28
|
+
const speedMs = stormSpeedKt * 0.514444;
|
|
29
|
+
const directionDeg = dep9 / 10;
|
|
30
|
+
if (!Number.isFinite(speedMs) || !Number.isFinite(directionDeg)) return null;
|
|
31
|
+
return { speedMs, directionDeg };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** AtticRadar calculateStormComponent — radial component of storm motion (m/s). */
|
|
35
|
+
export function level3StormRadialComponentMs(stormSpeedMs: number, stormDirectionDeg: number, azimuthDeg: number): number {
|
|
36
|
+
const stormDirRad = (stormDirectionDeg * Math.PI) / 180;
|
|
37
|
+
const azRad = (azimuthDeg * Math.PI) / 180;
|
|
38
|
+
return stormSpeedMs * Math.cos(stormDirRad - azRad);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function decodeGateRawMs(hi: number, lo: number, valueScale: number, valueOffset: number): number | null {
|
|
42
|
+
const raw = lo + hi * 256;
|
|
43
|
+
let signed = raw;
|
|
44
|
+
if (raw >= 32768) signed = raw - 65536;
|
|
45
|
+
if (signed <= -32768) return null;
|
|
46
|
+
return signed * valueScale + valueOffset;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function encodeGateRawMs(physicalMs: number, valueScale: number, valueOffset: number): [number, number] {
|
|
50
|
+
const raw = Math.round((physicalMs - valueOffset) / valueScale);
|
|
51
|
+
const signed = Math.max(-32768, Math.min(32767, raw));
|
|
52
|
+
const u = signed < 0 ? signed + 65536 : signed;
|
|
53
|
+
return [(u >> 8) & 0xff, u & 0xff];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Apply storm-relative correction to super-res base velocity (N0G): v_srv = v + storm_radial_component.
|
|
58
|
+
* Matches AtticRadar process_storm_relative_velocity / PR #12.
|
|
59
|
+
*/
|
|
60
|
+
export function applyLevel3StormRelativeToFrame(
|
|
61
|
+
frame: DecodedRadarFrame,
|
|
62
|
+
stormSpeedMs: number,
|
|
63
|
+
stormDirectionDeg: number,
|
|
64
|
+
): DecodedRadarFrame {
|
|
65
|
+
const { nRays, nGates, gateData, rayBoundariesDeg, valueScale, valueOffset } = frame;
|
|
66
|
+
const out = new Uint8Array(gateData.length);
|
|
67
|
+
for (let r = 0; r < nRays; r++) {
|
|
68
|
+
const azLow = rayBoundariesDeg[r];
|
|
69
|
+
const azHigh = rayBoundariesDeg[r + 1];
|
|
70
|
+
const azimuthDeg = (Number(azLow) + Number(azHigh)) * 0.5;
|
|
71
|
+
const stormComp = level3StormRadialComponentMs(stormSpeedMs, stormDirectionDeg, azimuthDeg);
|
|
72
|
+
for (let g = 0; g < nGates; g++) {
|
|
73
|
+
const o = (r * nGates + g) * 2;
|
|
74
|
+
const hi = gateData[o];
|
|
75
|
+
const lo = gateData[o + 1];
|
|
76
|
+
const v = decodeGateRawMs(hi, lo, valueScale, valueOffset);
|
|
77
|
+
if (v == null) {
|
|
78
|
+
out[o] = hi;
|
|
79
|
+
out[o + 1] = lo;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const corrected = v + stormComp;
|
|
83
|
+
const [nh, nl] = encodeGateRawMs(corrected, valueScale, valueOffset);
|
|
84
|
+
out[o] = nh;
|
|
85
|
+
out[o + 1] = nl;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { ...frame, gateData: out };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function pickNearestLevel3ObjectKey(
|
|
92
|
+
unixTime: number,
|
|
93
|
+
timeToKeyMap: Record<string, string>,
|
|
94
|
+
maxDeltaSec = 600,
|
|
95
|
+
): string | null {
|
|
96
|
+
const direct = timeToKeyMap[String(unixTime)];
|
|
97
|
+
if (direct) return direct;
|
|
98
|
+
const entries = Object.entries(timeToKeyMap);
|
|
99
|
+
if (entries.length === 0) return null;
|
|
100
|
+
let bestKey: string | null = null;
|
|
101
|
+
let bestDelta = Infinity;
|
|
102
|
+
for (const [tStr, key] of entries) {
|
|
103
|
+
const t = Number(tStr);
|
|
104
|
+
if (!Number.isFinite(t)) continue;
|
|
105
|
+
const d = Math.abs(t - unixTime);
|
|
106
|
+
if (d < bestDelta) {
|
|
107
|
+
bestDelta = d;
|
|
108
|
+
bestKey = key;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (bestKey == null) return null;
|
|
112
|
+
if (bestDelta > maxDeltaSec && Number.isFinite(maxDeltaSec)) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return bestKey;
|
|
116
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { NexradSite } from './PreprocessedSweepParser.js';
|
|
2
|
+
|
|
3
|
+
type NexradSitesPayload = {
|
|
4
|
+
sites?: NexradSite[];
|
|
5
|
+
features?: unknown[];
|
|
6
|
+
} | NexradSite[];
|
|
7
|
+
|
|
8
|
+
/** Default: same as aguacero-frontend — app serves `public/data/nexrad.json` at `/data/nexrad.json`. */
|
|
9
|
+
let sitesUrl = '/data/nexrad.json';
|
|
10
|
+
|
|
11
|
+
/** Override default nexrad.json URL if your static path differs. */
|
|
12
|
+
export function setNexradSitesJsonUrl(url: string) {
|
|
13
|
+
sitesUrl = url || sitesUrl;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let nexradSitesPayloadPromise: Promise<NexradSitesPayload> | null = null;
|
|
17
|
+
|
|
18
|
+
export function loadNexradSitesPayload(): Promise<NexradSitesPayload> {
|
|
19
|
+
if (nexradSitesPayloadPromise) {
|
|
20
|
+
return nexradSitesPayloadPromise;
|
|
21
|
+
}
|
|
22
|
+
nexradSitesPayloadPromise = fetch(sitesUrl)
|
|
23
|
+
.then((response) => {
|
|
24
|
+
if (!response.ok) throw new Error(`nexrad.json fetch failed: HTTP ${response.status}`);
|
|
25
|
+
return response.json() as Promise<NexradSitesPayload>;
|
|
26
|
+
})
|
|
27
|
+
.catch((error) => {
|
|
28
|
+
console.error('[mapsgl] Could not load nexrad.json:', error);
|
|
29
|
+
nexradSitesPayloadPromise = null;
|
|
30
|
+
throw error;
|
|
31
|
+
});
|
|
32
|
+
return nexradSitesPayloadPromise;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function loadNexradSites(): Promise<NexradSite[]> {
|
|
36
|
+
const payload = await loadNexradSitesPayload();
|
|
37
|
+
if (Array.isArray(payload)) return payload as NexradSite[];
|
|
38
|
+
const sites = (payload as { sites?: NexradSite[] }).sites;
|
|
39
|
+
if (sites && Array.isArray(sites)) return sites;
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Shared archive cache for decoded NEXRAD sweep frames.
|
|
2
|
+
// Lives in a separate module so RadarLayer and GlobalStateContext can both access it
|
|
3
|
+
// without creating a circular import dependency.
|
|
4
|
+
//
|
|
5
|
+
// Rolling cache across all radar sites: we do NOT clear on station or variable change,
|
|
6
|
+
// so returning to a previously viewed site reuses cached frames. When the cache exceeds
|
|
7
|
+
// MAX_ARCHIVE_CACHE_ENTRIES, the oldest entries (by insertion order) are evicted.
|
|
8
|
+
|
|
9
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export type DecodedRadarFrame = {
|
|
12
|
+
gateData: Uint8Array;
|
|
13
|
+
nRays: number;
|
|
14
|
+
nGates: number;
|
|
15
|
+
stationLat: number;
|
|
16
|
+
stationLon: number;
|
|
17
|
+
firstGateKm: number;
|
|
18
|
+
gateWidthKm: number;
|
|
19
|
+
valueScale: number;
|
|
20
|
+
valueOffset: number;
|
|
21
|
+
rayBoundariesDeg: Float32Array;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
// ─── Cache storage ────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
// Rolling cache size. Keeps decoded frames across site/variable switches; evicts oldest when over.
|
|
27
|
+
// ~14 tilts × ~72 time steps ≈ 1008 frames per site; 4 sites ≈ 4k frames.
|
|
28
|
+
const MAX_ARCHIVE_CACHE_ENTRIES = 5000;
|
|
29
|
+
|
|
30
|
+
export const archiveCache = new Map<string, DecodedRadarFrame | null>();
|
|
31
|
+
|
|
32
|
+
export function setArchiveCache(url: string, archive: DecodedRadarFrame | null): void {
|
|
33
|
+
archiveCache.delete(url);
|
|
34
|
+
archiveCache.set(url, archive);
|
|
35
|
+
while (archiveCache.size > MAX_ARCHIVE_CACHE_ENTRIES) {
|
|
36
|
+
const oldestKey = archiveCache.keys().next().value;
|
|
37
|
+
if (!oldestKey) break;
|
|
38
|
+
archiveCache.delete(oldestKey);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── Optional explicit eviction ────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Remove all archiveCache entries that belong to a given radar station.
|
|
46
|
+
* Not used on station change (rolling cache); available for manual/optional clearing.
|
|
47
|
+
*/
|
|
48
|
+
export function clearArchiveCacheForStation(stationId: string): void {
|
|
49
|
+
const prefix = `/${stationId}_`;
|
|
50
|
+
for (const url of Array.from(archiveCache.keys())) {
|
|
51
|
+
if (url.includes(prefix)) archiveCache.delete(url);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Remove all archiveCache entries for a specific station + variable combination.
|
|
57
|
+
* Not used on variable change (rolling cache); available for manual/optional clearing.
|
|
58
|
+
*/
|
|
59
|
+
export function clearArchiveCacheForStationVariable(stationId: string, variable: string): void {
|
|
60
|
+
const prefix = `/${stationId}_${variable}_`;
|
|
61
|
+
for (const url of Array.from(archiveCache.keys())) {
|
|
62
|
+
if (url.includes(prefix)) archiveCache.delete(url);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sample a decoded NEXRAD frame at lat/lon (same geometry as MapboxRadarLayer / readout).
|
|
3
|
+
* Extracted from RadarLayer so workers and cross-section code can import without pulling the layer bundle.
|
|
4
|
+
*/
|
|
5
|
+
import type { DecodedRadarFrame } from './nexradArchiveCache.js';
|
|
6
|
+
|
|
7
|
+
export type SampleNexradAtLatLonOptions = {
|
|
8
|
+
/**
|
|
9
|
+
* When true, match `MapboxRadarLayer` with gate smoothing: continuous gateX/gateY + bilinear blend of
|
|
10
|
+
* decoded int16 samples (nodata excluded). When false, snap to nearest gate/ray like unsmoothed shader.
|
|
11
|
+
*/
|
|
12
|
+
smoothPolar?: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function wrapAzimuthDeltaDeg(azDeg: number, azimuthBaseDeg: number): number {
|
|
16
|
+
let da = azDeg - azimuthBaseDeg;
|
|
17
|
+
da -= Math.floor(da / 360) * 360;
|
|
18
|
+
if (da < 0) da += 360;
|
|
19
|
+
return da;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Same as `snapGateCoord` in MapboxRadarLayer fragment shader. */
|
|
23
|
+
function snapGateCoord(t: number, nCells: number): number {
|
|
24
|
+
if (nCells <= 1) return 0;
|
|
25
|
+
const idx = Math.floor(t * (nCells - 1) + 0.5);
|
|
26
|
+
return idx / (nCells - 1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readGateRawSigned(frame: DecodedRadarFrame, rayIdx: number, gateIdx: number): number | null {
|
|
30
|
+
if (rayIdx < 0 || gateIdx < 0 || rayIdx >= frame.nRays || gateIdx >= frame.nGates) return null;
|
|
31
|
+
const byteOffset = (rayIdx * frame.nGates + gateIdx) * 2;
|
|
32
|
+
const hi = frame.gateData[byteOffset]!;
|
|
33
|
+
const lo = frame.gateData[byteOffset + 1]!;
|
|
34
|
+
const raw = lo + hi * 256;
|
|
35
|
+
const rawSigned = raw >= 32768 ? raw - 65536 : raw;
|
|
36
|
+
if (rawSigned <= -32768) return null;
|
|
37
|
+
return rawSigned;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Mirrors `sampleGateRawBilinear` in MapboxRadarLayer (NEAREST cells, blend after decode). */
|
|
41
|
+
function sampleGateRawBilinearDecoded(frame: DecodedRadarFrame, gateX: number, gateY: number): number | null {
|
|
42
|
+
const w = frame.nGates;
|
|
43
|
+
const h = frame.nRays;
|
|
44
|
+
if (w < 1 || h < 1) return null;
|
|
45
|
+
const gx = Math.min(1, Math.max(0, gateX));
|
|
46
|
+
const gy = Math.min(1, Math.max(0, gateY));
|
|
47
|
+
const sx = gx * Math.max(w - 1, 1);
|
|
48
|
+
const sy = gy * Math.max(h - 1, 1);
|
|
49
|
+
const i0 = Math.floor(sx);
|
|
50
|
+
const j0 = Math.floor(sy);
|
|
51
|
+
const i1 = Math.min(i0 + 1, w - 1);
|
|
52
|
+
const j1 = Math.min(j0 + 1, h - 1);
|
|
53
|
+
const fx = sx - i0;
|
|
54
|
+
const fy = sy - j0;
|
|
55
|
+
const w00 = (1 - fx) * (1 - fy);
|
|
56
|
+
const w10 = fx * (1 - fy);
|
|
57
|
+
const w01 = (1 - fx) * fy;
|
|
58
|
+
const w11 = fx * fy;
|
|
59
|
+
let acc = 0;
|
|
60
|
+
let wsum = 0;
|
|
61
|
+
const add = (r: number | null, wt: number) => {
|
|
62
|
+
if (r !== null) {
|
|
63
|
+
acc += r * wt;
|
|
64
|
+
wsum += wt;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
add(readGateRawSigned(frame, j0, i0), w00);
|
|
68
|
+
add(readGateRawSigned(frame, j0, i1), w10);
|
|
69
|
+
add(readGateRawSigned(frame, j1, i0), w01);
|
|
70
|
+
add(readGateRawSigned(frame, j1, i1), w11);
|
|
71
|
+
if (wsum < 1e-6) return null;
|
|
72
|
+
return acc / wsum;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function sampleNexradFrameAtLatLon(
|
|
76
|
+
frame: DecodedRadarFrame,
|
|
77
|
+
lat: number,
|
|
78
|
+
lon: number,
|
|
79
|
+
options?: SampleNexradAtLatLonOptions,
|
|
80
|
+
): { value: number; groundRangeKm: number } | null {
|
|
81
|
+
const DEG_TO_RAD = Math.PI / 180;
|
|
82
|
+
const EARTH_RADIUS_M = 6378137;
|
|
83
|
+
|
|
84
|
+
const dLatDeg = lat - frame.stationLat;
|
|
85
|
+
const dLonDeg = lon - frame.stationLon;
|
|
86
|
+
const cosLat = Math.max(Math.cos(lat * DEG_TO_RAD), 1e-6);
|
|
87
|
+
const xM = dLonDeg * DEG_TO_RAD * EARTH_RADIUS_M * cosLat;
|
|
88
|
+
const yM = dLatDeg * DEG_TO_RAD * EARTH_RADIUS_M;
|
|
89
|
+
const rangeM = Math.hypot(xM, yM);
|
|
90
|
+
const rangeKm = rangeM / 1000;
|
|
91
|
+
|
|
92
|
+
if (rangeKm < frame.firstGateKm) return null;
|
|
93
|
+
const spanKm = frame.nGates * frame.gateWidthKm;
|
|
94
|
+
if (!(spanKm > 0)) return null;
|
|
95
|
+
const maxRangeKm = frame.firstGateKm + spanKm;
|
|
96
|
+
if (rangeKm >= maxRangeKm) return null;
|
|
97
|
+
|
|
98
|
+
let azDeg = Math.atan2(xM, yM) * (180 / Math.PI);
|
|
99
|
+
if (azDeg < 0) azDeg += 360;
|
|
100
|
+
|
|
101
|
+
const boundaries = frame.rayBoundariesDeg;
|
|
102
|
+
const azimuthBaseDeg = boundaries.length > 0 ? Number(boundaries[0]) : 0;
|
|
103
|
+
const da = wrapAzimuthDeltaDeg(azDeg, azimuthBaseDeg);
|
|
104
|
+
let gateY = da / 360;
|
|
105
|
+
gateY = Math.min(1, Math.max(0, gateY));
|
|
106
|
+
|
|
107
|
+
let gateX = (rangeKm - frame.firstGateKm) / spanKm;
|
|
108
|
+
gateX = Math.min(1, Math.max(0, gateX));
|
|
109
|
+
|
|
110
|
+
const smoothPolar = options?.smoothPolar === true;
|
|
111
|
+
if (!smoothPolar) {
|
|
112
|
+
gateX = snapGateCoord(gateX, frame.nGates);
|
|
113
|
+
gateY = snapGateCoord(gateY, frame.nRays);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const rawSigned = sampleGateRawBilinearDecoded(frame, gateX, gateY);
|
|
117
|
+
if (rawSigned === null) return null;
|
|
118
|
+
|
|
119
|
+
const physical = rawSigned * frame.valueScale + frame.valueOffset;
|
|
120
|
+
return { value: physical, groundRangeKm: rangeKm };
|
|
121
|
+
}
|