@aguacerowx/mapsgl 0.0.46 → 0.0.48

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1111 +1,1156 @@
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
- const NWS_START_UNIX_KEYS = ['issued_at', 'issued', 'sent', 'onset', 'effective'];
159
- const NWS_ACTIVE_START_UNIX_KEYS = ['onset', 'effective', 'issued_at', 'issued', 'sent'];
160
-
161
- function getNwsStartUnixForMap(properties) {
162
- const startStr = getNwsTimeProp(properties, NWS_START_UNIX_KEYS);
163
- return parseNwsTimeToUnix(startStr) ?? 0;
164
- }
165
-
166
- function getNwsActiveStartUnix(properties) {
167
- const startStr = getNwsTimeProp(properties, NWS_ACTIVE_START_UNIX_KEYS);
168
- return parseNwsTimeToUnix(startStr) ?? 0;
169
- }
170
-
171
- export function getEarliestNwsReferenceSentRaw(properties) {
172
- if (!properties) return null;
173
- let refs = properties.references;
174
- if (typeof refs === 'string') {
175
- try {
176
- refs = JSON.parse(refs);
177
- } catch {
178
- return null;
179
- }
180
- }
181
- if (!Array.isArray(refs) || refs.length === 0) return null;
182
- let best = null;
183
- for (const r of refs) {
184
- if (!r || typeof r !== 'object') continue;
185
- const raw = r.sent ?? r.Sent;
186
- if (raw == null || raw === '') continue;
187
- const s = String(raw);
188
- const t = new Date(s).getTime();
189
- if (Number.isNaN(t)) continue;
190
- if (!best || t < best.t) best = { t, raw: s };
191
- }
192
- return best?.raw ?? null;
193
- }
194
-
195
- let nextWarningFeatureId = 1;
196
-
197
- function allocateNwsMapboxFeatureId(rawId) {
198
- if (typeof rawId === 'number' && Number.isFinite(rawId) && rawId >= 0 && rawId <= Number.MAX_SAFE_INTEGER) {
199
- return Math.trunc(rawId);
200
- }
201
- return nextWarningFeatureId++;
202
- }
203
-
204
- function ensureDistinctMapboxIdsOnFeatures(features) {
205
- const used = new Set();
206
- return features.map((f) => {
207
- let id =
208
- typeof f?.id === 'number' && Number.isFinite(f.id) && f.id >= 0 && f.id <= Number.MAX_SAFE_INTEGER
209
- ? Math.trunc(f.id)
210
- : null;
211
- if (id == null) {
212
- while (used.has(nextWarningFeatureId)) {
213
- nextWarningFeatureId++;
214
- }
215
- id = nextWarningFeatureId++;
216
- used.add(id);
217
- return { ...f, id };
218
- }
219
- if (!used.has(id)) {
220
- used.add(id);
221
- return f;
222
- }
223
- while (used.has(nextWarningFeatureId)) {
224
- nextWarningFeatureId++;
225
- }
226
- const newId = nextWarningFeatureId++;
227
- used.add(newId);
228
- return { ...f, id: newId };
229
- });
230
- }
231
-
232
- export function enrichWarningsWithColors(geojson) {
233
- if (!geojson?.features?.length) return geojson;
234
- const features = geojson.features.map((f) => {
235
- const p = f.properties || {};
236
- const earliestRef = getEarliestNwsReferenceSentRaw(p);
237
- const hasExplicitIssued = getNwsTimeProp(p, ['issued_at', 'issued']) != null;
238
- const nwsIssuedPatch =
239
- earliestRef && !hasExplicitIssued ? { _nws_issued_at: earliestRef } : null;
240
-
241
- const eventName = getNwsAlertEventLabelFromProperties(p);
242
- const renderPriority = computeNwsAlertRenderPriority(p, eventName);
243
-
244
- const startUnix = getNwsStartUnixForMap(p);
245
- const activeStartUnix = getNwsActiveStartUnix(p);
246
- const endStr = getNwsTimeProp(p, [...NWS_ALERT_END_TIME_PROP_KEYS]);
247
- const endUnix = parseNwsTimeToUnix(endStr) ?? 2147483647;
248
-
249
- const mapboxFeatureId = allocateNwsMapboxFeatureId(f.id);
250
-
251
- const hasEventName = !!eventName;
252
- const eventNameUnchanged = !hasEventName || p.event_name === eventName;
253
- const priorityUnchanged = p[NWS_RENDER_PRIORITY_PROP] === renderPriority;
254
- const startUnchanged = p.start_unix === startUnix;
255
- const activeStartUnchanged = p.active_start_unix === activeStartUnix;
256
- const endUnchanged = p.end_unix === endUnix;
257
- const idUnchanged = f.id === mapboxFeatureId;
258
- const nwsIssuedUnchanged =
259
- nwsIssuedPatch == null || String(p._nws_issued_at ?? '') === String(nwsIssuedPatch._nws_issued_at);
260
-
261
- if (
262
- eventNameUnchanged &&
263
- priorityUnchanged &&
264
- startUnchanged &&
265
- activeStartUnchanged &&
266
- endUnchanged &&
267
- idUnchanged &&
268
- nwsIssuedUnchanged
269
- ) {
270
- return f;
271
- }
272
- return {
273
- ...f,
274
- id: mapboxFeatureId,
275
- properties: {
276
- ...p,
277
- ...(nwsIssuedPatch ?? {}),
278
- ...(hasEventName ? { event_name: eventName } : {}),
279
- [NWS_RENDER_PRIORITY_PROP]: renderPriority,
280
- start_unix: startUnix,
281
- active_start_unix: activeStartUnix,
282
- end_unix: endUnix,
283
- },
284
- };
285
- });
286
- const distinctIds = ensureDistinctMapboxIdsOnFeatures(features);
287
- return { type: 'FeatureCollection', features: sortWarningsFeaturesForRender(distinctIds) };
288
- }
289
-
290
- export function normalizeNwwsAlertId(value) {
291
- if (value == null || value === '') return null;
292
- const s = String(value).trim();
293
- if (!s) return null;
294
- const marker = '/alerts/';
295
- const idx = s.indexOf(marker);
296
- if (idx >= 0) {
297
- const tail = s.slice(idx + marker.length).trim();
298
- return tail || s;
299
- }
300
- return s;
301
- }
302
-
303
- function isNwwsVtecTimestampInstanceId(id) {
304
- const parts = id.split('.');
305
- if (parts.length < 5) return false;
306
- const last = parts[parts.length - 1] ?? '';
307
- return /^\d{8,}$/.test(last);
308
- }
309
-
310
- function nwwsFeatureIdLooksLikeMapboxAllocation(raw) {
311
- if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0 && raw <= Number.MAX_SAFE_INTEGER) {
312
- return true;
313
- }
314
- if (typeof raw === 'string') {
315
- const t = raw.trim();
316
- if (t.includes('.')) return false;
317
- if (/^\d+$/.test(t) && t.length <= 16) return true;
318
- }
319
- return false;
320
- }
321
-
322
- export function getNwwsApiAlertUniqueId(feature) {
323
- if (!feature || typeof feature !== 'object') return null;
324
- const p = feature.properties;
325
- const fromProps =
326
- normalizeNwwsAlertId(p?.base_alert_id) ??
327
- normalizeNwwsAlertId(p?.base_alertid ?? p?.baseAlertId ?? p?.base_alertId) ??
328
- normalizeNwwsAlertId(p?.alert_id ?? p?.alertId) ??
329
- normalizeNwwsAlertId(p?.id);
330
- if (fromProps) return fromProps;
331
- const rid = feature.id;
332
- if (rid == null || rid === '' || nwwsFeatureIdLooksLikeMapboxAllocation(rid)) return null;
333
- return normalizeNwwsAlertId(rid);
334
- }
335
-
336
- function getNwwsVtecSequenceBaseFromInstanceId(fullId) {
337
- const parts = String(fullId).trim().split('.');
338
- if (parts.length < 5) return null;
339
- return parts.slice(0, 4).join('.');
340
- }
341
-
342
- function getNwwsVtecSeqBaseFromFeature(feature) {
343
- const p = feature?.properties;
344
- if (!p) return null;
345
- const pinned = p._nws_vtec_seq_base;
346
- if (typeof pinned === 'string' && pinned.includes('.')) return pinned.trim();
347
- const uid = getNwwsApiAlertUniqueId(feature);
348
- if (uid) {
349
- const fromUid = getNwwsVtecSequenceBaseFromInstanceId(uid);
350
- if (fromUid) return fromUid;
351
- }
352
- return extractNwsVtecStableId(p);
353
- }
354
-
355
- export function getNwwsTrackerStatusFromProperties(properties) {
356
- if (!properties) return null;
357
- const raw = properties._trackerStatus ?? properties.trackerStatus ?? properties.tracker_status;
358
- if (raw == null || raw === '') {
359
- if (properties.is_cancelled === true) return 'cancelled';
360
- return null;
361
- }
362
- const s = String(raw).trim().toLowerCase();
363
- if (!s) return null;
364
- if (s === 'canceled') return 'cancelled';
365
- if (s === 'cancel') return 'cancelled';
366
- if (s === 'expire') return 'expired';
367
- if (s === 'upgrade') return 'upgraded';
368
- if (s === 'extend' || s === 'extended' || s === 'extension' || s === 'expanded' || s === 'expansion') {
369
- return 'extended';
370
- }
371
- if (s === 'continuation' || s === 'continued' || s === 'correction' || s === 'continue') {
372
- return 'continuation';
373
- }
374
- return s;
375
- }
376
-
377
- export function isNwwsTrackerStatusTerminal(status) {
378
- return status === 'cancelled' || status === 'expired';
379
- }
380
-
381
- /**
382
- * CAP references / explicit replace fields for the previous bulletin(s) in the same event chain.
383
- * Used so extensions/upgrades/continuations remove polygons keyed by superseded ids, not only by
384
- * the new feature's `base_alert_id`.
385
- */
386
- function collectNwwsReplacementReferenceIds(properties) {
387
- if (!properties || typeof properties !== 'object') return [];
388
- const out = [];
389
- const pushRaw = (v) => {
390
- if (v == null) return;
391
- if (Array.isArray(v)) {
392
- for (const x of v) pushRaw(x);
393
- return;
394
- }
395
- const n = normalizeNwwsAlertId(v);
396
- if (n) out.push(n);
397
- };
398
- pushRaw(properties._upgradedFrom ?? properties.upgradedFrom);
399
- pushRaw(properties._replaces ?? properties.replaces ?? properties.replaced_alert_id ?? properties.replacedAlertId);
400
- let refs = properties.references;
401
- if (typeof refs === 'string') {
402
- try {
403
- refs = JSON.parse(refs);
404
- } catch {
405
- refs = null;
406
- }
407
- }
408
- if (Array.isArray(refs)) {
409
- for (const r of refs) {
410
- if (typeof r === 'string') pushRaw(r);
411
- else if (r && typeof r === 'object') {
412
- const ro = r;
413
- pushRaw(ro.identifier ?? ro.id ?? ro.alert_id ?? ro.alertId ?? ro.base_alert_id);
414
- }
415
- }
416
- }
417
- return out;
418
- }
419
-
420
- export function getNwsBaseAlertIdFromFeature(feature) {
421
- const p = feature?.properties;
422
- const base = normalizeNwwsAlertId(p?.base_alert_id ?? p?.base_alertid ?? p?.baseAlertId ?? p?.base_alertId);
423
- if (base) return base;
424
- const fromAlert = normalizeNwwsAlertId(p?.alert_id ?? p?.alertId ?? p?.id);
425
- if (fromAlert) return fromAlert;
426
- const rid = feature?.id;
427
- if (rid == null || nwwsFeatureIdLooksLikeMapboxAllocation(rid)) return null;
428
- return normalizeNwwsAlertId(rid);
429
- }
430
-
431
- export function getNwsAlertInstanceIdFromFeature(feature) {
432
- return (
433
- getNwwsApiAlertUniqueId(feature) ??
434
- normalizeNwwsAlertId(feature?.properties?.unique_id ?? feature?.properties?.uniqueId) ??
435
- getNwsBaseAlertIdFromFeature(feature)
436
- );
437
- }
438
-
439
- export function isUsableNwwsAlertGeometry(geometry) {
440
- if (geometry == null || typeof geometry !== 'object') return false;
441
- const g = geometry;
442
- const t = g.type;
443
- if (!t || t === 'GeometryCollection') {
444
- return Array.isArray(g.geometries) && g.geometries.length > 0;
445
- }
446
- const c = g.coordinates;
447
- if (c == null) return false;
448
- if (t === 'Point') return Array.isArray(c) && c.length >= 2;
449
- if (t === 'LineString') return Array.isArray(c) && c.length >= 2;
450
- if (t === 'MultiPoint' || t === 'MultiLineString' || t === 'Polygon') {
451
- return Array.isArray(c) && c.length > 0;
452
- }
453
- if (t === 'MultiPolygon') {
454
- return Array.isArray(c) && c.length > 0;
455
- }
456
- return true;
457
- }
458
-
459
- export function applyNwwsDeltaToCollection(data, delta) {
460
- let features = [...data.features];
461
- const cancelSet = new Set(delta.cancelIds);
462
- const expireSet = new Set(delta.expireIds);
463
- const removeIds = new Set([...expireSet, ...cancelSet]);
464
-
465
- for (const feat of delta.upserts) {
466
- if (!feat || feat.type !== 'Feature') continue;
467
- const norm = normalizeNwwsHttpAlertFeature(
468
- {
469
- type: 'Feature',
470
- id: feat.id,
471
- geometry: feat.geometry,
472
- properties: { ...(feat.properties ?? {}) },
473
- },
474
- true
475
- );
476
- for (const refId of collectNwwsReplacementReferenceIds(norm.properties)) {
477
- removeIds.add(refId);
478
- }
479
- }
480
-
481
- const replacedBaseIds = new Set();
482
- // Secondary: VTEC-derived stable IDs from upserts. Used as a fallback when base_alert_id
483
- // differs between the stored feature and the update (e.g. when the content hash changed but
484
- // the VTEC event number did not). This handles sessions where old hash-keyed features already
485
- // exist in the working collection when an update with a VTEC-keyed ID arrives.
486
- const replacedVtecIds = new Set();
487
- const vtecStaleSweep = new Map();
488
- for (const feat of delta.upserts) {
489
- if (!feat || feat.type !== 'Feature') continue;
490
- const norm = normalizeNwwsHttpAlertFeature(
491
- {
492
- type: 'Feature',
493
- id: feat.id,
494
- geometry: feat.geometry,
495
- properties: { ...(feat.properties ?? {}) },
496
- },
497
- true
498
- );
499
- const trackerPre = getNwwsTrackerStatusFromProperties(norm.properties);
500
- if (!isNwwsTrackerStatusTerminal(trackerPre)) {
501
- const uid = getNwwsApiAlertUniqueId(norm);
502
- const vtecBase = uid ? getNwwsVtecSequenceBaseFromInstanceId(uid) : null;
503
- if (uid && vtecBase) vtecStaleSweep.set(vtecBase, uid);
504
- }
505
- const baseId = getNwsBaseAlertIdFromFeature(norm);
506
- if (baseId) replacedBaseIds.add(baseId);
507
- const pr = norm.properties;
508
- const vtecIdCap = extractNwsVtecStableId(pr);
509
- if (vtecIdCap) replacedVtecIds.add(vtecIdCap);
510
- else {
511
- const uidForVtec = getNwwsApiAlertUniqueId(norm);
512
- const vbOnly = uidForVtec ? getNwwsVtecSequenceBaseFromInstanceId(uidForVtec) : null;
513
- if (vbOnly) replacedVtecIds.add(vbOnly);
514
- }
515
- }
516
-
517
- const clearedGeometryByInstance = new Map();
518
- const clearedMapboxIdByInstance = new Map();
519
-
520
- if (replacedBaseIds.size > 0 || replacedVtecIds.size > 0 || vtecStaleSweep.size > 0) {
521
- features = features.filter((f) => {
522
- const baseId = getNwsBaseAlertIdFromFeature(f);
523
- const matchesPrimary = baseId != null && replacedBaseIds.has(baseId);
524
- const fp = f?.properties;
525
- const vtecOnStored =
526
- extractNwsVtecStableId(fp) ?? (typeof fp?._nws_vtec_seq_base === 'string' ? fp._nws_vtec_seq_base : null);
527
- const matchesVtec =
528
- !matchesPrimary &&
529
- replacedVtecIds.size > 0 &&
530
- vtecOnStored != null &&
531
- replacedVtecIds.has(vtecOnStored);
532
- const fid = getNwwsApiAlertUniqueId(f);
533
- const seqB = getNwwsVtecSeqBaseFromFeature(f);
534
- let matchesStaleInstance = false;
535
- if (vtecStaleSweep.size > 0) {
536
- for (const [vtecBase, newUid] of vtecStaleSweep) {
537
- if (fid) {
538
- const sameSeries = fid === vtecBase || fid.startsWith(`${vtecBase}.`);
539
- if (sameSeries && fid !== newUid) {
540
- matchesStaleInstance = true;
541
- break;
542
- }
543
- }
544
- if (!matchesStaleInstance && seqB != null && seqB === vtecBase && (fid == null || fid !== newUid)) {
545
- matchesStaleInstance = true;
546
- break;
547
- }
548
- }
549
- }
550
- if (!matchesPrimary && !matchesVtec && !matchesStaleInstance) return true;
551
- const iid = getNwsAlertInstanceIdFromFeature(f) ?? baseId;
552
- if (f.geometry) clearedGeometryByInstance.set(iid, f.geometry);
553
- if (f.id != null) clearedMapboxIdByInstance.set(iid, f.id);
554
- return false;
555
- });
556
- }
557
-
558
- for (const feat of delta.upserts) {
559
- if (!feat || feat.type !== 'Feature') continue;
560
- const normalizedFeat = normalizeNwwsHttpAlertFeature(
561
- {
562
- type: 'Feature',
563
- id: feat.id,
564
- geometry: feat.geometry,
565
- properties: { ...(feat.properties ?? {}) },
566
- },
567
- true
568
- );
569
- const enriched = enrichWarningsWithColors({ type: 'FeatureCollection', features: [normalizedFeat] }).features[0];
570
- const trackerStatus = getNwwsTrackerStatusFromProperties(enriched?.properties);
571
- if (isNwwsTrackerStatusTerminal(trackerStatus)) {
572
- const terminalId = getNwsBaseAlertIdFromFeature(enriched);
573
- if (terminalId) removeIds.add(terminalId);
574
- continue;
575
- }
576
- const baseId = getNwsBaseAlertIdFromFeature(enriched);
577
- const instanceId = getNwsAlertInstanceIdFromFeature(enriched) ?? baseId;
578
-
579
- const clearedGeometry = instanceId ? clearedGeometryByInstance.get(instanceId) : undefined;
580
- const clearedMapboxId = instanceId ? clearedMapboxIdByInstance.get(instanceId) : undefined;
581
-
582
- features.push({
583
- ...enriched,
584
- id: clearedMapboxId ?? enriched.id,
585
- geometry: isUsableNwwsAlertGeometry(enriched.geometry)
586
- ? enriched.geometry
587
- : clearedGeometry ?? enriched.geometry,
588
- });
589
- }
590
-
591
- if (removeIds.size) {
592
- features = features.filter((f) => {
593
- const baseId = getNwsBaseAlertIdFromFeature(f);
594
- if (baseId && removeIds.has(baseId)) return false;
595
- const inst = getNwsAlertInstanceIdFromFeature(f);
596
- if (inst && removeIds.has(inst)) return false;
597
- return true;
598
- });
599
- }
600
-
601
- features = ensureDistinctMapboxIdsOnFeatures(features);
602
- return { type: 'FeatureCollection', features };
603
- }
604
-
605
- function pushIds(target, val) {
606
- if (!Array.isArray(val)) return;
607
- for (const x of val) {
608
- if (x == null) continue;
609
- if (typeof x === 'object') {
610
- const o = x;
611
- const id =
612
- o.base_alert_id ??
613
- o.base_alertid ??
614
- o.baseAlertId ??
615
- o.base_alertId ??
616
- o.alert_id ??
617
- o.alertId ??
618
- o.id;
619
- const normalizedId = normalizeNwwsAlertId(id);
620
- if (normalizedId) target.push(normalizedId);
621
- } else if (x !== '') {
622
- const normalizedId = normalizeNwwsAlertId(x);
623
- if (normalizedId) target.push(normalizedId);
624
- }
625
- }
626
- }
627
-
628
- export function parseNwwsWsDelta(raw) {
629
- const upserts = [];
630
- const cancelIds = [];
631
- const expireIds = [];
632
-
633
- const pushSupersededIdsFromFeature = (feature) => {
634
- const status = getNwwsTrackerStatusFromProperties(feature?.properties);
635
- if (status === 'cancelled' || status === 'expired') return;
636
-
637
- const p = feature?.properties ?? {};
638
- const self = new Set();
639
- const b = getNwsBaseAlertIdFromFeature(feature);
640
- const inst = getNwsAlertInstanceIdFromFeature(feature);
641
- if (b) self.add(b);
642
- if (inst) self.add(inst);
643
-
644
- for (const id of collectNwwsReplacementReferenceIds(p)) {
645
- if (!self.has(id)) cancelIds.push(id);
646
- }
647
- };
648
-
649
- const collectUpsertFeatures = (val) => {
650
- if (!val) return;
651
- const collectMaybeFeatureObject = (obj) => {
652
- if (!obj || typeof obj !== 'object') return;
653
- if (obj.type !== 'Feature' && obj.geometry != null && obj.properties != null && typeof obj.properties === 'object') {
654
- obj = { type: 'Feature', id: obj.id, geometry: obj.geometry, properties: obj.properties };
655
- }
656
- if (obj.type === 'Feature') {
657
- const status = getNwwsTrackerStatusFromProperties(obj?.properties);
658
- const baseId = getNwsBaseAlertIdFromFeature(obj);
659
- if (status === 'cancelled' && baseId) {
660
- cancelIds.push(baseId);
661
- return;
662
- }
663
- if (status === 'expired' && baseId) {
664
- expireIds.push(baseId);
665
- return;
666
- }
667
- pushSupersededIdsFromFeature(obj);
668
- upserts.push(obj);
669
- return;
670
- }
671
- if (obj.new != null) collectUpsertFeatures(obj.new);
672
- if (obj.feature != null) collectUpsertFeatures(obj.feature);
673
- if (obj.alert != null) collectUpsertFeatures(obj.alert);
674
- if (obj.current != null) collectUpsertFeatures(obj.current);
675
- };
676
- if (Array.isArray(val)) {
677
- for (const item of val) {
678
- collectMaybeFeatureObject(item);
679
- }
680
- return;
681
- }
682
- const candidate = val;
683
- if (candidate?.type === 'Feature') {
684
- collectMaybeFeatureObject(candidate);
685
- return;
686
- }
687
- if (candidate?.type === 'FeatureCollection' && Array.isArray(candidate.features)) {
688
- for (const item of candidate.features) {
689
- collectMaybeFeatureObject(item);
690
- }
691
- return;
692
- }
693
- if (candidate?.data != null) collectUpsertFeatures(candidate.data);
694
- if (candidate?.payload != null) collectUpsertFeatures(candidate.payload);
695
- if (candidate?.feature != null) collectUpsertFeatures(candidate.feature);
696
- if (candidate?.features != null) collectUpsertFeatures(candidate.features);
697
- if (candidate?.items != null) collectUpsertFeatures(candidate.items);
698
- if (candidate?.records != null) collectUpsertFeatures(candidate.records);
699
- if (candidate?.alerts != null) collectUpsertFeatures(candidate.alerts);
700
- if (candidate?.upsert != null) collectUpsertFeatures(candidate.upsert);
701
- if (candidate?.upserts != null) collectUpsertFeatures(candidate.upserts);
702
- if (candidate?.insert != null) collectUpsertFeatures(candidate.insert);
703
- if (candidate?.inserts != null) collectUpsertFeatures(candidate.inserts);
704
- if (candidate?.created != null) collectUpsertFeatures(candidate.created);
705
- if (candidate?.updated != null) collectUpsertFeatures(candidate.updated);
706
- if (candidate?.delta != null) {
707
- const deltaInner = candidate.delta;
708
- collectUpsertFeatures(deltaInner?.upsert ?? deltaInner?.upserts ?? deltaInner?.features ?? deltaInner?.data);
709
- }
710
- if (candidate?.patch != null) collectUpsertFeatures(candidate.patch);
711
- };
712
-
713
- if (raw == null || typeof raw !== 'object') return { upserts, cancelIds, expireIds };
714
- if (Array.isArray(raw)) {
715
- collectUpsertFeatures(raw);
716
- return { upserts, cancelIds, expireIds };
717
- }
718
- const msg = raw;
719
- const collectBuckets = (container) => {
720
- const action = container.type ?? container.action;
721
-
722
- if (container.upsert != null) collectUpsertFeatures(container.upsert);
723
- if (container.upserts != null) collectUpsertFeatures(container.upserts);
724
- if (container.alert != null) collectUpsertFeatures(container.alert);
725
- if (container.alerts != null) collectUpsertFeatures(container.alerts);
726
- if (container.new != null) collectUpsertFeatures(container.new);
727
- if (container.extended != null) collectUpsertFeatures(container.extended);
728
- if (container.upgraded != null) collectUpsertFeatures(container.upgraded);
729
- if (action === 'upsert' || action === 'Upsert') {
730
- collectUpsertFeatures(container.features ?? container.items ?? container.data);
731
- }
732
- if (container.features != null && container.type !== 'FeatureCollection') {
733
- collectUpsertFeatures(container.features);
734
- }
735
-
736
- if (container.cancel != null) pushIds(cancelIds, container.cancel);
737
- if (container.cancels != null) pushIds(cancelIds, container.cancels);
738
- if (container.cancelled != null) pushIds(cancelIds, container.cancelled);
739
- if (container.canceled != null) pushIds(cancelIds, container.canceled);
740
- if (action === 'cancel' || action === 'Cancel') {
741
- pushIds(cancelIds, container.ids ?? container.base_alert_ids ?? container.items);
742
- }
743
-
744
- if (container.expire != null) pushIds(expireIds, container.expire);
745
- if (container.expires != null) pushIds(expireIds, container.expires);
746
- if (container.expired != null) pushIds(expireIds, container.expired);
747
- if (action === 'expire' || action === 'Expire') {
748
- pushIds(expireIds, container.ids ?? container.base_alert_ids ?? container.items);
749
- }
750
- };
751
-
752
- collectBuckets(msg);
753
- if (msg.diff != null && typeof msg.diff === 'object') {
754
- collectBuckets(msg.diff);
755
- }
756
-
757
- return { upserts, cancelIds, expireIds };
758
- }
759
-
760
- export function filterNwwsTerminalStatusFeatures(fc) {
761
- return {
762
- type: 'FeatureCollection',
763
- features: (fc.features ?? []).filter((feature) => {
764
- const status = getNwwsTrackerStatusFromProperties(feature?.properties);
765
- return !isNwwsTrackerStatusTerminal(status);
766
- }),
767
- };
768
- }
769
-
770
- /**
771
- * Legacy filter: tornado + severe-thunderstorm family only (same as pre–all-alerts SDK).
772
- * @param {import('geojson').FeatureCollection} fc
773
- */
774
- export function filterToConvectiveSdkEvents(fc) {
775
- const out = [];
776
- for (const f of fc.features ?? []) {
777
- const ev = getNwsAlertEventLabelFromProperties(f?.properties ?? {});
778
- if (ev && SDK_ALLOWED_EVENT_SET.has(ev)) {
779
- out.push(f);
780
- }
781
- }
782
- return { type: 'FeatureCollection', features: out };
783
- }
784
-
785
- /**
786
- * Whether a resolved NWS `event_name` should be shown when the user chose {@link filterNwsAlertsByIncludedList}.
787
- * Matches exact names; also matches when the user lists a parent product (e.g. "Tornado Warning") and the feature
788
- * is a known subtype (e.g. "Tornado Warning (PDS)") via {@link NWS_WARNINGS_SUBTYPE_PARENT}.
789
- * @param {string[] | null | undefined} includedAlerts
790
- * @param {string} eventLabel
791
- */
792
- export function isNwsEventIncludedInAllowlist(includedAlerts, eventLabel) {
793
- if (!includedAlerts?.length || !eventLabel) {
794
- return false;
795
- }
796
- const set = new Set(
797
- includedAlerts.map((x) => (x != null ? String(x).trim() : '')).filter(Boolean)
798
- );
799
- if (set.size === 0) {
800
- return false;
801
- }
802
- const ev = String(eventLabel).trim();
803
- if (set.has(ev)) {
804
- return true;
805
- }
806
- const parent = NWS_WARNINGS_SUBTYPE_PARENT[ev];
807
- if (parent && set.has(parent)) {
808
- return true;
809
- }
810
- if (ev === 'Tornado Warning (Observed)' && set.has('Tornado Warning (Reported)')) {
811
- return true;
812
- }
813
- return false;
814
- }
815
-
816
- /**
817
- * @param {import('geojson').FeatureCollection} fc
818
- * @param {string[]} includedAlerts
819
- */
820
- export function filterNwsAlertsByIncludedList(fc, includedAlerts) {
821
- const out = [];
822
- for (const f of fc.features ?? []) {
823
- const ev = getNwsAlertEventLabelFromProperties(f?.properties ?? {});
824
- if (isNwsEventIncludedInAllowlist(includedAlerts, ev)) {
825
- out.push(f);
826
- }
827
- }
828
- return { type: 'FeatureCollection', features: out };
829
- }
830
-
831
- /**
832
- * @param {import('geojson').FeatureCollection} fc
833
- * @param {'all' | 'user'} [scope]
834
- * @param {string[] | undefined} [includedAlerts] - Used when `scope === 'user'`: exact NWS `event_name` strings to show.
835
- */
836
- export function filterNwsAlertsByScope(fc, scope = 'all', includedAlerts) {
837
- if (scope === 'user') {
838
- return filterNwsAlertsByIncludedList(fc, includedAlerts ?? []);
839
- }
840
- const features = (fc.features ?? []).filter((f) => {
841
- const ev = getNwsAlertEventLabelFromProperties(f?.properties ?? {});
842
- return !!(ev && String(ev).trim());
843
- });
844
- return { type: 'FeatureCollection', features };
845
- }
846
-
847
- /** @deprecated Use {@link filterToConvectiveSdkEvents} or {@link filterNwsAlertsByScope} with `scope: 'user'` and an explicit `includedAlerts` list. */
848
- export function filterToSdkAllowedEvents(fc) {
849
- return filterToConvectiveSdkEvents(fc);
850
- }
851
-
852
- function extractRawNwwsVtecString(properties) {
853
- if (!properties) return null;
854
- if (properties.vtec != null) return String(properties.vtec);
855
- if (properties.VTEC != null) return String(properties.VTEC);
856
- const details = properties.details;
857
- if (details?.pvtec != null) return String(details.pvtec);
858
- const params = properties.parameters;
859
- if (params != null && typeof params === 'object' && !Array.isArray(params)) {
860
- const direct = params.VTEC ?? params.vtec;
861
- if (direct != null) {
862
- return Array.isArray(direct) ? String(direct[0] ?? '') : String(direct);
863
- }
864
- for (const k of Object.keys(params)) {
865
- if (String(k).toUpperCase() === 'VTEC') {
866
- const x = params[k];
867
- if (x != null) return Array.isArray(x) ? String(x[0] ?? '') : String(x);
868
- }
869
- }
870
- }
871
- return null;
872
- }
873
-
874
- function normalizeNwsChainTimeComponent(v) {
875
- if (v == null || v === '') return null;
876
- if (typeof v === 'number' && Number.isFinite(v)) {
877
- return new Date(v > 1e12 ? v : v * 1000).toISOString();
878
- }
879
- const s = String(v).trim();
880
- const ms = Date.parse(s);
881
- if (!Number.isNaN(ms)) return new Date(ms).toISOString();
882
- return s;
883
- }
884
-
885
- function getNwsOfficeEventIssuedChainId(properties) {
886
- if (!properties) return null;
887
- const office = String(properties.office ?? properties.sender_icao ?? properties.sender ?? '').trim();
888
- const ev = String(properties.event ?? properties.event_name ?? '').trim();
889
- const rawIssued = getNwsTimeProp(properties, ['issued', 'issued_at']);
890
- if (!office || !ev || rawIssued == null || rawIssued === '') return null;
891
- const issuedNorm = normalizeNwsChainTimeComponent(rawIssued);
892
- if (!issuedNorm) return null;
893
- return `${office}|${ev}|${issuedNorm}`;
894
- }
895
-
896
- /**
897
- * Extracts a stable VTEC-derived alert ID (e.g. "KWNS.SV.A.0108") from raw feature properties.
898
- * The VTEC event number is stable across CON/EXT/CAN actions for the same watch/warning,
899
- * unlike content-based hashes which change on every update.
900
- */
901
- function extractNwsVtecStableId(properties) {
902
- const vtec = extractRawNwwsVtecString(properties);
903
- if (vtec == null || vtec === '') return null;
904
- const parts = String(vtec).split('.');
905
- if (parts.length < 6) return null;
906
- const office = parts[2]?.trim();
907
- const ph = parts[3]?.trim();
908
- const sig = parts[4]?.trim();
909
- const seq = parts[5]?.trim();
910
- if (!office || !ph || !sig || !seq) return null;
911
- return `${office}.${ph}.${sig}.${seq}`;
912
- }
913
-
914
- function getNwsHttpAlertStableId(properties) {
915
- if (!properties) return null;
916
- const details = properties.details;
917
- if (details && typeof details === 'object') {
918
- const tr = details.tracking;
919
- if (tr != null && String(tr).trim() !== '') return String(tr).trim();
920
- }
921
- // Same DB key as backend use if trim drops details/hash (see API trim payloads).
922
- for (const k of ['nws_stable_id', 'stable_id', 'alert_chain_id']) {
923
- const v = properties[k];
924
- if (v != null && String(v).trim() !== '') return String(v).trim();
925
- }
926
- if (properties.tracking != null && typeof properties.tracking === 'string' && String(properties.tracking).trim() !== '') {
927
- return String(properties.tracking).trim();
928
- }
929
- // Prefer VTEC-derived ID over hash: VTEC event number is stable across all CON/EXT/UPG
930
- // actions while the content hash changes on every update, causing phantom duplicates on the map.
931
- const vtecId = extractNwsVtecStableId(properties);
932
- if (vtecId) return vtecId;
933
- const chainId = getNwsOfficeEventIssuedChainId(properties);
934
- if (chainId) return chainId;
935
- const h = properties.hash;
936
- if (h != null && String(h).trim() !== '') return String(h).trim();
937
- return null;
938
- }
939
-
940
- function mapHttpActionToTrackerStatus(p, forLiveStream) {
941
- if (!forLiveStream) return undefined;
942
- if (p.is_cancelled === true) return 'cancelled';
943
- const at = String(p.action_type ?? '').trim().toLowerCase();
944
- if (at === 'cancelled' || at === 'canceled') return 'cancelled';
945
- if (at === 'expired') return 'expired';
946
- if (at === 'upgraded') return 'upgraded';
947
- if (at === 'extended' || at === 'extension') return 'extended';
948
- if (at === 'updated') return 'extended';
949
- if (at === 'continuation' || at === 'continued') return 'continuation';
950
- if (at === 'issued' || at === 'new') return 'issued';
951
- if (at === '') return 'issued';
952
- return 'issued';
953
- }
954
-
955
- export function normalizeNwwsHttpAlertFeature(raw, forLiveStream) {
956
- if (!raw || typeof raw !== 'object' || raw.type !== 'Feature') return raw;
957
- const p = { ...(raw.properties ?? {}) };
958
- const explicitUid =
959
- normalizeNwwsAlertId(raw.id) ?? normalizeNwwsAlertId(p.id) ?? normalizeNwwsAlertId(p.alert_id ?? p.alertId);
960
- if (explicitUid && isNwwsVtecTimestampInstanceId(explicitUid)) {
961
- p.base_alert_id = explicitUid;
962
- p.alert_id = explicitUid;
963
- const vb = getNwwsVtecSequenceBaseFromInstanceId(explicitUid);
964
- if (vb) p._nws_vtec_seq_base = vb;
965
- } else {
966
- const stableId = getNwsHttpAlertStableId(p);
967
- if (stableId) {
968
- p.base_alert_id = stableId;
969
- p.alert_id = stableId;
970
- } else {
971
- const fallback =
972
- normalizeNwwsAlertId(raw.id) ?? normalizeNwwsAlertId(p.id) ?? normalizeNwwsAlertId(p.alert_id ?? p.alertId);
973
- if (fallback) {
974
- p.base_alert_id = fallback;
975
- p.alert_id = fallback;
976
- } else {
977
- const chain = getNwsOfficeEventIssuedChainId(p);
978
- if (chain) {
979
- p.base_alert_id = chain;
980
- p.alert_id = chain;
981
- }
982
- }
983
- }
984
- }
985
- const tracker = mapHttpActionToTrackerStatus(p, forLiveStream);
986
- if (tracker !== undefined) {
987
- p._trackerStatus = tracker;
988
- }
989
- if (p._nws_vtec_seq_base == null) {
990
- const capV = extractNwsVtecStableId(p);
991
- if (capV) p._nws_vtec_seq_base = capV;
992
- }
993
- return { ...raw, properties: p };
994
- }
995
-
996
- export function unwrapNwwsFeatureCollectionRoot(parsed) {
997
- if (Array.isArray(parsed)) {
998
- return {
999
- type: 'FeatureCollection',
1000
- features: parsed.map((f) => normalizeNwwsHttpAlertFeature(f, false)),
1001
- };
1002
- }
1003
- if (!parsed || typeof parsed !== 'object') return null;
1004
- const o = parsed;
1005
- if (o.type === 'FeatureCollection' && Array.isArray(o.features)) {
1006
- return {
1007
- type: 'FeatureCollection',
1008
- features: o.features.map((f) => normalizeNwwsHttpAlertFeature(f, false)),
1009
- };
1010
- }
1011
- for (const key of ['geojson', 'data', 'payload', 'body', 'collection', 'result', 'message', 'record']) {
1012
- const inner = o[key];
1013
- if (
1014
- inner &&
1015
- typeof inner === 'object' &&
1016
- inner.type === 'FeatureCollection' &&
1017
- Array.isArray(inner.features)
1018
- ) {
1019
- return {
1020
- type: 'FeatureCollection',
1021
- features: inner.features.map((f) => normalizeNwwsHttpAlertFeature(f, false)),
1022
- };
1023
- }
1024
- }
1025
- return null;
1026
- }
1027
-
1028
- export function cloneNwwsFeatureCollectionStrippingVolatile(data) {
1029
- return {
1030
- type: 'FeatureCollection',
1031
- features: (data.features || []).flatMap((f) => {
1032
- const p = f?.properties;
1033
- if (p) {
1034
- const ex = p.nwws_expired;
1035
- if (ex === true || ex === 1 || String(ex).toLowerCase() === 'true') {
1036
- return [];
1037
- }
1038
- }
1039
- const copy = {
1040
- ...f,
1041
- properties: p ? { ...p } : {},
1042
- };
1043
- delete copy._lastActiveState;
1044
- if (copy.properties && 'nwws_expired' in copy.properties) {
1045
- delete copy.properties.nwws_expired;
1046
- }
1047
- if (copy.properties) {
1048
- delete copy.properties._nws_flash_until_unix;
1049
- delete copy.properties._nws_webhook_debug_flash;
1050
- }
1051
- return [copy];
1052
- }),
1053
- };
1054
- }
1055
-
1056
- export function buildAlertClickPayload(feature) {
1057
- const properties = feature?.properties ? { ...feature.properties } : {};
1058
- const eventName = getNwsAlertEventLabelFromProperties(properties);
1059
- let tags = properties.tags;
1060
- if (typeof tags === 'string') {
1061
- try {
1062
- tags = JSON.parse(tags);
1063
- } catch {
1064
- tags = null;
1065
- }
1066
- }
1067
- if (!Array.isArray(tags)) {
1068
- tags = tags != null ? [String(tags)] : [];
1069
- }
1070
- const headline = properties.headline != null ? String(properties.headline) : '';
1071
- const name =
1072
- headline ||
1073
- eventName ||
1074
- (properties.event != null ? String(properties.event) : '') ||
1075
- (properties.event_name != null ? String(properties.event_name) : '');
1076
- const description =
1077
- properties.description != null
1078
- ? String(properties.description)
1079
- : properties.raw_text != null
1080
- ? String(properties.raw_text)
1081
- : '';
1082
- const summary = properties.summary != null ? String(properties.summary) : '';
1083
- const instruction = properties.instruction != null ? String(properties.instruction) : '';
1084
-
1085
- const startUnix =
1086
- typeof properties.start_unix === 'number'
1087
- ? properties.start_unix
1088
- : getNwsStartUnixForMap(properties);
1089
- const endUnix =
1090
- typeof properties.end_unix === 'number'
1091
- ? properties.end_unix
1092
- : parseNwsTimeToUnix(getNwsTimeProp(properties, [...NWS_ALERT_END_TIME_PROP_KEYS]));
1093
- const activeStartUnix =
1094
- typeof properties.active_start_unix === 'number'
1095
- ? properties.active_start_unix
1096
- : getNwsActiveStartUnix(properties);
1097
-
1098
- return {
1099
- name,
1100
- eventName,
1101
- headline,
1102
- summary,
1103
- description,
1104
- instruction,
1105
- tags,
1106
- startUnix,
1107
- endUnix,
1108
- activeStartUnix,
1109
- properties,
1110
- };
1111
- }
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
+ const NWS_START_UNIX_KEYS = ['issued_at', 'issued', 'sent', 'onset', 'effective'];
159
+ const NWS_ACTIVE_START_UNIX_KEYS = ['onset', 'effective', 'issued_at', 'issued', 'sent'];
160
+
161
+ function getNwsStartUnixForMap(properties) {
162
+ const startStr = getNwsTimeProp(properties, NWS_START_UNIX_KEYS);
163
+ return parseNwsTimeToUnix(startStr) ?? 0;
164
+ }
165
+
166
+ function getNwsActiveStartUnix(properties) {
167
+ const startStr = getNwsTimeProp(properties, NWS_ACTIVE_START_UNIX_KEYS);
168
+ return parseNwsTimeToUnix(startStr) ?? 0;
169
+ }
170
+
171
+ export function getEarliestNwsReferenceSentRaw(properties) {
172
+ if (!properties) return null;
173
+ let refs = properties.references;
174
+ if (typeof refs === 'string') {
175
+ try {
176
+ refs = JSON.parse(refs);
177
+ } catch {
178
+ return null;
179
+ }
180
+ }
181
+ if (!Array.isArray(refs) || refs.length === 0) return null;
182
+ let best = null;
183
+ for (const r of refs) {
184
+ if (!r || typeof r !== 'object') continue;
185
+ const raw = r.sent ?? r.Sent;
186
+ if (raw == null || raw === '') continue;
187
+ const s = String(raw);
188
+ const t = new Date(s).getTime();
189
+ if (Number.isNaN(t)) continue;
190
+ if (!best || t < best.t) best = { t, raw: s };
191
+ }
192
+ return best?.raw ?? null;
193
+ }
194
+
195
+ let nextWarningFeatureId = 1;
196
+
197
+ function allocateNwsMapboxFeatureId(rawId) {
198
+ if (typeof rawId === 'number' && Number.isFinite(rawId) && rawId >= 0 && rawId <= Number.MAX_SAFE_INTEGER) {
199
+ return Math.trunc(rawId);
200
+ }
201
+ return nextWarningFeatureId++;
202
+ }
203
+
204
+ function ensureDistinctMapboxIdsOnFeatures(features) {
205
+ const used = new Set();
206
+ return features.map((f) => {
207
+ let id =
208
+ typeof f?.id === 'number' && Number.isFinite(f.id) && f.id >= 0 && f.id <= Number.MAX_SAFE_INTEGER
209
+ ? Math.trunc(f.id)
210
+ : null;
211
+ if (id == null) {
212
+ while (used.has(nextWarningFeatureId)) {
213
+ nextWarningFeatureId++;
214
+ }
215
+ id = nextWarningFeatureId++;
216
+ used.add(id);
217
+ return { ...f, id };
218
+ }
219
+ if (!used.has(id)) {
220
+ used.add(id);
221
+ return f;
222
+ }
223
+ while (used.has(nextWarningFeatureId)) {
224
+ nextWarningFeatureId++;
225
+ }
226
+ const newId = nextWarningFeatureId++;
227
+ used.add(newId);
228
+ return { ...f, id: newId };
229
+ });
230
+ }
231
+
232
+ export function enrichWarningsWithColors(geojson) {
233
+ if (!geojson || typeof geojson !== 'object') {
234
+ return { type: 'FeatureCollection', features: [] };
235
+ }
236
+ const rawFeats = geojson.features;
237
+ if (!Array.isArray(rawFeats) || rawFeats.length === 0) {
238
+ return { type: 'FeatureCollection', features: [] };
239
+ }
240
+ const features = rawFeats.map((f) => {
241
+ const p = f.properties || {};
242
+ const earliestRef = getEarliestNwsReferenceSentRaw(p);
243
+ const hasExplicitIssued = getNwsTimeProp(p, ['issued_at', 'issued']) != null;
244
+ const nwsIssuedPatch =
245
+ earliestRef && !hasExplicitIssued ? { _nws_issued_at: earliestRef } : null;
246
+
247
+ const eventName = getNwsAlertEventLabelFromProperties(p);
248
+ const renderPriority = computeNwsAlertRenderPriority(p, eventName);
249
+
250
+ const startUnix = getNwsStartUnixForMap(p);
251
+ const activeStartUnix = getNwsActiveStartUnix(p);
252
+ const endStr = getNwsTimeProp(p, [...NWS_ALERT_END_TIME_PROP_KEYS]);
253
+ const endUnix = parseNwsTimeToUnix(endStr) ?? 2147483647;
254
+
255
+ const mapboxFeatureId = allocateNwsMapboxFeatureId(f.id);
256
+
257
+ const hasEventName = !!eventName;
258
+ const eventNameUnchanged = !hasEventName || p.event_name === eventName;
259
+ const priorityUnchanged = p[NWS_RENDER_PRIORITY_PROP] === renderPriority;
260
+ const startUnchanged = p.start_unix === startUnix;
261
+ const activeStartUnchanged = p.active_start_unix === activeStartUnix;
262
+ const endUnchanged = p.end_unix === endUnix;
263
+ const idUnchanged = f.id === mapboxFeatureId;
264
+ const nwsIssuedUnchanged =
265
+ nwsIssuedPatch == null || String(p._nws_issued_at ?? '') === String(nwsIssuedPatch._nws_issued_at);
266
+
267
+ if (
268
+ eventNameUnchanged &&
269
+ priorityUnchanged &&
270
+ startUnchanged &&
271
+ activeStartUnchanged &&
272
+ endUnchanged &&
273
+ idUnchanged &&
274
+ nwsIssuedUnchanged
275
+ ) {
276
+ return f;
277
+ }
278
+ return {
279
+ ...f,
280
+ id: mapboxFeatureId,
281
+ properties: {
282
+ ...p,
283
+ ...(nwsIssuedPatch ?? {}),
284
+ ...(hasEventName ? { event_name: eventName } : {}),
285
+ [NWS_RENDER_PRIORITY_PROP]: renderPriority,
286
+ start_unix: startUnix,
287
+ active_start_unix: activeStartUnix,
288
+ end_unix: endUnix,
289
+ },
290
+ };
291
+ });
292
+ const distinctIds = ensureDistinctMapboxIdsOnFeatures(features);
293
+ return { type: 'FeatureCollection', features: sortWarningsFeaturesForRender(distinctIds) };
294
+ }
295
+
296
+ export function normalizeNwwsAlertId(value) {
297
+ if (value == null || value === '') return null;
298
+ const s = String(value).trim();
299
+ if (!s) return null;
300
+ const marker = '/alerts/';
301
+ const idx = s.indexOf(marker);
302
+ if (idx >= 0) {
303
+ const tail = s.slice(idx + marker.length).trim();
304
+ return tail || s;
305
+ }
306
+ return s;
307
+ }
308
+
309
+ function isNwwsVtecTimestampInstanceId(id) {
310
+ const parts = id.split('.');
311
+ if (parts.length < 5) return false;
312
+ const last = parts[parts.length - 1] ?? '';
313
+ return /^\d{8,}$/.test(last);
314
+ }
315
+
316
+ function nwwsFeatureIdLooksLikeMapboxAllocation(raw) {
317
+ if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0 && raw <= Number.MAX_SAFE_INTEGER) {
318
+ return true;
319
+ }
320
+ if (typeof raw === 'string') {
321
+ const t = raw.trim();
322
+ if (t.includes('.')) return false;
323
+ if (/^\d+$/.test(t) && t.length <= 16) return true;
324
+ }
325
+ return false;
326
+ }
327
+
328
+ export function getNwwsApiAlertUniqueId(feature) {
329
+ if (!feature || typeof feature !== 'object') return null;
330
+ const p = feature.properties;
331
+ const fromProps =
332
+ normalizeNwwsAlertId(p?.base_alert_id) ??
333
+ normalizeNwwsAlertId(p?.base_alertid ?? p?.baseAlertId ?? p?.base_alertId) ??
334
+ normalizeNwwsAlertId(p?.alert_id ?? p?.alertId) ??
335
+ normalizeNwwsAlertId(p?.id);
336
+ if (fromProps) return fromProps;
337
+ const rid = feature.id;
338
+ if (rid == null || rid === '' || nwwsFeatureIdLooksLikeMapboxAllocation(rid)) return null;
339
+ return normalizeNwwsAlertId(rid);
340
+ }
341
+
342
+ function getNwwsVtecSequenceBaseFromInstanceId(fullId) {
343
+ const parts = String(fullId).trim().split('.');
344
+ if (parts.length < 5) return null;
345
+ return parts.slice(0, 4).join('.');
346
+ }
347
+
348
+ function getNwwsVtecSeqBaseFromFeature(feature) {
349
+ const p = feature?.properties;
350
+ if (!p) return null;
351
+ const pinned = p._nws_vtec_seq_base;
352
+ if (typeof pinned === 'string' && pinned.includes('.')) return pinned.trim();
353
+ const uid = getNwwsApiAlertUniqueId(feature);
354
+ if (uid) {
355
+ const fromUid = getNwwsVtecSequenceBaseFromInstanceId(uid);
356
+ if (fromUid) return fromUid;
357
+ }
358
+ return extractNwsVtecStableId(p);
359
+ }
360
+
361
+ export function getNwwsTrackerStatusFromProperties(properties) {
362
+ if (!properties) return null;
363
+ const raw = properties._trackerStatus ?? properties.trackerStatus ?? properties.tracker_status;
364
+ if (raw == null || raw === '') {
365
+ if (properties.is_cancelled === true) return 'cancelled';
366
+ return null;
367
+ }
368
+ const s = String(raw).trim().toLowerCase();
369
+ if (!s) return null;
370
+ if (s === 'canceled') return 'cancelled';
371
+ if (s === 'cancel') return 'cancelled';
372
+ if (s === 'expire') return 'expired';
373
+ if (s === 'upgrade') return 'upgraded';
374
+ if (s === 'extend' || s === 'extended' || s === 'extension' || s === 'expanded' || s === 'expansion') {
375
+ return 'extended';
376
+ }
377
+ if (s === 'continuation' || s === 'continued' || s === 'correction' || s === 'continue') {
378
+ return 'continuation';
379
+ }
380
+ return s;
381
+ }
382
+
383
+ export function isNwwsTrackerStatusTerminal(status) {
384
+ return status === 'cancelled' || status === 'expired';
385
+ }
386
+
387
+ /**
388
+ * CAP references / explicit replace fields for the previous bulletin(s) in the same event chain.
389
+ * Used so extensions/upgrades/continuations remove polygons keyed by superseded ids, not only by
390
+ * the new feature's `base_alert_id`.
391
+ */
392
+ function collectNwwsReplacementReferenceIds(properties) {
393
+ if (!properties || typeof properties !== 'object') return [];
394
+ const out = [];
395
+ const pushRaw = (v) => {
396
+ if (v == null) return;
397
+ if (Array.isArray(v)) {
398
+ for (const x of v) pushRaw(x);
399
+ return;
400
+ }
401
+ const n = normalizeNwwsAlertId(v);
402
+ if (n) out.push(n);
403
+ };
404
+ pushRaw(properties._upgradedFrom ?? properties.upgradedFrom);
405
+ pushRaw(properties._replaces ?? properties.replaces ?? properties.replaced_alert_id ?? properties.replacedAlertId);
406
+ let refs = properties.references;
407
+ if (typeof refs === 'string') {
408
+ try {
409
+ refs = JSON.parse(refs);
410
+ } catch {
411
+ refs = null;
412
+ }
413
+ }
414
+ if (Array.isArray(refs)) {
415
+ for (const r of refs) {
416
+ if (typeof r === 'string') pushRaw(r);
417
+ else if (r && typeof r === 'object') {
418
+ const ro = r;
419
+ pushRaw(ro.identifier ?? ro.id ?? ro.alert_id ?? ro.alertId ?? ro.base_alert_id);
420
+ }
421
+ }
422
+ }
423
+ return out;
424
+ }
425
+
426
+ export function getNwsBaseAlertIdFromFeature(feature) {
427
+ const p = feature?.properties;
428
+ const base = normalizeNwwsAlertId(p?.base_alert_id ?? p?.base_alertid ?? p?.baseAlertId ?? p?.base_alertId);
429
+ if (base) return base;
430
+ const fromAlert = normalizeNwwsAlertId(p?.alert_id ?? p?.alertId ?? p?.id);
431
+ if (fromAlert) return fromAlert;
432
+ const rid = feature?.id;
433
+ if (rid == null || nwwsFeatureIdLooksLikeMapboxAllocation(rid)) return null;
434
+ return normalizeNwwsAlertId(rid);
435
+ }
436
+
437
+ export function getNwsAlertInstanceIdFromFeature(feature) {
438
+ return (
439
+ getNwwsApiAlertUniqueId(feature) ??
440
+ normalizeNwwsAlertId(feature?.properties?.unique_id ?? feature?.properties?.uniqueId) ??
441
+ getNwsBaseAlertIdFromFeature(feature)
442
+ );
443
+ }
444
+
445
+ export function isUsableNwwsAlertGeometry(geometry) {
446
+ if (geometry == null || typeof geometry !== 'object') return false;
447
+ const g = geometry;
448
+ const t = g.type;
449
+ if (t === 'GeometryCollection') {
450
+ const geoms = g.geometries;
451
+ return (
452
+ Array.isArray(geoms) &&
453
+ geoms.length > 0 &&
454
+ geoms.some((sub) => isUsableNwwsAlertGeometry(sub))
455
+ );
456
+ }
457
+ if (!t) return false;
458
+ const c = g.coordinates;
459
+ if (c == null) return false;
460
+ if (t === 'Point') return Array.isArray(c) && c.length >= 2;
461
+ if (t === 'LineString') return Array.isArray(c) && c.length >= 2;
462
+ if (t === 'MultiPoint' || t === 'MultiLineString' || t === 'Polygon') {
463
+ return Array.isArray(c) && c.length > 0;
464
+ }
465
+ if (t === 'MultiPolygon') {
466
+ return Array.isArray(c) && c.length > 0;
467
+ }
468
+ return true;
469
+ }
470
+
471
+ /** Empty collection safe for Mapbox GL `geojson` sources (never pass `features: null`). */
472
+ export const EMPTY_FEATURE_COLLECTION = Object.freeze({
473
+ type: 'FeatureCollection',
474
+ features: [],
475
+ });
476
+
477
+ /**
478
+ * Coerces input into a FeatureCollection Mapbox can load without worker errors
479
+ * (null `features`, invalid nested GeometryCollections, or null coordinates).
480
+ */
481
+ export function normalizeFeatureCollectionForMapboxGl(input) {
482
+ if (!input || typeof input !== 'object' || input.type !== 'FeatureCollection') {
483
+ return { type: 'FeatureCollection', features: [] };
484
+ }
485
+ const raw = input.features;
486
+ if (!Array.isArray(raw)) {
487
+ return { type: 'FeatureCollection', features: [] };
488
+ }
489
+ const out = [];
490
+ for (const f of raw) {
491
+ if (!f || typeof f !== 'object' || f.type !== 'Feature') continue;
492
+ if (!isUsableNwwsAlertGeometry(f.geometry)) continue;
493
+ out.push(f);
494
+ }
495
+ return { type: 'FeatureCollection', features: out };
496
+ }
497
+
498
+ export function applyNwwsDeltaToCollection(data, delta) {
499
+ const base = normalizeFeatureCollectionForMapboxGl(data);
500
+ let features = [...base.features];
501
+ const cancelSet = new Set(delta.cancelIds);
502
+ const expireSet = new Set(delta.expireIds);
503
+ const removeIds = new Set([...expireSet, ...cancelSet]);
504
+
505
+ for (const feat of delta.upserts) {
506
+ if (!feat || feat.type !== 'Feature') continue;
507
+ const norm = normalizeNwwsHttpAlertFeature(
508
+ {
509
+ type: 'Feature',
510
+ id: feat.id,
511
+ geometry: feat.geometry,
512
+ properties: { ...(feat.properties ?? {}) },
513
+ },
514
+ true
515
+ );
516
+ for (const refId of collectNwwsReplacementReferenceIds(norm.properties)) {
517
+ removeIds.add(refId);
518
+ }
519
+ }
520
+
521
+ const replacedBaseIds = new Set();
522
+ // Secondary: VTEC-derived stable IDs from upserts. Used as a fallback when base_alert_id
523
+ // differs between the stored feature and the update (e.g. when the content hash changed but
524
+ // the VTEC event number did not). This handles sessions where old hash-keyed features already
525
+ // exist in the working collection when an update with a VTEC-keyed ID arrives.
526
+ const replacedVtecIds = new Set();
527
+ const vtecStaleSweep = new Map();
528
+ for (const feat of delta.upserts) {
529
+ if (!feat || feat.type !== 'Feature') continue;
530
+ const norm = normalizeNwwsHttpAlertFeature(
531
+ {
532
+ type: 'Feature',
533
+ id: feat.id,
534
+ geometry: feat.geometry,
535
+ properties: { ...(feat.properties ?? {}) },
536
+ },
537
+ true
538
+ );
539
+ const trackerPre = getNwwsTrackerStatusFromProperties(norm.properties);
540
+ if (!isNwwsTrackerStatusTerminal(trackerPre)) {
541
+ const uid = getNwwsApiAlertUniqueId(norm);
542
+ const vtecBase = uid ? getNwwsVtecSequenceBaseFromInstanceId(uid) : null;
543
+ if (uid && vtecBase) vtecStaleSweep.set(vtecBase, uid);
544
+ }
545
+ const baseId = getNwsBaseAlertIdFromFeature(norm);
546
+ if (baseId) replacedBaseIds.add(baseId);
547
+ const pr = norm.properties;
548
+ const vtecIdCap = extractNwsVtecStableId(pr);
549
+ if (vtecIdCap) replacedVtecIds.add(vtecIdCap);
550
+ else {
551
+ const uidForVtec = getNwwsApiAlertUniqueId(norm);
552
+ const vbOnly = uidForVtec ? getNwwsVtecSequenceBaseFromInstanceId(uidForVtec) : null;
553
+ if (vbOnly) replacedVtecIds.add(vbOnly);
554
+ }
555
+ }
556
+
557
+ const clearedGeometryByInstance = new Map();
558
+ const clearedMapboxIdByInstance = new Map();
559
+
560
+ if (replacedBaseIds.size > 0 || replacedVtecIds.size > 0 || vtecStaleSweep.size > 0) {
561
+ features = features.filter((f) => {
562
+ const baseId = getNwsBaseAlertIdFromFeature(f);
563
+ const matchesPrimary = baseId != null && replacedBaseIds.has(baseId);
564
+ const fp = f?.properties;
565
+ const vtecOnStored =
566
+ extractNwsVtecStableId(fp) ?? (typeof fp?._nws_vtec_seq_base === 'string' ? fp._nws_vtec_seq_base : null);
567
+ const matchesVtec =
568
+ !matchesPrimary &&
569
+ replacedVtecIds.size > 0 &&
570
+ vtecOnStored != null &&
571
+ replacedVtecIds.has(vtecOnStored);
572
+ const fid = getNwwsApiAlertUniqueId(f);
573
+ const seqB = getNwwsVtecSeqBaseFromFeature(f);
574
+ let matchesStaleInstance = false;
575
+ if (vtecStaleSweep.size > 0) {
576
+ for (const [vtecBase, newUid] of vtecStaleSweep) {
577
+ if (fid) {
578
+ const sameSeries = fid === vtecBase || fid.startsWith(`${vtecBase}.`);
579
+ if (sameSeries && fid !== newUid) {
580
+ matchesStaleInstance = true;
581
+ break;
582
+ }
583
+ }
584
+ if (!matchesStaleInstance && seqB != null && seqB === vtecBase && (fid == null || fid !== newUid)) {
585
+ matchesStaleInstance = true;
586
+ break;
587
+ }
588
+ }
589
+ }
590
+ if (!matchesPrimary && !matchesVtec && !matchesStaleInstance) return true;
591
+ const iid = getNwsAlertInstanceIdFromFeature(f) ?? baseId;
592
+ if (f.geometry) clearedGeometryByInstance.set(iid, f.geometry);
593
+ if (f.id != null) clearedMapboxIdByInstance.set(iid, f.id);
594
+ return false;
595
+ });
596
+ }
597
+
598
+ for (const feat of delta.upserts) {
599
+ if (!feat || feat.type !== 'Feature') continue;
600
+ const normalizedFeat = normalizeNwwsHttpAlertFeature(
601
+ {
602
+ type: 'Feature',
603
+ id: feat.id,
604
+ geometry: feat.geometry,
605
+ properties: { ...(feat.properties ?? {}) },
606
+ },
607
+ true
608
+ );
609
+ const enriched = enrichWarningsWithColors({ type: 'FeatureCollection', features: [normalizedFeat] }).features[0];
610
+ const trackerStatus = getNwwsTrackerStatusFromProperties(enriched?.properties);
611
+ if (isNwwsTrackerStatusTerminal(trackerStatus)) {
612
+ const terminalId = getNwsBaseAlertIdFromFeature(enriched);
613
+ if (terminalId) removeIds.add(terminalId);
614
+ continue;
615
+ }
616
+ const baseId = getNwsBaseAlertIdFromFeature(enriched);
617
+ const instanceId = getNwsAlertInstanceIdFromFeature(enriched) ?? baseId;
618
+
619
+ const clearedGeometry = instanceId ? clearedGeometryByInstance.get(instanceId) : undefined;
620
+ const clearedMapboxId = instanceId ? clearedMapboxIdByInstance.get(instanceId) : undefined;
621
+
622
+ features.push({
623
+ ...enriched,
624
+ id: clearedMapboxId ?? enriched.id,
625
+ geometry: isUsableNwwsAlertGeometry(enriched.geometry)
626
+ ? enriched.geometry
627
+ : clearedGeometry ?? enriched.geometry,
628
+ });
629
+ }
630
+
631
+ if (removeIds.size) {
632
+ features = features.filter((f) => {
633
+ const baseId = getNwsBaseAlertIdFromFeature(f);
634
+ if (baseId && removeIds.has(baseId)) return false;
635
+ const inst = getNwsAlertInstanceIdFromFeature(f);
636
+ if (inst && removeIds.has(inst)) return false;
637
+ return true;
638
+ });
639
+ }
640
+
641
+ features = ensureDistinctMapboxIdsOnFeatures(features);
642
+ return normalizeFeatureCollectionForMapboxGl({ type: 'FeatureCollection', features });
643
+ }
644
+
645
+ function pushIds(target, val) {
646
+ if (!Array.isArray(val)) return;
647
+ for (const x of val) {
648
+ if (x == null) continue;
649
+ if (typeof x === 'object') {
650
+ const o = x;
651
+ const id =
652
+ o.base_alert_id ??
653
+ o.base_alertid ??
654
+ o.baseAlertId ??
655
+ o.base_alertId ??
656
+ o.alert_id ??
657
+ o.alertId ??
658
+ o.id;
659
+ const normalizedId = normalizeNwwsAlertId(id);
660
+ if (normalizedId) target.push(normalizedId);
661
+ } else if (x !== '') {
662
+ const normalizedId = normalizeNwwsAlertId(x);
663
+ if (normalizedId) target.push(normalizedId);
664
+ }
665
+ }
666
+ }
667
+
668
+ export function parseNwwsWsDelta(raw) {
669
+ const upserts = [];
670
+ const cancelIds = [];
671
+ const expireIds = [];
672
+
673
+ const pushSupersededIdsFromFeature = (feature) => {
674
+ const status = getNwwsTrackerStatusFromProperties(feature?.properties);
675
+ if (status === 'cancelled' || status === 'expired') return;
676
+
677
+ const p = feature?.properties ?? {};
678
+ const self = new Set();
679
+ const b = getNwsBaseAlertIdFromFeature(feature);
680
+ const inst = getNwsAlertInstanceIdFromFeature(feature);
681
+ if (b) self.add(b);
682
+ if (inst) self.add(inst);
683
+
684
+ for (const id of collectNwwsReplacementReferenceIds(p)) {
685
+ if (!self.has(id)) cancelIds.push(id);
686
+ }
687
+ };
688
+
689
+ const collectUpsertFeatures = (val) => {
690
+ if (!val) return;
691
+ const collectMaybeFeatureObject = (obj) => {
692
+ if (!obj || typeof obj !== 'object') return;
693
+ if (obj.type !== 'Feature' && obj.geometry != null && obj.properties != null && typeof obj.properties === 'object') {
694
+ obj = { type: 'Feature', id: obj.id, geometry: obj.geometry, properties: obj.properties };
695
+ }
696
+ if (obj.type === 'Feature') {
697
+ const status = getNwwsTrackerStatusFromProperties(obj?.properties);
698
+ const baseId = getNwsBaseAlertIdFromFeature(obj);
699
+ if (status === 'cancelled' && baseId) {
700
+ cancelIds.push(baseId);
701
+ return;
702
+ }
703
+ if (status === 'expired' && baseId) {
704
+ expireIds.push(baseId);
705
+ return;
706
+ }
707
+ pushSupersededIdsFromFeature(obj);
708
+ upserts.push(obj);
709
+ return;
710
+ }
711
+ if (obj.new != null) collectUpsertFeatures(obj.new);
712
+ if (obj.feature != null) collectUpsertFeatures(obj.feature);
713
+ if (obj.alert != null) collectUpsertFeatures(obj.alert);
714
+ if (obj.current != null) collectUpsertFeatures(obj.current);
715
+ };
716
+ if (Array.isArray(val)) {
717
+ for (const item of val) {
718
+ collectMaybeFeatureObject(item);
719
+ }
720
+ return;
721
+ }
722
+ const candidate = val;
723
+ if (candidate?.type === 'Feature') {
724
+ collectMaybeFeatureObject(candidate);
725
+ return;
726
+ }
727
+ if (candidate?.type === 'FeatureCollection' && Array.isArray(candidate.features)) {
728
+ for (const item of candidate.features) {
729
+ collectMaybeFeatureObject(item);
730
+ }
731
+ return;
732
+ }
733
+ if (candidate?.data != null) collectUpsertFeatures(candidate.data);
734
+ if (candidate?.payload != null) collectUpsertFeatures(candidate.payload);
735
+ if (candidate?.feature != null) collectUpsertFeatures(candidate.feature);
736
+ if (candidate?.features != null) collectUpsertFeatures(candidate.features);
737
+ if (candidate?.items != null) collectUpsertFeatures(candidate.items);
738
+ if (candidate?.records != null) collectUpsertFeatures(candidate.records);
739
+ if (candidate?.alerts != null) collectUpsertFeatures(candidate.alerts);
740
+ if (candidate?.upsert != null) collectUpsertFeatures(candidate.upsert);
741
+ if (candidate?.upserts != null) collectUpsertFeatures(candidate.upserts);
742
+ if (candidate?.insert != null) collectUpsertFeatures(candidate.insert);
743
+ if (candidate?.inserts != null) collectUpsertFeatures(candidate.inserts);
744
+ if (candidate?.created != null) collectUpsertFeatures(candidate.created);
745
+ if (candidate?.updated != null) collectUpsertFeatures(candidate.updated);
746
+ if (candidate?.delta != null) {
747
+ const deltaInner = candidate.delta;
748
+ collectUpsertFeatures(deltaInner?.upsert ?? deltaInner?.upserts ?? deltaInner?.features ?? deltaInner?.data);
749
+ }
750
+ if (candidate?.patch != null) collectUpsertFeatures(candidate.patch);
751
+ };
752
+
753
+ if (raw == null || typeof raw !== 'object') return { upserts, cancelIds, expireIds };
754
+ if (Array.isArray(raw)) {
755
+ collectUpsertFeatures(raw);
756
+ return { upserts, cancelIds, expireIds };
757
+ }
758
+ const msg = raw;
759
+ const collectBuckets = (container) => {
760
+ const action = container.type ?? container.action;
761
+
762
+ if (container.upsert != null) collectUpsertFeatures(container.upsert);
763
+ if (container.upserts != null) collectUpsertFeatures(container.upserts);
764
+ if (container.alert != null) collectUpsertFeatures(container.alert);
765
+ if (container.alerts != null) collectUpsertFeatures(container.alerts);
766
+ if (container.new != null) collectUpsertFeatures(container.new);
767
+ if (container.extended != null) collectUpsertFeatures(container.extended);
768
+ if (container.upgraded != null) collectUpsertFeatures(container.upgraded);
769
+ if (action === 'upsert' || action === 'Upsert') {
770
+ collectUpsertFeatures(container.features ?? container.items ?? container.data);
771
+ }
772
+ if (container.features != null && container.type !== 'FeatureCollection') {
773
+ collectUpsertFeatures(container.features);
774
+ }
775
+
776
+ if (container.cancel != null) pushIds(cancelIds, container.cancel);
777
+ if (container.cancels != null) pushIds(cancelIds, container.cancels);
778
+ if (container.cancelled != null) pushIds(cancelIds, container.cancelled);
779
+ if (container.canceled != null) pushIds(cancelIds, container.canceled);
780
+ if (action === 'cancel' || action === 'Cancel') {
781
+ pushIds(cancelIds, container.ids ?? container.base_alert_ids ?? container.items);
782
+ }
783
+
784
+ if (container.expire != null) pushIds(expireIds, container.expire);
785
+ if (container.expires != null) pushIds(expireIds, container.expires);
786
+ if (container.expired != null) pushIds(expireIds, container.expired);
787
+ if (action === 'expire' || action === 'Expire') {
788
+ pushIds(expireIds, container.ids ?? container.base_alert_ids ?? container.items);
789
+ }
790
+ };
791
+
792
+ collectBuckets(msg);
793
+ if (msg.diff != null && typeof msg.diff === 'object') {
794
+ collectBuckets(msg.diff);
795
+ }
796
+
797
+ return { upserts, cancelIds, expireIds };
798
+ }
799
+
800
+ export function filterNwwsTerminalStatusFeatures(fc) {
801
+ const base = normalizeFeatureCollectionForMapboxGl(fc);
802
+ return {
803
+ type: 'FeatureCollection',
804
+ features: base.features.filter((feature) => {
805
+ const status = getNwwsTrackerStatusFromProperties(feature?.properties);
806
+ return !isNwwsTrackerStatusTerminal(status);
807
+ }),
808
+ };
809
+ }
810
+
811
+ /**
812
+ * Legacy filter: tornado + severe-thunderstorm family only (same as pre–all-alerts SDK).
813
+ * @param {import('geojson').FeatureCollection} fc
814
+ */
815
+ export function filterToConvectiveSdkEvents(fc) {
816
+ const base = normalizeFeatureCollectionForMapboxGl(fc);
817
+ const out = [];
818
+ for (const f of base.features) {
819
+ const ev = getNwsAlertEventLabelFromProperties(f?.properties ?? {});
820
+ if (ev && SDK_ALLOWED_EVENT_SET.has(ev)) {
821
+ out.push(f);
822
+ }
823
+ }
824
+ return { type: 'FeatureCollection', features: out };
825
+ }
826
+
827
+ /**
828
+ * Whether a resolved NWS `event_name` should be shown when the user chose {@link filterNwsAlertsByIncludedList}.
829
+ * Matches exact names; also matches when the user lists a parent product (e.g. "Tornado Warning") and the feature
830
+ * is a known subtype (e.g. "Tornado Warning (PDS)") via {@link NWS_WARNINGS_SUBTYPE_PARENT}.
831
+ * @param {string[] | null | undefined} includedAlerts
832
+ * @param {string} eventLabel
833
+ */
834
+ export function isNwsEventIncludedInAllowlist(includedAlerts, eventLabel) {
835
+ if (!includedAlerts?.length || !eventLabel) {
836
+ return false;
837
+ }
838
+ const set = new Set(
839
+ includedAlerts.map((x) => (x != null ? String(x).trim() : '')).filter(Boolean)
840
+ );
841
+ if (set.size === 0) {
842
+ return false;
843
+ }
844
+ const ev = String(eventLabel).trim();
845
+ if (set.has(ev)) {
846
+ return true;
847
+ }
848
+ const parent = NWS_WARNINGS_SUBTYPE_PARENT[ev];
849
+ if (parent && set.has(parent)) {
850
+ return true;
851
+ }
852
+ if (ev === 'Tornado Warning (Observed)' && set.has('Tornado Warning (Reported)')) {
853
+ return true;
854
+ }
855
+ return false;
856
+ }
857
+
858
+ /**
859
+ * @param {import('geojson').FeatureCollection} fc
860
+ * @param {string[]} includedAlerts
861
+ */
862
+ export function filterNwsAlertsByIncludedList(fc, includedAlerts) {
863
+ const base = normalizeFeatureCollectionForMapboxGl(fc);
864
+ const out = [];
865
+ for (const f of base.features) {
866
+ const ev = getNwsAlertEventLabelFromProperties(f?.properties ?? {});
867
+ if (isNwsEventIncludedInAllowlist(includedAlerts, ev)) {
868
+ out.push(f);
869
+ }
870
+ }
871
+ return { type: 'FeatureCollection', features: out };
872
+ }
873
+
874
+ /**
875
+ * @param {import('geojson').FeatureCollection} fc
876
+ * @param {'all' | 'user'} [scope]
877
+ * @param {string[] | undefined} [includedAlerts] - Used when `scope === 'user'`: exact NWS `event_name` strings to show.
878
+ */
879
+ export function filterNwsAlertsByScope(fc, scope = 'all', includedAlerts) {
880
+ const safe = normalizeFeatureCollectionForMapboxGl(fc);
881
+ if (scope === 'user') {
882
+ return filterNwsAlertsByIncludedList(safe, includedAlerts ?? []);
883
+ }
884
+ const features = safe.features.filter((f) => {
885
+ const ev = getNwsAlertEventLabelFromProperties(f?.properties ?? {});
886
+ return !!(ev && String(ev).trim());
887
+ });
888
+ return { type: 'FeatureCollection', features };
889
+ }
890
+
891
+ /** @deprecated Use {@link filterToConvectiveSdkEvents} or {@link filterNwsAlertsByScope} with `scope: 'user'` and an explicit `includedAlerts` list. */
892
+ export function filterToSdkAllowedEvents(fc) {
893
+ return filterToConvectiveSdkEvents(fc);
894
+ }
895
+
896
+ function extractRawNwwsVtecString(properties) {
897
+ if (!properties) return null;
898
+ if (properties.vtec != null) return String(properties.vtec);
899
+ if (properties.VTEC != null) return String(properties.VTEC);
900
+ const details = properties.details;
901
+ if (details?.pvtec != null) return String(details.pvtec);
902
+ const params = properties.parameters;
903
+ if (params != null && typeof params === 'object' && !Array.isArray(params)) {
904
+ const direct = params.VTEC ?? params.vtec;
905
+ if (direct != null) {
906
+ return Array.isArray(direct) ? String(direct[0] ?? '') : String(direct);
907
+ }
908
+ for (const k of Object.keys(params)) {
909
+ if (String(k).toUpperCase() === 'VTEC') {
910
+ const x = params[k];
911
+ if (x != null) return Array.isArray(x) ? String(x[0] ?? '') : String(x);
912
+ }
913
+ }
914
+ }
915
+ return null;
916
+ }
917
+
918
+ function normalizeNwsChainTimeComponent(v) {
919
+ if (v == null || v === '') return null;
920
+ if (typeof v === 'number' && Number.isFinite(v)) {
921
+ return new Date(v > 1e12 ? v : v * 1000).toISOString();
922
+ }
923
+ const s = String(v).trim();
924
+ const ms = Date.parse(s);
925
+ if (!Number.isNaN(ms)) return new Date(ms).toISOString();
926
+ return s;
927
+ }
928
+
929
+ function getNwsOfficeEventIssuedChainId(properties) {
930
+ if (!properties) return null;
931
+ const office = String(properties.office ?? properties.sender_icao ?? properties.sender ?? '').trim();
932
+ const ev = String(properties.event ?? properties.event_name ?? '').trim();
933
+ const rawIssued = getNwsTimeProp(properties, ['issued', 'issued_at']);
934
+ if (!office || !ev || rawIssued == null || rawIssued === '') return null;
935
+ const issuedNorm = normalizeNwsChainTimeComponent(rawIssued);
936
+ if (!issuedNorm) return null;
937
+ return `${office}|${ev}|${issuedNorm}`;
938
+ }
939
+
940
+ /**
941
+ * Extracts a stable VTEC-derived alert ID (e.g. "KWNS.SV.A.0108") from raw feature properties.
942
+ * The VTEC event number is stable across CON/EXT/CAN actions for the same watch/warning,
943
+ * unlike content-based hashes which change on every update.
944
+ */
945
+ function extractNwsVtecStableId(properties) {
946
+ const vtec = extractRawNwwsVtecString(properties);
947
+ if (vtec == null || vtec === '') return null;
948
+ const parts = String(vtec).split('.');
949
+ if (parts.length < 6) return null;
950
+ const office = parts[2]?.trim();
951
+ const ph = parts[3]?.trim();
952
+ const sig = parts[4]?.trim();
953
+ const seq = parts[5]?.trim();
954
+ if (!office || !ph || !sig || !seq) return null;
955
+ return `${office}.${ph}.${sig}.${seq}`;
956
+ }
957
+
958
+ function getNwsHttpAlertStableId(properties) {
959
+ if (!properties) return null;
960
+ const details = properties.details;
961
+ if (details && typeof details === 'object') {
962
+ const tr = details.tracking;
963
+ if (tr != null && String(tr).trim() !== '') return String(tr).trim();
964
+ }
965
+ // Same DB key as backend — use if trim drops details/hash (see API trim payloads).
966
+ for (const k of ['nws_stable_id', 'stable_id', 'alert_chain_id']) {
967
+ const v = properties[k];
968
+ if (v != null && String(v).trim() !== '') return String(v).trim();
969
+ }
970
+ if (properties.tracking != null && typeof properties.tracking === 'string' && String(properties.tracking).trim() !== '') {
971
+ return String(properties.tracking).trim();
972
+ }
973
+ // Prefer VTEC-derived ID over hash: VTEC event number is stable across all CON/EXT/UPG
974
+ // actions while the content hash changes on every update, causing phantom duplicates on the map.
975
+ const vtecId = extractNwsVtecStableId(properties);
976
+ if (vtecId) return vtecId;
977
+ const chainId = getNwsOfficeEventIssuedChainId(properties);
978
+ if (chainId) return chainId;
979
+ const h = properties.hash;
980
+ if (h != null && String(h).trim() !== '') return String(h).trim();
981
+ return null;
982
+ }
983
+
984
+ function mapHttpActionToTrackerStatus(p, forLiveStream) {
985
+ if (!forLiveStream) return undefined;
986
+ if (p.is_cancelled === true) return 'cancelled';
987
+ const at = String(p.action_type ?? '').trim().toLowerCase();
988
+ if (at === 'cancelled' || at === 'canceled') return 'cancelled';
989
+ if (at === 'expired') return 'expired';
990
+ if (at === 'upgraded') return 'upgraded';
991
+ if (at === 'extended' || at === 'extension') return 'extended';
992
+ if (at === 'updated') return 'extended';
993
+ if (at === 'continuation' || at === 'continued') return 'continuation';
994
+ if (at === 'issued' || at === 'new') return 'issued';
995
+ if (at === '') return 'issued';
996
+ return 'issued';
997
+ }
998
+
999
+ export function normalizeNwwsHttpAlertFeature(raw, forLiveStream) {
1000
+ if (!raw || typeof raw !== 'object' || raw.type !== 'Feature') return raw;
1001
+ const p = { ...(raw.properties ?? {}) };
1002
+ const explicitUid =
1003
+ normalizeNwwsAlertId(raw.id) ?? normalizeNwwsAlertId(p.id) ?? normalizeNwwsAlertId(p.alert_id ?? p.alertId);
1004
+ if (explicitUid && isNwwsVtecTimestampInstanceId(explicitUid)) {
1005
+ p.base_alert_id = explicitUid;
1006
+ p.alert_id = explicitUid;
1007
+ const vb = getNwwsVtecSequenceBaseFromInstanceId(explicitUid);
1008
+ if (vb) p._nws_vtec_seq_base = vb;
1009
+ } else {
1010
+ const stableId = getNwsHttpAlertStableId(p);
1011
+ if (stableId) {
1012
+ p.base_alert_id = stableId;
1013
+ p.alert_id = stableId;
1014
+ } else {
1015
+ const fallback =
1016
+ normalizeNwwsAlertId(raw.id) ?? normalizeNwwsAlertId(p.id) ?? normalizeNwwsAlertId(p.alert_id ?? p.alertId);
1017
+ if (fallback) {
1018
+ p.base_alert_id = fallback;
1019
+ p.alert_id = fallback;
1020
+ } else {
1021
+ const chain = getNwsOfficeEventIssuedChainId(p);
1022
+ if (chain) {
1023
+ p.base_alert_id = chain;
1024
+ p.alert_id = chain;
1025
+ }
1026
+ }
1027
+ }
1028
+ }
1029
+ const tracker = mapHttpActionToTrackerStatus(p, forLiveStream);
1030
+ if (tracker !== undefined) {
1031
+ p._trackerStatus = tracker;
1032
+ }
1033
+ if (p._nws_vtec_seq_base == null) {
1034
+ const capV = extractNwsVtecStableId(p);
1035
+ if (capV) p._nws_vtec_seq_base = capV;
1036
+ }
1037
+ return { ...raw, properties: p };
1038
+ }
1039
+
1040
+ export function unwrapNwwsFeatureCollectionRoot(parsed) {
1041
+ if (Array.isArray(parsed)) {
1042
+ return {
1043
+ type: 'FeatureCollection',
1044
+ features: parsed.map((f) => normalizeNwwsHttpAlertFeature(f, false)),
1045
+ };
1046
+ }
1047
+ if (!parsed || typeof parsed !== 'object') return null;
1048
+ const o = parsed;
1049
+ if (o.type === 'FeatureCollection' && Array.isArray(o.features)) {
1050
+ return {
1051
+ type: 'FeatureCollection',
1052
+ features: o.features.map((f) => normalizeNwwsHttpAlertFeature(f, false)),
1053
+ };
1054
+ }
1055
+ for (const key of ['geojson', 'data', 'payload', 'body', 'collection', 'result', 'message', 'record']) {
1056
+ const inner = o[key];
1057
+ if (
1058
+ inner &&
1059
+ typeof inner === 'object' &&
1060
+ inner.type === 'FeatureCollection' &&
1061
+ Array.isArray(inner.features)
1062
+ ) {
1063
+ return {
1064
+ type: 'FeatureCollection',
1065
+ features: inner.features.map((f) => normalizeNwwsHttpAlertFeature(f, false)),
1066
+ };
1067
+ }
1068
+ }
1069
+ return null;
1070
+ }
1071
+
1072
+ export function cloneNwwsFeatureCollectionStrippingVolatile(data) {
1073
+ const norm = normalizeFeatureCollectionForMapboxGl(data);
1074
+ return {
1075
+ type: 'FeatureCollection',
1076
+ features: norm.features.flatMap((f) => {
1077
+ const p = f?.properties;
1078
+ if (p) {
1079
+ const ex = p.nwws_expired;
1080
+ if (ex === true || ex === 1 || String(ex).toLowerCase() === 'true') {
1081
+ return [];
1082
+ }
1083
+ }
1084
+ const copy = {
1085
+ ...f,
1086
+ properties: p ? { ...p } : {},
1087
+ };
1088
+ delete copy._lastActiveState;
1089
+ if (copy.properties && 'nwws_expired' in copy.properties) {
1090
+ delete copy.properties.nwws_expired;
1091
+ }
1092
+ if (copy.properties) {
1093
+ delete copy.properties._nws_flash_until_unix;
1094
+ delete copy.properties._nws_webhook_debug_flash;
1095
+ }
1096
+ return [copy];
1097
+ }),
1098
+ };
1099
+ }
1100
+
1101
+ export function buildAlertClickPayload(feature) {
1102
+ const properties = feature?.properties ? { ...feature.properties } : {};
1103
+ const eventName = getNwsAlertEventLabelFromProperties(properties);
1104
+ let tags = properties.tags;
1105
+ if (typeof tags === 'string') {
1106
+ try {
1107
+ tags = JSON.parse(tags);
1108
+ } catch {
1109
+ tags = null;
1110
+ }
1111
+ }
1112
+ if (!Array.isArray(tags)) {
1113
+ tags = tags != null ? [String(tags)] : [];
1114
+ }
1115
+ const headline = properties.headline != null ? String(properties.headline) : '';
1116
+ const name =
1117
+ headline ||
1118
+ eventName ||
1119
+ (properties.event != null ? String(properties.event) : '') ||
1120
+ (properties.event_name != null ? String(properties.event_name) : '');
1121
+ const description =
1122
+ properties.description != null
1123
+ ? String(properties.description)
1124
+ : properties.raw_text != null
1125
+ ? String(properties.raw_text)
1126
+ : '';
1127
+ const summary = properties.summary != null ? String(properties.summary) : '';
1128
+ const instruction = properties.instruction != null ? String(properties.instruction) : '';
1129
+
1130
+ const startUnix =
1131
+ typeof properties.start_unix === 'number'
1132
+ ? properties.start_unix
1133
+ : getNwsStartUnixForMap(properties);
1134
+ const endUnix =
1135
+ typeof properties.end_unix === 'number'
1136
+ ? properties.end_unix
1137
+ : parseNwsTimeToUnix(getNwsTimeProp(properties, [...NWS_ALERT_END_TIME_PROP_KEYS]));
1138
+ const activeStartUnix =
1139
+ typeof properties.active_start_unix === 'number'
1140
+ ? properties.active_start_unix
1141
+ : getNwsActiveStartUnix(properties);
1142
+
1143
+ return {
1144
+ name,
1145
+ eventName,
1146
+ headline,
1147
+ summary,
1148
+ description,
1149
+ instruction,
1150
+ tags,
1151
+ startUnix,
1152
+ endUnix,
1153
+ activeStartUnix,
1154
+ properties,
1155
+ };
1156
+ }