@aguacerowx/javascript-sdk 0.0.24 → 0.0.26
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/dist/AguaceroCore.js +794 -207
- package/dist/default-colormaps.js +32 -0
- package/dist/dictionaries.js +73 -0
- package/dist/getBundleId.js +9 -20
- package/dist/getBundleId.native.js +18 -0
- package/dist/gridDecodePipeline.js +37 -0
- package/dist/gridDecodeWorker.js +31 -0
- package/dist/index.js +172 -1
- package/dist/nexradTiltCoalesce.js +95 -0
- package/dist/nexradTilts.js +129 -0
- package/dist/nexrad_level3_catalog.js +56 -0
- package/dist/nexrad_support.js +269 -0
- package/dist/satellite_support.js +395 -0
- package/package.json +37 -2
- package/src/AguaceroCore.js +15 -6
- package/src/index.js +7 -1
- package/src/nws/NwsWatchesWarningsOverlay.js +977 -0
- package/src/nws/nwsAlertsFetchSpec.js +93 -0
- package/src/nws/nwsAlertsSupport.js +1337 -0
- package/src/nws/nwsEventColorsDefaults.js +133 -0
- package/src/nws/nwsSdkConstants.js +368 -0
- package/src/nws/nwsWarningCustomizationKey.gen.js +493 -0
|
@@ -0,0 +1,1337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NWWS HTTP + SSE merge pipeline (subset of aguacero-frontend WatchesWarningsLayer).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { resolveNwsWarningCustomizationKey } from './nwsWarningCustomizationKey.gen.js';
|
|
6
|
+
import { NWS_ALERT_END_TIME_PROP_KEYS, NWS_WARNINGS_SUBTYPE_PARENT } from './nwsSdkConstants.js';
|
|
7
|
+
|
|
8
|
+
export const NWS_RENDER_PRIORITY_PROP = '_nws_render_priority';
|
|
9
|
+
|
|
10
|
+
export const SDK_ALLOWED_EVENT_SET = new Set([
|
|
11
|
+
'Tornado Watch',
|
|
12
|
+
'Tornado Warning',
|
|
13
|
+
'Tornado Warning (Observed)',
|
|
14
|
+
'Tornado Warning (PDS)',
|
|
15
|
+
'Tornado Warning (Emergency)',
|
|
16
|
+
'Severe Thunderstorm Watch',
|
|
17
|
+
'Severe Thunderstorm Warning',
|
|
18
|
+
'Severe Thunderstorm Warning (Considerable)',
|
|
19
|
+
'Severe Thunderstorm Warning (Destructive)',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const NWS_EVENT_CANONICAL_MAP = new Map(
|
|
23
|
+
[...SDK_ALLOWED_EVENT_SET].map((name) => [normalizeNwsEventName(name), name])
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
function normalizeNwsEventName(raw) {
|
|
27
|
+
if (raw == null) return '';
|
|
28
|
+
return String(raw).trim().replace(/\s+/g, ' ').toLowerCase();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function canonicalizeNwsEventName(raw) {
|
|
32
|
+
const normalized = normalizeNwsEventName(raw);
|
|
33
|
+
if (!normalized) return '';
|
|
34
|
+
return NWS_EVENT_CANONICAL_MAP.get(normalized) ?? String(raw).trim();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getNwsAlertEventLabelFromProperties(p) {
|
|
38
|
+
if (!p) return '';
|
|
39
|
+
const explicitEventName = canonicalizeNwsEventName(p.event_name);
|
|
40
|
+
const raw = [p.event, p.phenomenon, p.event_name].find((v) => v != null && String(v).trim() !== '');
|
|
41
|
+
if (raw == null) return '';
|
|
42
|
+
const base = canonicalizeNwsEventName(raw);
|
|
43
|
+
if (!base) return '';
|
|
44
|
+
const resolved = resolveNwsWarningCustomizationKey(base, p);
|
|
45
|
+
if (
|
|
46
|
+
resolved === base &&
|
|
47
|
+
explicitEventName &&
|
|
48
|
+
explicitEventName !== base &&
|
|
49
|
+
NWS_WARNINGS_SUBTYPE_PARENT[explicitEventName] === base
|
|
50
|
+
) {
|
|
51
|
+
return explicitEventName;
|
|
52
|
+
}
|
|
53
|
+
return resolved;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizeNwsRankToken(raw) {
|
|
57
|
+
if (!raw) return '';
|
|
58
|
+
if (typeof raw === 'string') return raw.trim().toLowerCase();
|
|
59
|
+
return String(raw).trim().toLowerCase();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function computeNwsAlertRenderPriority(properties, precomputedEventName) {
|
|
63
|
+
if (!properties) return 0;
|
|
64
|
+
const eventName = normalizeNwsRankToken(precomputedEventName ?? getNwsAlertEventLabelFromProperties(properties));
|
|
65
|
+
const severity = normalizeNwsRankToken(properties.severity);
|
|
66
|
+
const urgency = normalizeNwsRankToken(properties.urgency);
|
|
67
|
+
const certainty = normalizeNwsRankToken(properties.certainty);
|
|
68
|
+
const response = normalizeNwsRankToken(properties.response);
|
|
69
|
+
const significance = normalizeNwsRankToken(properties.significance);
|
|
70
|
+
|
|
71
|
+
let score = 0;
|
|
72
|
+
if (eventName.includes('emergency')) score += 500;
|
|
73
|
+
else if (eventName.includes('warning')) score += 400;
|
|
74
|
+
else if (eventName.includes('watch')) score += 300;
|
|
75
|
+
else if (eventName.includes('advisory')) score += 200;
|
|
76
|
+
else if (eventName.includes('statement')) score += 120;
|
|
77
|
+
|
|
78
|
+
if (significance === 'w') score += 80;
|
|
79
|
+
else if (significance === 'a') score += 60;
|
|
80
|
+
else if (significance === 'y') score += 40;
|
|
81
|
+
else if (significance === 's') score += 20;
|
|
82
|
+
|
|
83
|
+
if (severity === 'extreme') score += 100;
|
|
84
|
+
else if (severity === 'severe') score += 75;
|
|
85
|
+
else if (severity === 'moderate') score += 50;
|
|
86
|
+
else if (severity === 'minor') score += 25;
|
|
87
|
+
|
|
88
|
+
if (urgency === 'immediate') score += 60;
|
|
89
|
+
else if (urgency === 'expected') score += 40;
|
|
90
|
+
else if (urgency === 'future') score += 20;
|
|
91
|
+
|
|
92
|
+
if (certainty === 'observed') score += 40;
|
|
93
|
+
else if (certainty === 'likely') score += 28;
|
|
94
|
+
else if (certainty === 'possible') score += 16;
|
|
95
|
+
else if (certainty === 'unlikely') score += 4;
|
|
96
|
+
|
|
97
|
+
if (response === 'immediate') score += 25;
|
|
98
|
+
else if (response === 'execute') score += 15;
|
|
99
|
+
|
|
100
|
+
return score;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function sortWarningsFeaturesForRender(features) {
|
|
104
|
+
if (!Array.isArray(features) || features.length <= 1) return features;
|
|
105
|
+
const copy = features.slice();
|
|
106
|
+
for (let i = 0; i < copy.length; i++) {
|
|
107
|
+
const f = copy[i];
|
|
108
|
+
const props = f?.properties;
|
|
109
|
+
if (props && typeof props[NWS_RENDER_PRIORITY_PROP] !== 'number') {
|
|
110
|
+
copy[i] = {
|
|
111
|
+
...f,
|
|
112
|
+
properties: {
|
|
113
|
+
...props,
|
|
114
|
+
[NWS_RENDER_PRIORITY_PROP]: computeNwsAlertRenderPriority(props),
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
copy.sort((a, b) => {
|
|
120
|
+
const pA = a?.properties?.[NWS_RENDER_PRIORITY_PROP] ?? 0;
|
|
121
|
+
const pB = b?.properties?.[NWS_RENDER_PRIORITY_PROP] ?? 0;
|
|
122
|
+
return pA - pB;
|
|
123
|
+
});
|
|
124
|
+
return copy;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function getNwsTimeProp(properties, keys) {
|
|
128
|
+
if (!properties) return null;
|
|
129
|
+
for (let i = 0; i < keys.length; i++) {
|
|
130
|
+
const v = properties[keys[i]];
|
|
131
|
+
if (v != null && v !== '') return v;
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function parseNwsTimeToUnix(value) {
|
|
137
|
+
if (value == null || value === '') return null;
|
|
138
|
+
if (typeof value === 'number') {
|
|
139
|
+
if (!Number.isFinite(value)) return null;
|
|
140
|
+
return value > 1e12 ? Math.floor(value / 1000) : Math.floor(value);
|
|
141
|
+
}
|
|
142
|
+
const s = String(value).trim();
|
|
143
|
+
if (s.includes('T') || s.includes('-')) {
|
|
144
|
+
const t = Date.parse(s);
|
|
145
|
+
if (!Number.isNaN(t)) return Math.floor(t / 1000);
|
|
146
|
+
}
|
|
147
|
+
if (s.length === 10 && /^\d{10}$/.test(s)) {
|
|
148
|
+
return Number(s);
|
|
149
|
+
}
|
|
150
|
+
if (s.length === 13 && /^\d{13}$/.test(s)) {
|
|
151
|
+
return Math.floor(Number(s) / 1000);
|
|
152
|
+
}
|
|
153
|
+
const t = Date.parse(s);
|
|
154
|
+
if (!Number.isNaN(t)) return Math.floor(t / 1000);
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** CAP validity start fields (order matches aguacero-frontend `nwsWarningsModalHelpers`). */
|
|
159
|
+
const NWS_VALIDITY_START_PROP_KEYS = ['onset', 'effective', 'issued_at', 'issued', 'sent'];
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* True if CAP-validity [start, end] overlaps [winStartSec, winEndSec] (inclusive).
|
|
163
|
+
* Missing end time is treated as ongoing.
|
|
164
|
+
*
|
|
165
|
+
* @param {{ properties?: Record<string, unknown> }} feature
|
|
166
|
+
* @param {number} winStartSec
|
|
167
|
+
* @param {number} winEndSec
|
|
168
|
+
* @returns {boolean}
|
|
169
|
+
*/
|
|
170
|
+
export function nwsFeatureOverlapsUnixWindow(feature, winStartSec, winEndSec) {
|
|
171
|
+
const p = feature?.properties;
|
|
172
|
+
if (!p) return false;
|
|
173
|
+
const start =
|
|
174
|
+
typeof p.start_unix === 'number' && Number.isFinite(p.start_unix)
|
|
175
|
+
? Math.floor(p.start_unix)
|
|
176
|
+
: parseNwsTimeToUnix(getNwsTimeProp(p, NWS_VALIDITY_START_PROP_KEYS));
|
|
177
|
+
const end =
|
|
178
|
+
typeof p.end_unix === 'number' && Number.isFinite(p.end_unix)
|
|
179
|
+
? Math.floor(p.end_unix)
|
|
180
|
+
: parseNwsTimeToUnix(getNwsTimeProp(p, [...NWS_ALERT_END_TIME_PROP_KEYS]));
|
|
181
|
+
const s = typeof start === 'number' && Number.isFinite(start) ? start : 0;
|
|
182
|
+
const e = typeof end === 'number' && Number.isFinite(end) ? end : Number.POSITIVE_INFINITY;
|
|
183
|
+
return s <= winEndSec && e >= winStartSec;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const NWS_START_UNIX_KEYS = ['issued_at', 'issued', 'sent', 'onset', 'effective'];
|
|
187
|
+
const NWS_ACTIVE_START_UNIX_KEYS = ['onset', 'effective', 'issued_at', 'issued', 'sent'];
|
|
188
|
+
|
|
189
|
+
function getNwsStartUnixForMap(properties) {
|
|
190
|
+
const startStr = getNwsTimeProp(properties, NWS_START_UNIX_KEYS);
|
|
191
|
+
return parseNwsTimeToUnix(startStr) ?? 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function getNwsActiveStartUnix(properties) {
|
|
195
|
+
const startStr = getNwsTimeProp(properties, NWS_ACTIVE_START_UNIX_KEYS);
|
|
196
|
+
return parseNwsTimeToUnix(startStr) ?? 0;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function getEarliestNwsReferenceSentRaw(properties) {
|
|
200
|
+
if (!properties) return null;
|
|
201
|
+
let refs = properties.references;
|
|
202
|
+
if (typeof refs === 'string') {
|
|
203
|
+
try {
|
|
204
|
+
refs = JSON.parse(refs);
|
|
205
|
+
} catch {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
if (!Array.isArray(refs) || refs.length === 0) return null;
|
|
210
|
+
let best = null;
|
|
211
|
+
for (const r of refs) {
|
|
212
|
+
if (!r || typeof r !== 'object') continue;
|
|
213
|
+
const raw = r.sent ?? r.Sent;
|
|
214
|
+
if (raw == null || raw === '') continue;
|
|
215
|
+
const s = String(raw);
|
|
216
|
+
const t = new Date(s).getTime();
|
|
217
|
+
if (Number.isNaN(t)) continue;
|
|
218
|
+
if (!best || t < best.t) best = { t, raw: s };
|
|
219
|
+
}
|
|
220
|
+
return best?.raw ?? null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
let nextWarningFeatureId = 1;
|
|
224
|
+
|
|
225
|
+
function allocateNwsMapboxFeatureId(rawId) {
|
|
226
|
+
if (typeof rawId === 'number' && Number.isFinite(rawId) && rawId >= 0 && rawId <= Number.MAX_SAFE_INTEGER) {
|
|
227
|
+
return Math.trunc(rawId);
|
|
228
|
+
}
|
|
229
|
+
return nextWarningFeatureId++;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function ensureDistinctMapboxIdsOnFeatures(features) {
|
|
233
|
+
const used = new Set();
|
|
234
|
+
return features.map((f) => {
|
|
235
|
+
let id =
|
|
236
|
+
typeof f?.id === 'number' && Number.isFinite(f.id) && f.id >= 0 && f.id <= Number.MAX_SAFE_INTEGER
|
|
237
|
+
? Math.trunc(f.id)
|
|
238
|
+
: null;
|
|
239
|
+
if (id == null) {
|
|
240
|
+
while (used.has(nextWarningFeatureId)) {
|
|
241
|
+
nextWarningFeatureId++;
|
|
242
|
+
}
|
|
243
|
+
id = nextWarningFeatureId++;
|
|
244
|
+
used.add(id);
|
|
245
|
+
return { ...f, id };
|
|
246
|
+
}
|
|
247
|
+
if (!used.has(id)) {
|
|
248
|
+
used.add(id);
|
|
249
|
+
return f;
|
|
250
|
+
}
|
|
251
|
+
while (used.has(nextWarningFeatureId)) {
|
|
252
|
+
nextWarningFeatureId++;
|
|
253
|
+
}
|
|
254
|
+
const newId = nextWarningFeatureId++;
|
|
255
|
+
used.add(newId);
|
|
256
|
+
return { ...f, id: newId };
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function enrichWarningsWithColors(geojson) {
|
|
261
|
+
if (!geojson || typeof geojson !== 'object') {
|
|
262
|
+
return { type: 'FeatureCollection', features: [] };
|
|
263
|
+
}
|
|
264
|
+
const rawFeats = geojson.features;
|
|
265
|
+
if (!Array.isArray(rawFeats) || rawFeats.length === 0) {
|
|
266
|
+
return { type: 'FeatureCollection', features: [] };
|
|
267
|
+
}
|
|
268
|
+
const features = rawFeats.map((f) => {
|
|
269
|
+
const p = f.properties || {};
|
|
270
|
+
const earliestRef = getEarliestNwsReferenceSentRaw(p);
|
|
271
|
+
const hasExplicitIssued = getNwsTimeProp(p, ['issued_at', 'issued']) != null;
|
|
272
|
+
const nwsIssuedPatch =
|
|
273
|
+
earliestRef && !hasExplicitIssued ? { _nws_issued_at: earliestRef } : null;
|
|
274
|
+
|
|
275
|
+
const eventName = getNwsAlertEventLabelFromProperties(p);
|
|
276
|
+
const renderPriority = computeNwsAlertRenderPriority(p, eventName);
|
|
277
|
+
|
|
278
|
+
const startUnix = getNwsStartUnixForMap(p);
|
|
279
|
+
const activeStartUnix = getNwsActiveStartUnix(p);
|
|
280
|
+
const endStr = getNwsTimeProp(p, [...NWS_ALERT_END_TIME_PROP_KEYS]);
|
|
281
|
+
const endUnix = parseNwsTimeToUnix(endStr) ?? 2147483647;
|
|
282
|
+
|
|
283
|
+
const mapboxFeatureId = allocateNwsMapboxFeatureId(f.id);
|
|
284
|
+
|
|
285
|
+
const hasEventName = !!eventName;
|
|
286
|
+
const eventNameUnchanged = !hasEventName || p.event_name === eventName;
|
|
287
|
+
const priorityUnchanged = p[NWS_RENDER_PRIORITY_PROP] === renderPriority;
|
|
288
|
+
const startUnchanged = p.start_unix === startUnix;
|
|
289
|
+
const activeStartUnchanged = p.active_start_unix === activeStartUnix;
|
|
290
|
+
const endUnchanged = p.end_unix === endUnix;
|
|
291
|
+
const idUnchanged = f.id === mapboxFeatureId;
|
|
292
|
+
const nwsIssuedUnchanged =
|
|
293
|
+
nwsIssuedPatch == null || String(p._nws_issued_at ?? '') === String(nwsIssuedPatch._nws_issued_at);
|
|
294
|
+
|
|
295
|
+
if (
|
|
296
|
+
eventNameUnchanged &&
|
|
297
|
+
priorityUnchanged &&
|
|
298
|
+
startUnchanged &&
|
|
299
|
+
activeStartUnchanged &&
|
|
300
|
+
endUnchanged &&
|
|
301
|
+
idUnchanged &&
|
|
302
|
+
nwsIssuedUnchanged
|
|
303
|
+
) {
|
|
304
|
+
return f;
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
...f,
|
|
308
|
+
id: mapboxFeatureId,
|
|
309
|
+
properties: {
|
|
310
|
+
...p,
|
|
311
|
+
...(nwsIssuedPatch ?? {}),
|
|
312
|
+
...(hasEventName ? { event_name: eventName } : {}),
|
|
313
|
+
[NWS_RENDER_PRIORITY_PROP]: renderPriority,
|
|
314
|
+
start_unix: startUnix,
|
|
315
|
+
active_start_unix: activeStartUnix,
|
|
316
|
+
end_unix: endUnix,
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
});
|
|
320
|
+
const distinctIds = ensureDistinctMapboxIdsOnFeatures(features);
|
|
321
|
+
return { type: 'FeatureCollection', features: sortWarningsFeaturesForRender(distinctIds) };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export function normalizeNwwsAlertId(value) {
|
|
325
|
+
if (value == null || value === '') return null;
|
|
326
|
+
const s = String(value).trim();
|
|
327
|
+
if (!s) return null;
|
|
328
|
+
const marker = '/alerts/';
|
|
329
|
+
const idx = s.indexOf(marker);
|
|
330
|
+
if (idx >= 0) {
|
|
331
|
+
const tail = s.slice(idx + marker.length).trim();
|
|
332
|
+
return tail || s;
|
|
333
|
+
}
|
|
334
|
+
return s;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function isNwwsVtecTimestampInstanceId(id) {
|
|
338
|
+
const parts = id.split('.');
|
|
339
|
+
if (parts.length < 5) return false;
|
|
340
|
+
const last = parts[parts.length - 1] ?? '';
|
|
341
|
+
return /^\d{8,}$/.test(last);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function nwwsFeatureIdLooksLikeMapboxAllocation(raw) {
|
|
345
|
+
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0 && raw <= Number.MAX_SAFE_INTEGER) {
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
if (typeof raw === 'string') {
|
|
349
|
+
const t = raw.trim();
|
|
350
|
+
if (t.includes('.')) return false;
|
|
351
|
+
if (/^\d+$/.test(t) && t.length <= 16) return true;
|
|
352
|
+
}
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function getNwwsApiAlertUniqueId(feature) {
|
|
357
|
+
if (!feature || typeof feature !== 'object') return null;
|
|
358
|
+
const p = feature.properties;
|
|
359
|
+
const fromProps =
|
|
360
|
+
normalizeNwwsAlertId(p?.base_alert_id) ??
|
|
361
|
+
normalizeNwwsAlertId(p?.base_alertid ?? p?.baseAlertId ?? p?.base_alertId) ??
|
|
362
|
+
normalizeNwwsAlertId(p?.alert_id ?? p?.alertId) ??
|
|
363
|
+
normalizeNwwsAlertId(p?.id);
|
|
364
|
+
if (fromProps) return fromProps;
|
|
365
|
+
const rid = feature.id;
|
|
366
|
+
if (rid == null || rid === '' || nwwsFeatureIdLooksLikeMapboxAllocation(rid)) return null;
|
|
367
|
+
return normalizeNwwsAlertId(rid);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function getNwwsVtecSequenceBaseFromInstanceId(fullId) {
|
|
371
|
+
const parts = String(fullId).trim().split('.');
|
|
372
|
+
if (parts.length < 5) return null;
|
|
373
|
+
return parts.slice(0, 4).join('.');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function getNwwsVtecSeqBaseFromFeature(feature) {
|
|
377
|
+
const p = feature?.properties;
|
|
378
|
+
if (!p) return null;
|
|
379
|
+
const pinned = p._nws_vtec_seq_base;
|
|
380
|
+
if (typeof pinned === 'string' && pinned.includes('.')) return pinned.trim();
|
|
381
|
+
const uid = getNwwsApiAlertUniqueId(feature);
|
|
382
|
+
if (uid) {
|
|
383
|
+
const fromUid = getNwwsVtecSequenceBaseFromInstanceId(uid);
|
|
384
|
+
if (fromUid) return fromUid;
|
|
385
|
+
}
|
|
386
|
+
return extractNwsVtecStableId(p);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function getNwwsTrackerStatusFromProperties(properties) {
|
|
390
|
+
if (!properties) return null;
|
|
391
|
+
const raw = properties._trackerStatus ?? properties.trackerStatus ?? properties.tracker_status;
|
|
392
|
+
if (raw == null || raw === '') {
|
|
393
|
+
if (properties.is_cancelled === true) return 'cancelled';
|
|
394
|
+
return null;
|
|
395
|
+
}
|
|
396
|
+
const s = String(raw).trim().toLowerCase();
|
|
397
|
+
if (!s) return null;
|
|
398
|
+
if (s === 'canceled') return 'cancelled';
|
|
399
|
+
if (s === 'cancel') return 'cancelled';
|
|
400
|
+
if (s === 'expire') return 'expired';
|
|
401
|
+
if (s === 'upgrade') return 'upgraded';
|
|
402
|
+
if (s === 'extend' || s === 'extended' || s === 'extension' || s === 'expanded' || s === 'expansion') {
|
|
403
|
+
return 'extended';
|
|
404
|
+
}
|
|
405
|
+
if (s === 'continuation' || s === 'continued' || s === 'correction' || s === 'continue') {
|
|
406
|
+
return 'continuation';
|
|
407
|
+
}
|
|
408
|
+
return s;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
export function isNwwsTrackerStatusTerminal(status) {
|
|
412
|
+
return status === 'cancelled' || status === 'expired';
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* CAP references / explicit replace fields for the previous bulletin(s) in the same event chain.
|
|
417
|
+
* Used so extensions/upgrades/continuations remove polygons keyed by superseded ids, not only by
|
|
418
|
+
* the new feature's `base_alert_id`.
|
|
419
|
+
*/
|
|
420
|
+
function collectNwwsReplacementReferenceIds(properties) {
|
|
421
|
+
if (!properties || typeof properties !== 'object') return [];
|
|
422
|
+
const out = [];
|
|
423
|
+
const pushRaw = (v) => {
|
|
424
|
+
if (v == null) return;
|
|
425
|
+
if (Array.isArray(v)) {
|
|
426
|
+
for (const x of v) pushRaw(x);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
const n = normalizeNwwsAlertId(v);
|
|
430
|
+
if (n) out.push(n);
|
|
431
|
+
};
|
|
432
|
+
pushRaw(properties._upgradedFrom ?? properties.upgradedFrom);
|
|
433
|
+
pushRaw(properties._replaces ?? properties.replaces ?? properties.replaced_alert_id ?? properties.replacedAlertId);
|
|
434
|
+
let refs = properties.references;
|
|
435
|
+
if (typeof refs === 'string') {
|
|
436
|
+
try {
|
|
437
|
+
refs = JSON.parse(refs);
|
|
438
|
+
} catch {
|
|
439
|
+
refs = null;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (Array.isArray(refs)) {
|
|
443
|
+
for (const r of refs) {
|
|
444
|
+
if (typeof r === 'string') pushRaw(r);
|
|
445
|
+
else if (r && typeof r === 'object') {
|
|
446
|
+
const ro = r;
|
|
447
|
+
pushRaw(ro.identifier ?? ro.id ?? ro.alert_id ?? ro.alertId ?? ro.base_alert_id);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return out;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function getNwsBaseAlertIdFromFeature(feature) {
|
|
455
|
+
const p = feature?.properties;
|
|
456
|
+
const base = normalizeNwwsAlertId(p?.base_alert_id ?? p?.base_alertid ?? p?.baseAlertId ?? p?.base_alertId);
|
|
457
|
+
if (base) return base;
|
|
458
|
+
const fromAlert = normalizeNwwsAlertId(p?.alert_id ?? p?.alertId ?? p?.id);
|
|
459
|
+
if (fromAlert) return fromAlert;
|
|
460
|
+
const rid = feature?.id;
|
|
461
|
+
if (rid == null || nwwsFeatureIdLooksLikeMapboxAllocation(rid)) return null;
|
|
462
|
+
return normalizeNwwsAlertId(rid);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function getNwsAlertInstanceIdFromFeature(feature) {
|
|
466
|
+
return (
|
|
467
|
+
getNwwsApiAlertUniqueId(feature) ??
|
|
468
|
+
normalizeNwwsAlertId(feature?.properties?.unique_id ?? feature?.properties?.uniqueId) ??
|
|
469
|
+
getNwsBaseAlertIdFromFeature(feature)
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* SVR revision visibility: superseded geometry only before cutover unix; updated geometry only at/after cutover.
|
|
475
|
+
* Returns null when this feature does not use revision split (fall back to start_unix / end_unix).
|
|
476
|
+
* Matches aguacero-frontend {@link nwsRevisionVisibilityActive}.
|
|
477
|
+
*/
|
|
478
|
+
export function nwsRevisionVisibilityActive(properties, referenceUnix) {
|
|
479
|
+
if (!properties) return null;
|
|
480
|
+
const role = properties._nws_revision_role;
|
|
481
|
+
const cut = properties._nws_revision_cutover_unix;
|
|
482
|
+
if (cut == null || typeof cut !== 'number' || !role) return null;
|
|
483
|
+
if (role === 'superseded') {
|
|
484
|
+
return referenceUnix < cut;
|
|
485
|
+
}
|
|
486
|
+
if (role === 'update') {
|
|
487
|
+
return referenceUnix >= cut;
|
|
488
|
+
}
|
|
489
|
+
if (role === 'current') {
|
|
490
|
+
return null;
|
|
491
|
+
}
|
|
492
|
+
return null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/** nwws-server stores ids as `${vtecBaseId}.${issuedTs}`; strip the issued suffix for revision grouping. */
|
|
496
|
+
function stripNwwsIssuedMillisSuffixFromId(id) {
|
|
497
|
+
const m = String(id).match(/^(.+)\.(\d{10,})$/);
|
|
498
|
+
return m ? m[1] : id;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* One key per logical warning product (VTEC sequence / base id). Matches aguacero-frontend
|
|
503
|
+
* {@link getNwwsLogicalProductKey} for deduping superseded vs update rows on the time slider.
|
|
504
|
+
*/
|
|
505
|
+
export function getNwwsLogicalProductKey(feature) {
|
|
506
|
+
const p = feature?.properties ?? {};
|
|
507
|
+
const params = p.parameters;
|
|
508
|
+
const vtecRaw = params != null && typeof params === 'object' && !Array.isArray(params) ? params.VTEC : undefined;
|
|
509
|
+
const vtec = Array.isArray(vtecRaw)
|
|
510
|
+
? vtecRaw[0]
|
|
511
|
+
: p.vtec ?? (p.details && typeof p.details === 'object' ? p.details.pvtec : undefined);
|
|
512
|
+
if (typeof vtec === 'string' && vtec.trim()) {
|
|
513
|
+
const parts = vtec.split('.');
|
|
514
|
+
if (parts.length >= 6) {
|
|
515
|
+
return `${parts[2]}.${parts[3]}.${parts[4]}.${parts[5]}`;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
const base = getNwsBaseAlertIdFromFeature(feature);
|
|
519
|
+
if (base) return stripNwwsIssuedMillisSuffixFromId(base);
|
|
520
|
+
if (feature?.id != null && feature.id !== '') return stripNwwsIssuedMillisSuffixFromId(String(feature.id));
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
export function getNwwsProductKeyFromFeature(feature) {
|
|
525
|
+
const pk = feature?.properties?.nws_product_key;
|
|
526
|
+
if (typeof pk === 'string' && pk.trim() !== '') return pk.trim();
|
|
527
|
+
return getNwwsLogicalProductKey(feature);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* When superseded + update rows both pass time checks, pick one per reference instant (same product key).
|
|
532
|
+
* Matches aguacero-frontend {@link pickBestNwsRevisionAmongConflictingFeatures}.
|
|
533
|
+
*/
|
|
534
|
+
export function pickBestNwsRevisionAmongConflictingFeatures(features, referenceUnix) {
|
|
535
|
+
if (!Array.isArray(features) || features.length === 0) return null;
|
|
536
|
+
if (features.length === 1) return features[0];
|
|
537
|
+
const scored = features.map((f) => {
|
|
538
|
+
const p = f?.properties ?? {};
|
|
539
|
+
const role = String(p._nws_revision_role ?? '');
|
|
540
|
+
const cut =
|
|
541
|
+
typeof p._nws_revision_cutover_unix === 'number' && Number.isFinite(p._nws_revision_cutover_unix)
|
|
542
|
+
? p._nws_revision_cutover_unix
|
|
543
|
+
: null;
|
|
544
|
+
let slotScore = 0;
|
|
545
|
+
if (role === 'superseded' && cut != null) {
|
|
546
|
+
slotScore = referenceUnix < cut ? 2 : 0;
|
|
547
|
+
} else if (role === 'update' && cut != null) {
|
|
548
|
+
slotScore = referenceUnix >= cut ? 2 : 0;
|
|
549
|
+
} else if (role === 'current') {
|
|
550
|
+
slotScore = 1;
|
|
551
|
+
} else {
|
|
552
|
+
slotScore = 1;
|
|
553
|
+
}
|
|
554
|
+
const t =
|
|
555
|
+
parseNwsTimeToUnix(getNwsTimeProp(p, ['sent', 'updated_at', 'issued_at', 'issued'])) ?? 0;
|
|
556
|
+
return { f, slotScore, t };
|
|
557
|
+
});
|
|
558
|
+
scored.sort((a, b) => {
|
|
559
|
+
if (b.slotScore !== a.slotScore) return b.slotScore - a.slotScore;
|
|
560
|
+
return b.t - a.t;
|
|
561
|
+
});
|
|
562
|
+
return scored[0].f;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export function isUsableNwwsAlertGeometry(geometry) {
|
|
566
|
+
if (geometry == null || typeof geometry !== 'object') return false;
|
|
567
|
+
const g = geometry;
|
|
568
|
+
const t = g.type;
|
|
569
|
+
if (t === 'GeometryCollection') {
|
|
570
|
+
const geoms = g.geometries;
|
|
571
|
+
return (
|
|
572
|
+
Array.isArray(geoms) &&
|
|
573
|
+
geoms.length > 0 &&
|
|
574
|
+
geoms.some((sub) => isUsableNwwsAlertGeometry(sub))
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
if (!t) return false;
|
|
578
|
+
const c = g.coordinates;
|
|
579
|
+
if (c == null) return false;
|
|
580
|
+
if (t === 'Point') return Array.isArray(c) && c.length >= 2;
|
|
581
|
+
if (t === 'LineString') return Array.isArray(c) && c.length >= 2;
|
|
582
|
+
if (t === 'MultiPoint' || t === 'MultiLineString' || t === 'Polygon') {
|
|
583
|
+
return Array.isArray(c) && c.length > 0;
|
|
584
|
+
}
|
|
585
|
+
if (t === 'MultiPolygon') {
|
|
586
|
+
return Array.isArray(c) && c.length > 0;
|
|
587
|
+
}
|
|
588
|
+
return true;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** Empty collection safe for Mapbox GL `geojson` sources (never pass `features: null`). */
|
|
592
|
+
export const EMPTY_FEATURE_COLLECTION = Object.freeze({
|
|
593
|
+
type: 'FeatureCollection',
|
|
594
|
+
features: [],
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Coerces input into a FeatureCollection Mapbox can load without worker errors
|
|
599
|
+
* (null `features`, invalid nested GeometryCollections, or null coordinates).
|
|
600
|
+
*/
|
|
601
|
+
export function normalizeFeatureCollectionForMapboxGl(input) {
|
|
602
|
+
if (!input || typeof input !== 'object' || input.type !== 'FeatureCollection') {
|
|
603
|
+
return { type: 'FeatureCollection', features: [] };
|
|
604
|
+
}
|
|
605
|
+
const raw = input.features;
|
|
606
|
+
if (!Array.isArray(raw)) {
|
|
607
|
+
return { type: 'FeatureCollection', features: [] };
|
|
608
|
+
}
|
|
609
|
+
const out = [];
|
|
610
|
+
for (const f of raw) {
|
|
611
|
+
if (!f || typeof f !== 'object' || f.type !== 'Feature') continue;
|
|
612
|
+
if (!isUsableNwwsAlertGeometry(f.geometry)) continue;
|
|
613
|
+
out.push(f);
|
|
614
|
+
}
|
|
615
|
+
return { type: 'FeatureCollection', features: out };
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
export function applyNwwsDeltaToCollection(data, delta) {
|
|
619
|
+
const base = normalizeFeatureCollectionForMapboxGl(data);
|
|
620
|
+
let features = [...base.features];
|
|
621
|
+
const cancelSet = new Set(delta.cancelIds);
|
|
622
|
+
const expireSet = new Set(delta.expireIds);
|
|
623
|
+
const removeIds = new Set([...expireSet, ...cancelSet]);
|
|
624
|
+
|
|
625
|
+
for (const feat of delta.upserts) {
|
|
626
|
+
if (!feat || feat.type !== 'Feature') continue;
|
|
627
|
+
const norm = normalizeNwwsHttpAlertFeature(
|
|
628
|
+
{
|
|
629
|
+
type: 'Feature',
|
|
630
|
+
id: feat.id,
|
|
631
|
+
geometry: feat.geometry,
|
|
632
|
+
properties: { ...(feat.properties ?? {}) },
|
|
633
|
+
},
|
|
634
|
+
true
|
|
635
|
+
);
|
|
636
|
+
for (const refId of collectNwwsReplacementReferenceIds(norm.properties)) {
|
|
637
|
+
removeIds.add(refId);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const replacedBaseIds = new Set();
|
|
642
|
+
// Secondary: VTEC-derived stable IDs from upserts. Used as a fallback when base_alert_id
|
|
643
|
+
// differs between the stored feature and the update (e.g. when the content hash changed but
|
|
644
|
+
// the VTEC event number did not). This handles sessions where old hash-keyed features already
|
|
645
|
+
// exist in the working collection when an update with a VTEC-keyed ID arrives.
|
|
646
|
+
const replacedVtecIds = new Set();
|
|
647
|
+
const vtecStaleSweep = new Map();
|
|
648
|
+
for (const feat of delta.upserts) {
|
|
649
|
+
if (!feat || feat.type !== 'Feature') continue;
|
|
650
|
+
const norm = normalizeNwwsHttpAlertFeature(
|
|
651
|
+
{
|
|
652
|
+
type: 'Feature',
|
|
653
|
+
id: feat.id,
|
|
654
|
+
geometry: feat.geometry,
|
|
655
|
+
properties: { ...(feat.properties ?? {}) },
|
|
656
|
+
},
|
|
657
|
+
true
|
|
658
|
+
);
|
|
659
|
+
const trackerPre = getNwwsTrackerStatusFromProperties(norm.properties);
|
|
660
|
+
if (!isNwwsTrackerStatusTerminal(trackerPre)) {
|
|
661
|
+
const uid = getNwwsApiAlertUniqueId(norm);
|
|
662
|
+
const vtecBase = uid ? getNwwsVtecSequenceBaseFromInstanceId(uid) : null;
|
|
663
|
+
if (uid && vtecBase) vtecStaleSweep.set(vtecBase, uid);
|
|
664
|
+
}
|
|
665
|
+
const baseId = getNwsBaseAlertIdFromFeature(norm);
|
|
666
|
+
if (baseId) replacedBaseIds.add(baseId);
|
|
667
|
+
const pr = norm.properties;
|
|
668
|
+
const vtecIdCap = extractNwsVtecStableId(pr);
|
|
669
|
+
if (vtecIdCap) replacedVtecIds.add(vtecIdCap);
|
|
670
|
+
else {
|
|
671
|
+
const uidForVtec = getNwwsApiAlertUniqueId(norm);
|
|
672
|
+
const vbOnly = uidForVtec ? getNwwsVtecSequenceBaseFromInstanceId(uidForVtec) : null;
|
|
673
|
+
if (vbOnly) replacedVtecIds.add(vbOnly);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
const clearedGeometryByInstance = new Map();
|
|
678
|
+
const clearedMapboxIdByInstance = new Map();
|
|
679
|
+
|
|
680
|
+
if (replacedBaseIds.size > 0 || replacedVtecIds.size > 0 || vtecStaleSweep.size > 0) {
|
|
681
|
+
features = features.filter((f) => {
|
|
682
|
+
const baseId = getNwsBaseAlertIdFromFeature(f);
|
|
683
|
+
const matchesPrimary = baseId != null && replacedBaseIds.has(baseId);
|
|
684
|
+
const fp = f?.properties;
|
|
685
|
+
const vtecOnStored =
|
|
686
|
+
extractNwsVtecStableId(fp) ?? (typeof fp?._nws_vtec_seq_base === 'string' ? fp._nws_vtec_seq_base : null);
|
|
687
|
+
const matchesVtec =
|
|
688
|
+
!matchesPrimary &&
|
|
689
|
+
replacedVtecIds.size > 0 &&
|
|
690
|
+
vtecOnStored != null &&
|
|
691
|
+
replacedVtecIds.has(vtecOnStored);
|
|
692
|
+
const fid = getNwwsApiAlertUniqueId(f);
|
|
693
|
+
const seqB = getNwwsVtecSeqBaseFromFeature(f);
|
|
694
|
+
let matchesStaleInstance = false;
|
|
695
|
+
if (vtecStaleSweep.size > 0) {
|
|
696
|
+
for (const [vtecBase, newUid] of vtecStaleSweep) {
|
|
697
|
+
if (fid) {
|
|
698
|
+
const sameSeries = fid === vtecBase || fid.startsWith(`${vtecBase}.`);
|
|
699
|
+
if (sameSeries && fid !== newUid) {
|
|
700
|
+
matchesStaleInstance = true;
|
|
701
|
+
break;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
if (!matchesStaleInstance && seqB != null && seqB === vtecBase && (fid == null || fid !== newUid)) {
|
|
705
|
+
matchesStaleInstance = true;
|
|
706
|
+
break;
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
if (!matchesPrimary && !matchesVtec && !matchesStaleInstance) return true;
|
|
711
|
+
const iid = getNwsAlertInstanceIdFromFeature(f) ?? baseId;
|
|
712
|
+
if (f.geometry) clearedGeometryByInstance.set(iid, f.geometry);
|
|
713
|
+
if (f.id != null) clearedMapboxIdByInstance.set(iid, f.id);
|
|
714
|
+
return false;
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
for (const feat of delta.upserts) {
|
|
719
|
+
if (!feat || feat.type !== 'Feature') continue;
|
|
720
|
+
const normalizedFeat = normalizeNwwsHttpAlertFeature(
|
|
721
|
+
{
|
|
722
|
+
type: 'Feature',
|
|
723
|
+
id: feat.id,
|
|
724
|
+
geometry: feat.geometry,
|
|
725
|
+
properties: { ...(feat.properties ?? {}) },
|
|
726
|
+
},
|
|
727
|
+
true
|
|
728
|
+
);
|
|
729
|
+
const enriched = enrichWarningsWithColors({ type: 'FeatureCollection', features: [normalizedFeat] }).features[0];
|
|
730
|
+
const trackerStatus = getNwwsTrackerStatusFromProperties(enriched?.properties);
|
|
731
|
+
if (isNwwsTrackerStatusTerminal(trackerStatus)) {
|
|
732
|
+
const terminalId = getNwsBaseAlertIdFromFeature(enriched);
|
|
733
|
+
if (terminalId) removeIds.add(terminalId);
|
|
734
|
+
continue;
|
|
735
|
+
}
|
|
736
|
+
const baseId = getNwsBaseAlertIdFromFeature(enriched);
|
|
737
|
+
const instanceId = getNwsAlertInstanceIdFromFeature(enriched) ?? baseId;
|
|
738
|
+
|
|
739
|
+
const clearedGeometry = instanceId ? clearedGeometryByInstance.get(instanceId) : undefined;
|
|
740
|
+
const clearedMapboxId = instanceId ? clearedMapboxIdByInstance.get(instanceId) : undefined;
|
|
741
|
+
|
|
742
|
+
features.push({
|
|
743
|
+
...enriched,
|
|
744
|
+
id: clearedMapboxId ?? enriched.id,
|
|
745
|
+
geometry: isUsableNwwsAlertGeometry(enriched.geometry)
|
|
746
|
+
? enriched.geometry
|
|
747
|
+
: clearedGeometry ?? enriched.geometry,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
if (removeIds.size) {
|
|
752
|
+
features = features.filter((f) => {
|
|
753
|
+
const baseId = getNwsBaseAlertIdFromFeature(f);
|
|
754
|
+
if (baseId && removeIds.has(baseId)) return false;
|
|
755
|
+
const inst = getNwsAlertInstanceIdFromFeature(f);
|
|
756
|
+
if (inst && removeIds.has(inst)) return false;
|
|
757
|
+
return true;
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
features = ensureDistinctMapboxIdsOnFeatures(features);
|
|
762
|
+
return normalizeFeatureCollectionForMapboxGl({ type: 'FeatureCollection', features });
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
function pushIds(target, val) {
|
|
766
|
+
if (!Array.isArray(val)) return;
|
|
767
|
+
for (const x of val) {
|
|
768
|
+
if (x == null) continue;
|
|
769
|
+
if (typeof x === 'object') {
|
|
770
|
+
const o = x;
|
|
771
|
+
const id =
|
|
772
|
+
o.base_alert_id ??
|
|
773
|
+
o.base_alertid ??
|
|
774
|
+
o.baseAlertId ??
|
|
775
|
+
o.base_alertId ??
|
|
776
|
+
o.alert_id ??
|
|
777
|
+
o.alertId ??
|
|
778
|
+
o.id;
|
|
779
|
+
const normalizedId = normalizeNwwsAlertId(id);
|
|
780
|
+
if (normalizedId) target.push(normalizedId);
|
|
781
|
+
} else if (x !== '') {
|
|
782
|
+
const normalizedId = normalizeNwwsAlertId(x);
|
|
783
|
+
if (normalizedId) target.push(normalizedId);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
export function parseNwwsWsDelta(raw) {
|
|
789
|
+
const upserts = [];
|
|
790
|
+
const cancelIds = [];
|
|
791
|
+
const expireIds = [];
|
|
792
|
+
|
|
793
|
+
const pushSupersededIdsFromFeature = (feature) => {
|
|
794
|
+
const status = getNwwsTrackerStatusFromProperties(feature?.properties);
|
|
795
|
+
if (status === 'cancelled' || status === 'expired') return;
|
|
796
|
+
|
|
797
|
+
const p = feature?.properties ?? {};
|
|
798
|
+
const self = new Set();
|
|
799
|
+
const b = getNwsBaseAlertIdFromFeature(feature);
|
|
800
|
+
const inst = getNwsAlertInstanceIdFromFeature(feature);
|
|
801
|
+
if (b) self.add(b);
|
|
802
|
+
if (inst) self.add(inst);
|
|
803
|
+
|
|
804
|
+
for (const id of collectNwwsReplacementReferenceIds(p)) {
|
|
805
|
+
if (!self.has(id)) cancelIds.push(id);
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
const collectUpsertFeatures = (val) => {
|
|
810
|
+
if (!val) return;
|
|
811
|
+
const collectMaybeFeatureObject = (obj) => {
|
|
812
|
+
if (!obj || typeof obj !== 'object') return;
|
|
813
|
+
if (obj.type !== 'Feature' && obj.geometry != null && obj.properties != null && typeof obj.properties === 'object') {
|
|
814
|
+
obj = { type: 'Feature', id: obj.id, geometry: obj.geometry, properties: obj.properties };
|
|
815
|
+
}
|
|
816
|
+
if (obj.type === 'Feature') {
|
|
817
|
+
const status = getNwwsTrackerStatusFromProperties(obj?.properties);
|
|
818
|
+
const baseId = getNwsBaseAlertIdFromFeature(obj);
|
|
819
|
+
if (status === 'cancelled' && baseId) {
|
|
820
|
+
cancelIds.push(baseId);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
if (status === 'expired' && baseId) {
|
|
824
|
+
expireIds.push(baseId);
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
pushSupersededIdsFromFeature(obj);
|
|
828
|
+
upserts.push(obj);
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
if (obj.new != null) collectUpsertFeatures(obj.new);
|
|
832
|
+
if (obj.feature != null) collectUpsertFeatures(obj.feature);
|
|
833
|
+
if (obj.alert != null) collectUpsertFeatures(obj.alert);
|
|
834
|
+
if (obj.current != null) collectUpsertFeatures(obj.current);
|
|
835
|
+
};
|
|
836
|
+
if (Array.isArray(val)) {
|
|
837
|
+
for (const item of val) {
|
|
838
|
+
collectMaybeFeatureObject(item);
|
|
839
|
+
}
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
const candidate = val;
|
|
843
|
+
if (candidate?.type === 'Feature') {
|
|
844
|
+
collectMaybeFeatureObject(candidate);
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
if (candidate?.type === 'FeatureCollection' && Array.isArray(candidate.features)) {
|
|
848
|
+
for (const item of candidate.features) {
|
|
849
|
+
collectMaybeFeatureObject(item);
|
|
850
|
+
}
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
if (candidate?.data != null) collectUpsertFeatures(candidate.data);
|
|
854
|
+
if (candidate?.payload != null) collectUpsertFeatures(candidate.payload);
|
|
855
|
+
if (candidate?.feature != null) collectUpsertFeatures(candidate.feature);
|
|
856
|
+
if (candidate?.features != null) collectUpsertFeatures(candidate.features);
|
|
857
|
+
if (candidate?.items != null) collectUpsertFeatures(candidate.items);
|
|
858
|
+
if (candidate?.records != null) collectUpsertFeatures(candidate.records);
|
|
859
|
+
if (candidate?.alerts != null) collectUpsertFeatures(candidate.alerts);
|
|
860
|
+
if (candidate?.upsert != null) collectUpsertFeatures(candidate.upsert);
|
|
861
|
+
if (candidate?.upserts != null) collectUpsertFeatures(candidate.upserts);
|
|
862
|
+
if (candidate?.insert != null) collectUpsertFeatures(candidate.insert);
|
|
863
|
+
if (candidate?.inserts != null) collectUpsertFeatures(candidate.inserts);
|
|
864
|
+
if (candidate?.created != null) collectUpsertFeatures(candidate.created);
|
|
865
|
+
if (candidate?.updated != null) collectUpsertFeatures(candidate.updated);
|
|
866
|
+
if (candidate?.delta != null) {
|
|
867
|
+
const deltaInner = candidate.delta;
|
|
868
|
+
collectUpsertFeatures(deltaInner?.upsert ?? deltaInner?.upserts ?? deltaInner?.features ?? deltaInner?.data);
|
|
869
|
+
}
|
|
870
|
+
if (candidate?.patch != null) collectUpsertFeatures(candidate.patch);
|
|
871
|
+
};
|
|
872
|
+
|
|
873
|
+
if (raw == null || typeof raw !== 'object') return { upserts, cancelIds, expireIds };
|
|
874
|
+
if (Array.isArray(raw)) {
|
|
875
|
+
collectUpsertFeatures(raw);
|
|
876
|
+
return { upserts, cancelIds, expireIds };
|
|
877
|
+
}
|
|
878
|
+
const msg = raw;
|
|
879
|
+
const collectBuckets = (container) => {
|
|
880
|
+
const action = container.type ?? container.action;
|
|
881
|
+
|
|
882
|
+
if (container.upsert != null) collectUpsertFeatures(container.upsert);
|
|
883
|
+
if (container.upserts != null) collectUpsertFeatures(container.upserts);
|
|
884
|
+
if (container.alert != null) collectUpsertFeatures(container.alert);
|
|
885
|
+
if (container.alerts != null) collectUpsertFeatures(container.alerts);
|
|
886
|
+
if (container.new != null) collectUpsertFeatures(container.new);
|
|
887
|
+
if (container.extended != null) collectUpsertFeatures(container.extended);
|
|
888
|
+
if (container.upgraded != null) collectUpsertFeatures(container.upgraded);
|
|
889
|
+
if (action === 'upsert' || action === 'Upsert') {
|
|
890
|
+
collectUpsertFeatures(container.features ?? container.items ?? container.data);
|
|
891
|
+
}
|
|
892
|
+
if (container.features != null && container.type !== 'FeatureCollection') {
|
|
893
|
+
collectUpsertFeatures(container.features);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
if (container.cancel != null) pushIds(cancelIds, container.cancel);
|
|
897
|
+
if (container.cancels != null) pushIds(cancelIds, container.cancels);
|
|
898
|
+
if (container.cancelled != null) pushIds(cancelIds, container.cancelled);
|
|
899
|
+
if (container.canceled != null) pushIds(cancelIds, container.canceled);
|
|
900
|
+
if (action === 'cancel' || action === 'Cancel') {
|
|
901
|
+
pushIds(cancelIds, container.ids ?? container.base_alert_ids ?? container.items);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
if (container.expire != null) pushIds(expireIds, container.expire);
|
|
905
|
+
if (container.expires != null) pushIds(expireIds, container.expires);
|
|
906
|
+
if (container.expired != null) pushIds(expireIds, container.expired);
|
|
907
|
+
if (action === 'expire' || action === 'Expire') {
|
|
908
|
+
pushIds(expireIds, container.ids ?? container.base_alert_ids ?? container.items);
|
|
909
|
+
}
|
|
910
|
+
};
|
|
911
|
+
|
|
912
|
+
collectBuckets(msg);
|
|
913
|
+
if (msg.diff != null && typeof msg.diff === 'object') {
|
|
914
|
+
collectBuckets(msg.diff);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
return { upserts, cancelIds, expireIds };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
export function filterNwwsTerminalStatusFeatures(fc) {
|
|
921
|
+
const base = normalizeFeatureCollectionForMapboxGl(fc);
|
|
922
|
+
return {
|
|
923
|
+
type: 'FeatureCollection',
|
|
924
|
+
features: base.features.filter((feature) => {
|
|
925
|
+
const status = getNwwsTrackerStatusFromProperties(feature?.properties);
|
|
926
|
+
return !isNwwsTrackerStatusTerminal(status);
|
|
927
|
+
}),
|
|
928
|
+
};
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Legacy filter: tornado + severe-thunderstorm family only (same as pre–all-alerts SDK).
|
|
933
|
+
* @param {import('geojson').FeatureCollection} fc
|
|
934
|
+
*/
|
|
935
|
+
export function filterToConvectiveSdkEvents(fc) {
|
|
936
|
+
const base = normalizeFeatureCollectionForMapboxGl(fc);
|
|
937
|
+
const out = [];
|
|
938
|
+
for (const f of base.features) {
|
|
939
|
+
const ev = getNwsAlertEventLabelFromProperties(f?.properties ?? {});
|
|
940
|
+
if (ev && SDK_ALLOWED_EVENT_SET.has(ev)) {
|
|
941
|
+
out.push(f);
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
return { type: 'FeatureCollection', features: out };
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Whether a resolved NWS `event_name` should be shown when the user chose {@link filterNwsAlertsByIncludedList}.
|
|
949
|
+
* Matches exact names; also matches when the user lists a parent product (e.g. "Tornado Warning") and the feature
|
|
950
|
+
* is a known subtype (e.g. "Tornado Warning (PDS)") via {@link NWS_WARNINGS_SUBTYPE_PARENT}.
|
|
951
|
+
* @param {string[] | null | undefined} includedAlerts
|
|
952
|
+
* @param {string} eventLabel
|
|
953
|
+
*/
|
|
954
|
+
export function isNwsEventIncludedInAllowlist(includedAlerts, eventLabel) {
|
|
955
|
+
if (!includedAlerts?.length || !eventLabel) {
|
|
956
|
+
return false;
|
|
957
|
+
}
|
|
958
|
+
const set = new Set(
|
|
959
|
+
includedAlerts.map((x) => (x != null ? String(x).trim() : '')).filter(Boolean)
|
|
960
|
+
);
|
|
961
|
+
if (set.size === 0) {
|
|
962
|
+
return false;
|
|
963
|
+
}
|
|
964
|
+
const ev = String(eventLabel).trim();
|
|
965
|
+
if (set.has(ev)) {
|
|
966
|
+
return true;
|
|
967
|
+
}
|
|
968
|
+
const parent = NWS_WARNINGS_SUBTYPE_PARENT[ev];
|
|
969
|
+
if (parent && set.has(parent)) {
|
|
970
|
+
return true;
|
|
971
|
+
}
|
|
972
|
+
if (ev === 'Tornado Warning (Observed)' && set.has('Tornado Warning (Reported)')) {
|
|
973
|
+
return true;
|
|
974
|
+
}
|
|
975
|
+
return false;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/**
|
|
979
|
+
* @param {import('geojson').FeatureCollection} fc
|
|
980
|
+
* @param {string[]} includedAlerts
|
|
981
|
+
*/
|
|
982
|
+
export function filterNwsAlertsByIncludedList(fc, includedAlerts) {
|
|
983
|
+
const base = normalizeFeatureCollectionForMapboxGl(fc);
|
|
984
|
+
const out = [];
|
|
985
|
+
for (const f of base.features) {
|
|
986
|
+
const ev = getNwsAlertEventLabelFromProperties(f?.properties ?? {});
|
|
987
|
+
if (isNwsEventIncludedInAllowlist(includedAlerts, ev)) {
|
|
988
|
+
out.push(f);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
return { type: 'FeatureCollection', features: out };
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* @param {import('geojson').FeatureCollection} fc
|
|
996
|
+
* @param {'all' | 'user'} [scope]
|
|
997
|
+
* @param {string[] | undefined} [includedAlerts] - Used when `scope === 'user'`: exact NWS `event_name` strings to show.
|
|
998
|
+
*/
|
|
999
|
+
export function filterNwsAlertsByScope(fc, scope = 'all', includedAlerts) {
|
|
1000
|
+
const safe = normalizeFeatureCollectionForMapboxGl(fc);
|
|
1001
|
+
if (scope === 'user') {
|
|
1002
|
+
return filterNwsAlertsByIncludedList(safe, includedAlerts ?? []);
|
|
1003
|
+
}
|
|
1004
|
+
const features = safe.features.filter((f) => {
|
|
1005
|
+
const ev = getNwsAlertEventLabelFromProperties(f?.properties ?? {});
|
|
1006
|
+
return !!(ev && String(ev).trim());
|
|
1007
|
+
});
|
|
1008
|
+
return { type: 'FeatureCollection', features };
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
/** @deprecated Use {@link filterToConvectiveSdkEvents} or {@link filterNwsAlertsByScope} with `scope: 'user'` and an explicit `includedAlerts` list. */
|
|
1012
|
+
export function filterToSdkAllowedEvents(fc) {
|
|
1013
|
+
return filterToConvectiveSdkEvents(fc);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
function extractRawNwwsVtecString(properties) {
|
|
1017
|
+
if (!properties) return null;
|
|
1018
|
+
if (properties.vtec != null) return String(properties.vtec);
|
|
1019
|
+
if (properties.VTEC != null) return String(properties.VTEC);
|
|
1020
|
+
const details = properties.details;
|
|
1021
|
+
if (details?.pvtec != null) return String(details.pvtec);
|
|
1022
|
+
const params = properties.parameters;
|
|
1023
|
+
if (params != null && typeof params === 'object' && !Array.isArray(params)) {
|
|
1024
|
+
const direct = params.VTEC ?? params.vtec;
|
|
1025
|
+
if (direct != null) {
|
|
1026
|
+
return Array.isArray(direct) ? String(direct[0] ?? '') : String(direct);
|
|
1027
|
+
}
|
|
1028
|
+
for (const k of Object.keys(params)) {
|
|
1029
|
+
if (String(k).toUpperCase() === 'VTEC') {
|
|
1030
|
+
const x = params[k];
|
|
1031
|
+
if (x != null) return Array.isArray(x) ? String(x[0] ?? '') : String(x);
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
return null;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function normalizeNwsChainTimeComponent(v) {
|
|
1039
|
+
if (v == null || v === '') return null;
|
|
1040
|
+
if (typeof v === 'number' && Number.isFinite(v)) {
|
|
1041
|
+
return new Date(v > 1e12 ? v : v * 1000).toISOString();
|
|
1042
|
+
}
|
|
1043
|
+
const s = String(v).trim();
|
|
1044
|
+
const ms = Date.parse(s);
|
|
1045
|
+
if (!Number.isNaN(ms)) return new Date(ms).toISOString();
|
|
1046
|
+
return s;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function getNwsOfficeEventIssuedChainId(properties) {
|
|
1050
|
+
if (!properties) return null;
|
|
1051
|
+
const office = String(properties.office ?? properties.sender_icao ?? properties.sender ?? '').trim();
|
|
1052
|
+
const ev = String(properties.event ?? properties.event_name ?? '').trim();
|
|
1053
|
+
const rawIssued = getNwsTimeProp(properties, ['issued', 'issued_at']);
|
|
1054
|
+
if (!office || !ev || rawIssued == null || rawIssued === '') return null;
|
|
1055
|
+
const issuedNorm = normalizeNwsChainTimeComponent(rawIssued);
|
|
1056
|
+
if (!issuedNorm) return null;
|
|
1057
|
+
return `${office}|${ev}|${issuedNorm}`;
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1060
|
+
/**
|
|
1061
|
+
* Extracts a stable VTEC-derived alert ID (e.g. "KWNS.SV.A.0108") from raw feature properties.
|
|
1062
|
+
* The VTEC event number is stable across CON/EXT/CAN actions for the same watch/warning,
|
|
1063
|
+
* unlike content-based hashes which change on every update.
|
|
1064
|
+
*/
|
|
1065
|
+
function extractNwsVtecStableId(properties) {
|
|
1066
|
+
const vtec = extractRawNwwsVtecString(properties);
|
|
1067
|
+
if (vtec == null || vtec === '') return null;
|
|
1068
|
+
const parts = String(vtec).split('.');
|
|
1069
|
+
if (parts.length < 6) return null;
|
|
1070
|
+
const office = parts[2]?.trim();
|
|
1071
|
+
const ph = parts[3]?.trim();
|
|
1072
|
+
const sig = parts[4]?.trim();
|
|
1073
|
+
const seq = parts[5]?.trim();
|
|
1074
|
+
if (!office || !ph || !sig || !seq) return null;
|
|
1075
|
+
return `${office}.${ph}.${sig}.${seq}`;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function getNwsHttpAlertStableId(properties) {
|
|
1079
|
+
if (!properties) return null;
|
|
1080
|
+
const details = properties.details;
|
|
1081
|
+
if (details && typeof details === 'object') {
|
|
1082
|
+
const tr = details.tracking;
|
|
1083
|
+
if (tr != null && String(tr).trim() !== '') return String(tr).trim();
|
|
1084
|
+
}
|
|
1085
|
+
// Same DB key as backend — use if trim drops details/hash (see API trim payloads).
|
|
1086
|
+
for (const k of ['nws_stable_id', 'stable_id', 'alert_chain_id']) {
|
|
1087
|
+
const v = properties[k];
|
|
1088
|
+
if (v != null && String(v).trim() !== '') return String(v).trim();
|
|
1089
|
+
}
|
|
1090
|
+
if (properties.tracking != null && typeof properties.tracking === 'string' && String(properties.tracking).trim() !== '') {
|
|
1091
|
+
return String(properties.tracking).trim();
|
|
1092
|
+
}
|
|
1093
|
+
// Prefer VTEC-derived ID over hash: VTEC event number is stable across all CON/EXT/UPG
|
|
1094
|
+
// actions while the content hash changes on every update, causing phantom duplicates on the map.
|
|
1095
|
+
const vtecId = extractNwsVtecStableId(properties);
|
|
1096
|
+
if (vtecId) return vtecId;
|
|
1097
|
+
const chainId = getNwsOfficeEventIssuedChainId(properties);
|
|
1098
|
+
if (chainId) return chainId;
|
|
1099
|
+
const h = properties.hash;
|
|
1100
|
+
if (h != null && String(h).trim() !== '') return String(h).trim();
|
|
1101
|
+
return null;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function mapHttpActionToTrackerStatus(p, forLiveStream) {
|
|
1105
|
+
if (!forLiveStream) return undefined;
|
|
1106
|
+
if (p.is_cancelled === true) return 'cancelled';
|
|
1107
|
+
const at = String(p.action_type ?? '').trim().toLowerCase();
|
|
1108
|
+
if (at === 'cancelled' || at === 'canceled') return 'cancelled';
|
|
1109
|
+
if (at === 'expired') return 'expired';
|
|
1110
|
+
if (at === 'upgraded') return 'upgraded';
|
|
1111
|
+
if (at === 'extended' || at === 'extension') return 'extended';
|
|
1112
|
+
if (at === 'updated') return 'extended';
|
|
1113
|
+
if (at === 'continuation' || at === 'continued') return 'continuation';
|
|
1114
|
+
if (at === 'issued' || at === 'new') return 'issued';
|
|
1115
|
+
if (at === '') return 'issued';
|
|
1116
|
+
return 'issued';
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
export function normalizeNwwsHttpAlertFeature(raw, forLiveStream) {
|
|
1120
|
+
if (!raw || typeof raw !== 'object' || raw.type !== 'Feature') return raw;
|
|
1121
|
+
const p = { ...(raw.properties ?? {}) };
|
|
1122
|
+
const explicitUid =
|
|
1123
|
+
normalizeNwwsAlertId(raw.id) ?? normalizeNwwsAlertId(p.id) ?? normalizeNwwsAlertId(p.alert_id ?? p.alertId);
|
|
1124
|
+
if (explicitUid && isNwwsVtecTimestampInstanceId(explicitUid)) {
|
|
1125
|
+
p.base_alert_id = explicitUid;
|
|
1126
|
+
p.alert_id = explicitUid;
|
|
1127
|
+
const vb = getNwwsVtecSequenceBaseFromInstanceId(explicitUid);
|
|
1128
|
+
if (vb) p._nws_vtec_seq_base = vb;
|
|
1129
|
+
} else {
|
|
1130
|
+
const stableId = getNwsHttpAlertStableId(p);
|
|
1131
|
+
if (stableId) {
|
|
1132
|
+
p.base_alert_id = stableId;
|
|
1133
|
+
p.alert_id = stableId;
|
|
1134
|
+
} else {
|
|
1135
|
+
const fallback =
|
|
1136
|
+
normalizeNwwsAlertId(raw.id) ?? normalizeNwwsAlertId(p.id) ?? normalizeNwwsAlertId(p.alert_id ?? p.alertId);
|
|
1137
|
+
if (fallback) {
|
|
1138
|
+
p.base_alert_id = fallback;
|
|
1139
|
+
p.alert_id = fallback;
|
|
1140
|
+
} else {
|
|
1141
|
+
const chain = getNwsOfficeEventIssuedChainId(p);
|
|
1142
|
+
if (chain) {
|
|
1143
|
+
p.base_alert_id = chain;
|
|
1144
|
+
p.alert_id = chain;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
const tracker = mapHttpActionToTrackerStatus(p, forLiveStream);
|
|
1150
|
+
if (tracker !== undefined) {
|
|
1151
|
+
p._trackerStatus = tracker;
|
|
1152
|
+
}
|
|
1153
|
+
if (p._nws_vtec_seq_base == null) {
|
|
1154
|
+
const capV = extractNwsVtecStableId(p);
|
|
1155
|
+
if (capV) p._nws_vtec_seq_base = capV;
|
|
1156
|
+
}
|
|
1157
|
+
return { ...raw, properties: p };
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
export function unwrapNwwsFeatureCollectionRoot(parsed) {
|
|
1161
|
+
if (Array.isArray(parsed)) {
|
|
1162
|
+
return {
|
|
1163
|
+
type: 'FeatureCollection',
|
|
1164
|
+
features: parsed.map((f) => normalizeNwwsHttpAlertFeature(f, false)),
|
|
1165
|
+
};
|
|
1166
|
+
}
|
|
1167
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
1168
|
+
const o = parsed;
|
|
1169
|
+
if (o.type === 'FeatureCollection' && Array.isArray(o.features)) {
|
|
1170
|
+
return {
|
|
1171
|
+
type: 'FeatureCollection',
|
|
1172
|
+
features: o.features.map((f) => normalizeNwwsHttpAlertFeature(f, false)),
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
for (const key of ['geojson', 'data', 'payload', 'body', 'collection', 'result', 'message', 'record']) {
|
|
1176
|
+
const inner = o[key];
|
|
1177
|
+
if (
|
|
1178
|
+
inner &&
|
|
1179
|
+
typeof inner === 'object' &&
|
|
1180
|
+
inner.type === 'FeatureCollection' &&
|
|
1181
|
+
Array.isArray(inner.features)
|
|
1182
|
+
) {
|
|
1183
|
+
return {
|
|
1184
|
+
type: 'FeatureCollection',
|
|
1185
|
+
features: inner.features.map((f) => normalizeNwwsHttpAlertFeature(f, false)),
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
return null;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
export function cloneNwwsFeatureCollectionStrippingVolatile(data) {
|
|
1193
|
+
const norm = normalizeFeatureCollectionForMapboxGl(data);
|
|
1194
|
+
return {
|
|
1195
|
+
type: 'FeatureCollection',
|
|
1196
|
+
features: norm.features.flatMap((f) => {
|
|
1197
|
+
const p = f?.properties;
|
|
1198
|
+
if (p) {
|
|
1199
|
+
const ex = p.nwws_expired;
|
|
1200
|
+
if (ex === true || ex === 1 || String(ex).toLowerCase() === 'true') {
|
|
1201
|
+
return [];
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
const copy = {
|
|
1205
|
+
...f,
|
|
1206
|
+
properties: p ? { ...p } : {},
|
|
1207
|
+
};
|
|
1208
|
+
delete copy._lastActiveState;
|
|
1209
|
+
if (copy.properties && 'nwws_expired' in copy.properties) {
|
|
1210
|
+
delete copy.properties.nwws_expired;
|
|
1211
|
+
}
|
|
1212
|
+
if (copy.properties) {
|
|
1213
|
+
delete copy.properties._nws_flash_until_unix;
|
|
1214
|
+
delete copy.properties._nws_webhook_debug_flash;
|
|
1215
|
+
}
|
|
1216
|
+
return [copy];
|
|
1217
|
+
}),
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/**
|
|
1222
|
+
* Parse `properties.parameters` (JSON string or object from NWWS / CAP-style extensions).
|
|
1223
|
+
* @param {unknown} raw
|
|
1224
|
+
* @returns {Record<string, unknown>|null}
|
|
1225
|
+
*/
|
|
1226
|
+
function parseNwsParametersJson(raw) {
|
|
1227
|
+
if (raw == null) return null;
|
|
1228
|
+
if (typeof raw === 'object' && !Array.isArray(raw)) return /** @type {Record<string, unknown>} */ (raw);
|
|
1229
|
+
if (typeof raw === 'string') {
|
|
1230
|
+
try {
|
|
1231
|
+
const o = JSON.parse(raw);
|
|
1232
|
+
return o && typeof o === 'object' && !Array.isArray(o) ? o : null;
|
|
1233
|
+
} catch {
|
|
1234
|
+
return null;
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
return null;
|
|
1238
|
+
}
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* Human-oriented subset of `parameters` for severe wx / common warning types (hail, wind, radar source).
|
|
1242
|
+
* @param {Record<string, unknown>|null} pObj
|
|
1243
|
+
* @returns {Record<string, string> | null}
|
|
1244
|
+
*/
|
|
1245
|
+
function buildHazardDetailsFromParameters(pObj) {
|
|
1246
|
+
if (!pObj || typeof pObj !== 'object') return null;
|
|
1247
|
+
/** @type {Record<string, string>} */
|
|
1248
|
+
const out = {};
|
|
1249
|
+
const set = (key, from) => {
|
|
1250
|
+
const v = pObj[from];
|
|
1251
|
+
if (v != null && String(v).trim() !== '') out[key] = String(v);
|
|
1252
|
+
};
|
|
1253
|
+
set('source', 'source');
|
|
1254
|
+
set('maxHailSize', 'max_hail_size');
|
|
1255
|
+
set('maxWindGust', 'max_wind_gust');
|
|
1256
|
+
set('damageThreat', 'damage_threat');
|
|
1257
|
+
set('wmo', 'wmo');
|
|
1258
|
+
if (pObj.tornado_detection != null && String(pObj.tornado_detection).trim() !== '')
|
|
1259
|
+
out.tornadoDetection = String(pObj.tornado_detection);
|
|
1260
|
+
if (pObj.flood_detection != null && String(pObj.flood_detection).trim() !== '')
|
|
1261
|
+
out.floodDetection = String(pObj.flood_detection);
|
|
1262
|
+
return Object.keys(out).length ? out : null;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/**
|
|
1266
|
+
* Normalized payload for `nws:alert:click`. Top-level fields are derived from `feature.properties`
|
|
1267
|
+
* so you rarely need the duplicate `properties` object (kept for advanced / forward-compatible use).
|
|
1268
|
+
*
|
|
1269
|
+
* @param {GeoJSON.Feature|{ properties?: Record<string, unknown> }} feature
|
|
1270
|
+
*/
|
|
1271
|
+
export function buildAlertClickPayload(feature) {
|
|
1272
|
+
const properties = feature?.properties ? { ...feature.properties } : {};
|
|
1273
|
+
const eventName = getNwsAlertEventLabelFromProperties(properties);
|
|
1274
|
+
let tags = properties.tags;
|
|
1275
|
+
if (typeof tags === 'string') {
|
|
1276
|
+
try {
|
|
1277
|
+
tags = JSON.parse(tags);
|
|
1278
|
+
} catch {
|
|
1279
|
+
tags = null;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
if (!Array.isArray(tags)) {
|
|
1283
|
+
tags = tags != null ? [String(tags)] : [];
|
|
1284
|
+
}
|
|
1285
|
+
const name =
|
|
1286
|
+
(properties.headline != null && String(properties.headline).trim() !== ''
|
|
1287
|
+
? String(properties.headline)
|
|
1288
|
+
: '') ||
|
|
1289
|
+
eventName ||
|
|
1290
|
+
(properties.event != null ? String(properties.event) : '') ||
|
|
1291
|
+
(properties.event_name != null ? String(properties.event_name) : '');
|
|
1292
|
+
const description =
|
|
1293
|
+
properties.description != null
|
|
1294
|
+
? String(properties.description)
|
|
1295
|
+
: properties.raw_text != null
|
|
1296
|
+
? String(properties.raw_text)
|
|
1297
|
+
: '';
|
|
1298
|
+
|
|
1299
|
+
const startUnix =
|
|
1300
|
+
typeof properties.start_unix === 'number'
|
|
1301
|
+
? properties.start_unix
|
|
1302
|
+
: getNwsStartUnixForMap(properties);
|
|
1303
|
+
const endUnix =
|
|
1304
|
+
typeof properties.end_unix === 'number'
|
|
1305
|
+
? properties.end_unix
|
|
1306
|
+
: parseNwsTimeToUnix(getNwsTimeProp(properties, [...NWS_ALERT_END_TIME_PROP_KEYS]));
|
|
1307
|
+
const activeStartUnix =
|
|
1308
|
+
typeof properties.active_start_unix === 'number'
|
|
1309
|
+
? properties.active_start_unix
|
|
1310
|
+
: getNwsActiveStartUnix(properties);
|
|
1311
|
+
|
|
1312
|
+
const parametersParsed = parseNwsParametersJson(properties.parameters);
|
|
1313
|
+
const hazardDetails = buildHazardDetailsFromParameters(parametersParsed);
|
|
1314
|
+
|
|
1315
|
+
return {
|
|
1316
|
+
name,
|
|
1317
|
+
eventName,
|
|
1318
|
+
description,
|
|
1319
|
+
tags,
|
|
1320
|
+
startUnix,
|
|
1321
|
+
endUnix,
|
|
1322
|
+
activeStartUnix,
|
|
1323
|
+
/** ISO-ish strings from the feed (see also unix fields above). */
|
|
1324
|
+
issued: properties.issued != null ? String(properties.issued) : '',
|
|
1325
|
+
updatedAt: properties.updated_at != null ? String(properties.updated_at) : '',
|
|
1326
|
+
expiresAt: properties.expires != null ? String(properties.expires) : '',
|
|
1327
|
+
alertId: properties.alert_id != null ? String(properties.alert_id) : '',
|
|
1328
|
+
office: properties.office != null ? String(properties.office) : '',
|
|
1329
|
+
nwsProductKey: properties.nws_product_key != null ? String(properties.nws_product_key) : '',
|
|
1330
|
+
/** Parsed `properties.parameters` JSON when present (hail/wind/source keys vary by product). */
|
|
1331
|
+
parametersParsed,
|
|
1332
|
+
/** Short labels for SV / similar products: source, maxHailSize, maxWindGust, damageThreat, … */
|
|
1333
|
+
hazardDetails,
|
|
1334
|
+
/** Same as `feature.properties` — prefer top-level fields above when possible. */
|
|
1335
|
+
properties,
|
|
1336
|
+
};
|
|
1337
|
+
}
|