@aguacerowx/mapsgl 0.0.50 → 0.0.51
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,852 +1,858 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* NWS watches/warnings overlay for Mapbox GL (default: all alert types; optional user allowlist).
|
|
3
|
-
* Pairs with aguacero-frontend defaults and NWWS HTTP + SSE feeds.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import {
|
|
7
|
-
buildNwsDualLinePaint,
|
|
8
|
-
buildNwsFillColorExpression,
|
|
9
|
-
buildNwsLineDasharrayExpression,
|
|
10
|
-
buildWarningsFillOpacityExpr,
|
|
11
|
-
buildWarningsLineOpacityExpr,
|
|
12
|
-
collectNwsExpressionEventKeys,
|
|
13
|
-
NWS_DEFAULT_FILL_HIDDEN,
|
|
14
|
-
} from './nwsSdkConstants.js';
|
|
15
|
-
import {
|
|
16
|
-
applyNwwsDeltaToCollection,
|
|
17
|
-
buildAlertClickPayload,
|
|
18
|
-
cloneNwwsFeatureCollectionStrippingVolatile,
|
|
19
|
-
enrichWarningsWithColors,
|
|
20
|
-
filterNwwsTerminalStatusFeatures,
|
|
21
|
-
filterNwsAlertsByScope,
|
|
22
|
-
getNwwsProductKeyFromFeature,
|
|
23
|
-
normalizeFeatureCollectionForMapboxGl,
|
|
24
|
-
normalizeNwwsHttpAlertFeature,
|
|
25
|
-
nwsRevisionVisibilityActive,
|
|
26
|
-
parseNwwsWsDelta,
|
|
27
|
-
pickBestNwsRevisionAmongConflictingFeatures,
|
|
28
|
-
unwrapNwwsFeatureCollectionRoot,
|
|
29
|
-
} from './nwsAlertsSupport.js';
|
|
30
|
-
|
|
31
|
-
/** Aguacero base style: state polygon outlines — NWS alert lines render under this layer. */
|
|
32
|
-
export const NWS_DEFAULT_LINE_BEFORE_LAYER_ID = 'AML_-_states';
|
|
33
|
-
|
|
34
|
-
const DEFAULT_OPTS = {
|
|
35
|
-
enabled: false,
|
|
36
|
-
/**
|
|
37
|
-
* Optional manual override: insert NWS fill immediately **below** this layer id.
|
|
38
|
-
* When null, {@link NwsWatchesWarningsOverlay._resolveFillAnchorLayerId} picks the lower of
|
|
39
|
-
* `weatherLayerId` and {@link NWS_DEFAULT_LINE_BEFORE_LAYER_ID} so fill does not cover state outlines
|
|
40
|
-
* when the weather custom layer is stacked above `AML_-_states`.
|
|
41
|
-
*/
|
|
42
|
-
fillBeforeLayerId: null,
|
|
43
|
-
/**
|
|
44
|
-
* Active satellite / MRMS / NEXRAD Mapbox layer id (set by {@link WeatherLayerManager}).
|
|
45
|
-
* Used with {@link NWS_DEFAULT_LINE_BEFORE_LAYER_ID} for automatic fill stacking when `fillBeforeLayerId` is unset.
|
|
46
|
-
*/
|
|
47
|
-
weatherLayerId: null,
|
|
48
|
-
/**
|
|
49
|
-
* Insert NWS outline lines immediately **below** this layer (under state boundaries, above terrain).
|
|
50
|
-
* @default {@link NWS_DEFAULT_LINE_BEFORE_LAYER_ID}
|
|
51
|
-
*/
|
|
52
|
-
lineBeforeLayerId: NWS_DEFAULT_LINE_BEFORE_LAYER_ID,
|
|
53
|
-
/** Base URL without trailing slash; `/alerts` and `/alerts/stream` are appended. */
|
|
54
|
-
alertsBaseUrl: 'https://api.aguacerowx.com',
|
|
55
|
-
/** When true, time filter uses wall clock and onset window (matches frontend `activeOnlyRealtime`). */
|
|
56
|
-
activeOnlyRealtime: false,
|
|
57
|
-
/** When false, map clicks do not emit `nws:alert:click`. */
|
|
58
|
-
alertInteractionEnabled: true,
|
|
59
|
-
fillOpacity: 1,
|
|
60
|
-
lineOpacity: 0.9,
|
|
61
|
-
lineWidth: 2,
|
|
62
|
-
/** Debounce SSE delta merges (ms). */
|
|
63
|
-
deltaDebounceMs: 500,
|
|
64
|
-
nwsAlertSettings: null,
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
function normalizeAlertScope(d) {
|
|
68
|
-
const s = d?.alertScope;
|
|
69
|
-
if (s === 'user') {
|
|
70
|
-
return 'user';
|
|
71
|
-
}
|
|
72
|
-
return 'all';
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function mergeNwsSettings(base) {
|
|
76
|
-
const d = base ?? {};
|
|
77
|
-
return {
|
|
78
|
-
colors: { ...(d.colors ?? {}) },
|
|
79
|
-
fillHidden: {
|
|
80
|
-
...NWS_DEFAULT_FILL_HIDDEN,
|
|
81
|
-
...(d.fillHidden ?? {}),
|
|
82
|
-
},
|
|
83
|
-
lineHidden: { ...(d.lineHidden ?? {}) },
|
|
84
|
-
fillOpacity: d.fillOpacity != null ? { ...d.fillOpacity } : {},
|
|
85
|
-
lineStyles: { ...(d.lineStyles ?? {}) },
|
|
86
|
-
lineDash: d.lineDash ?? 'solid',
|
|
87
|
-
flashStyles: { ...(d.flashStyles ?? {}) },
|
|
88
|
-
activeOnlyRealtime: d.activeOnlyRealtime ?? false,
|
|
89
|
-
/**
|
|
90
|
-
* `'all'` (default): every alert with a resolved `event_name`.
|
|
91
|
-
* `'user'`: only alerts listed in `includedAlerts` (exact NWS names; subtypes match if parent is listed).
|
|
92
|
-
*/
|
|
93
|
-
alertScope: normalizeAlertScope(d),
|
|
94
|
-
/** @type {string[]} */
|
|
95
|
-
includedAlerts: Array.isArray(d.includedAlerts)
|
|
96
|
-
? d.includedAlerts.map((x) => (x != null ? String(x).trim() : '')).filter(Boolean)
|
|
97
|
-
: [],
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export class NwsWatchesWarningsOverlay {
|
|
102
|
-
/**
|
|
103
|
-
* @param {import('mapbox-gl').Map} map
|
|
104
|
-
* @param {object} [options]
|
|
105
|
-
*/
|
|
106
|
-
constructor(map, options = {}) {
|
|
107
|
-
this.map = map;
|
|
108
|
-
this.options = { ...DEFAULT_OPTS, ...options };
|
|
109
|
-
this._nwsAlertSettings = mergeNwsSettings({
|
|
110
|
-
...(options.nwsAlertSettings ?? {}),
|
|
111
|
-
...(options.activeOnlyRealtime !== undefined ? { activeOnlyRealtime: options.activeOnlyRealtime } : {}),
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
this._sourceId = 'aguacero-nws-alerts-source';
|
|
115
|
-
this._fillId = 'aguacero-nws-alerts-fill';
|
|
116
|
-
this._lineCasingId = 'aguacero-nws-alerts-line-casing';
|
|
117
|
-
this._lineCoreId = 'aguacero-nws-alerts-line-core';
|
|
118
|
-
|
|
119
|
-
this._workingFc = { type: 'FeatureCollection', features: [] };
|
|
120
|
-
/** Terminal + enriched features before `alertScope` / `includedAlerts` filtering (full dataset for SSE + scope changes). */
|
|
121
|
-
this._preScopeFc = { type: 'FeatureCollection', features: [] };
|
|
122
|
-
this._eventSource = null;
|
|
123
|
-
this._deltaTimer = null;
|
|
124
|
-
this._pendingDeltas = [];
|
|
125
|
-
this._boundClick = this._onMapClick.bind(this);
|
|
126
|
-
/** @type {((ev: MessageEvent) => void) | null} */
|
|
127
|
-
this._sseInboundHandler = null;
|
|
128
|
-
/** @type {string[] | null} */
|
|
129
|
-
this._sseBoundEventNames = null;
|
|
130
|
-
this._sseFirstOpen = true;
|
|
131
|
-
this._destroyed = false;
|
|
132
|
-
/** When true, baseline fetch + SSE have been started for the current enabled session. */
|
|
133
|
-
this._started = false;
|
|
134
|
-
|
|
135
|
-
/** @type {number | null | undefined} */
|
|
136
|
-
this._timelineUnix = undefined;
|
|
137
|
-
/** Timeline unix list for the active layer (satellite / MRMS / NEXRAD) — used to match frontend “live edge” behavior. */
|
|
138
|
-
this._timelineTimes = null;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
getAlertsUrl() {
|
|
142
|
-
const base = String(this.options.alertsBaseUrl || '').replace(/\/$/, '');
|
|
143
|
-
return `${base}/alerts`;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
getStreamUrl() {
|
|
147
|
-
const base = String(this.options.alertsBaseUrl || '').replace(/\/$/, '');
|
|
148
|
-
return `${base}/alerts/stream`;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Update merged NWS user settings (colors, allowlist, fill/line visibility, etc.).
|
|
153
|
-
* @param {object} partial
|
|
154
|
-
*/
|
|
155
|
-
setNwsAlertSettings(partial) {
|
|
156
|
-
this._nwsAlertSettings = mergeNwsSettings({ ...this._nwsAlertSettings, ...partial });
|
|
157
|
-
this._refreshPaint();
|
|
158
|
-
this._resyncSourceDataFilters();
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** Merge top-level options (e.g. `deltaDebounceMs`, `alertsBaseUrl`) and/or nested `nwsAlertSettings`. */
|
|
162
|
-
updateOptions(partial) {
|
|
163
|
-
if (!partial || typeof partial !== 'object') return;
|
|
164
|
-
for (const [k, v] of Object.entries(partial)) {
|
|
165
|
-
if (v === undefined) continue;
|
|
166
|
-
if (k === 'nwsAlertSettings' && v && typeof v === 'object') {
|
|
167
|
-
this._nwsAlertSettings = mergeNwsSettings({
|
|
168
|
-
...this._nwsAlertSettings,
|
|
169
|
-
...v,
|
|
170
|
-
});
|
|
171
|
-
} else {
|
|
172
|
-
this.options[k] = v;
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
if (this.map?.getLayer?.(this._fillId)) {
|
|
176
|
-
this._refreshPaint();
|
|
177
|
-
this._resyncSourceDataFilters();
|
|
178
|
-
this._syncClickHandler();
|
|
179
|
-
this._applyLayerOrder();
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Layer id to insert/move NWS fill **before** (Mapbox: directly under that layer).
|
|
185
|
-
* Manual `fillBeforeLayerId` wins. When the active weather layer exists, fill is always anchored under it
|
|
186
|
-
* so radar/satellite/MRMS draws above the alert fill. Without weather, falls back under state outlines when present.
|
|
187
|
-
* @returns {string | null}
|
|
188
|
-
*/
|
|
189
|
-
_resolveFillAnchorLayerId() {
|
|
190
|
-
const m = this.map;
|
|
191
|
-
const styleLayers = m.getStyle()?.layers;
|
|
192
|
-
if (!styleLayers) {
|
|
193
|
-
return null;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
const manual = this.options.fillBeforeLayerId;
|
|
197
|
-
if (manual != null && String(manual) !== '' && m.getLayer(String(manual))) {
|
|
198
|
-
return String(manual);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
const layerIndex = (id) => styleLayers.findIndex((l) => l.id === id);
|
|
202
|
-
const weatherId = this.options.weatherLayerId;
|
|
203
|
-
const statesId = NWS_DEFAULT_LINE_BEFORE_LAYER_ID;
|
|
204
|
-
|
|
205
|
-
if (weatherId && m.getLayer(weatherId)) {
|
|
206
|
-
return weatherId;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
const iS = m.getLayer(statesId) ? layerIndex(statesId) : -1;
|
|
210
|
-
if (iS >= 0) {
|
|
211
|
-
return statesId;
|
|
212
|
-
}
|
|
213
|
-
return null;
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* Stack: alert fill below weather; NEXRAD/satellite/MRMS below alert lines.
|
|
218
|
-
* When weather is present, lines sit directly under whatever layer is above the weather layer (often state outlines).
|
|
219
|
-
*/
|
|
220
|
-
_applyLayerOrder() {
|
|
221
|
-
const m = this.map;
|
|
222
|
-
if (!m?.getStyle?.() || !m.getLayer(this._fillId)) {
|
|
223
|
-
return;
|
|
224
|
-
}
|
|
225
|
-
const weatherId = this.options.weatherLayerId;
|
|
226
|
-
const statesFallback = this.options.lineBeforeLayerId || NWS_DEFAULT_LINE_BEFORE_LAYER_ID;
|
|
227
|
-
|
|
228
|
-
try {
|
|
229
|
-
const fillAnchor = this._resolveFillAnchorLayerId();
|
|
230
|
-
if (fillAnchor) {
|
|
231
|
-
m.moveLayer(this._fillId, fillAnchor);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
const layers = m.getStyle()?.layers ?? [];
|
|
235
|
-
let lineBeforeId = null;
|
|
236
|
-
if (weatherId && m.getLayer(weatherId)) {
|
|
237
|
-
const iw = layers.findIndex((l) => l.id === weatherId);
|
|
238
|
-
if (iw >= 0 && iw < layers.length - 1) {
|
|
239
|
-
lineBeforeId = layers[iw + 1].id;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
if (!lineBeforeId && m.getLayer(statesFallback)) {
|
|
243
|
-
lineBeforeId = statesFallback;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
if (lineBeforeId && m.getLayer(this._lineCasingId) && m.getLayer(lineBeforeId)) {
|
|
247
|
-
m.moveLayer(this._lineCasingId, lineBeforeId);
|
|
248
|
-
}
|
|
249
|
-
if (lineBeforeId && m.getLayer(this._lineCoreId) && m.getLayer(lineBeforeId)) {
|
|
250
|
-
m.moveLayer(this._lineCoreId, lineBeforeId);
|
|
251
|
-
}
|
|
252
|
-
} catch {
|
|
253
|
-
/* style still settling */
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/**
|
|
258
|
-
* @param {boolean} enabled - Master switch; when false, stream is torn down and layers cleared.
|
|
259
|
-
*/
|
|
260
|
-
setEnabled(enabled) {
|
|
261
|
-
this.options.enabled = !!enabled;
|
|
262
|
-
this.syncWithMode({
|
|
263
|
-
enabled: this.options.enabled,
|
|
264
|
-
timelineUnix: this._timelineUnix,
|
|
265
|
-
});
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/**
|
|
269
|
-
* Whether clicking alerts emits `nws:alert:click` on the map.
|
|
270
|
-
* @param {boolean} on
|
|
271
|
-
*/
|
|
272
|
-
setAlertInteractionEnabled(on) {
|
|
273
|
-
this.options.alertInteractionEnabled = !!on;
|
|
274
|
-
this._syncClickHandler();
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
/**
|
|
278
|
-
* Timeline unix (satellite / MRMS / NEXRAD slider) for time-of-day filtering.
|
|
279
|
-
* @param {number | null | undefined} unixSeconds
|
|
280
|
-
*/
|
|
281
|
-
setTimelineUnix(unixSeconds) {
|
|
282
|
-
this._timelineUnix = unixSeconds;
|
|
283
|
-
this._applyTimeFilters();
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
_userColors() {
|
|
287
|
-
return this._nwsAlertSettings.colors ?? {};
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
_lineStyles() {
|
|
291
|
-
return this._nwsAlertSettings.lineStyles ?? {};
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
_flashStyles() {
|
|
295
|
-
return this._nwsAlertSettings.flashStyles ?? {};
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
_isFollowingLatestTimelineAnchor() {
|
|
299
|
-
const current = this._timelineUnix;
|
|
300
|
-
if (current == null || !Number.isFinite(current)) return false;
|
|
301
|
-
const times = this._timelineTimes;
|
|
302
|
-
if (!Array.isArray(times) || times.length === 0) return false;
|
|
303
|
-
let latest = -Infinity;
|
|
304
|
-
for (const t of times) {
|
|
305
|
-
if (typeof t === 'number' && Number.isFinite(t) && t > latest) {
|
|
306
|
-
latest = t;
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
if (!Number.isFinite(latest)) return false;
|
|
310
|
-
return Math.abs(current - latest) <= 1;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
/**
|
|
314
|
-
* Matches aguacero-frontend {@link WatchesWarningsLayer.resolveNwsDisplayTimeMode}: at the last slider
|
|
315
|
-
* step, use wall clock so SSE/stream updates appear even when the timeline tick lags.
|
|
316
|
-
*/
|
|
317
|
-
_resolveDisplayTimeMode() {
|
|
318
|
-
const userPinnedRealtime = this._nwsAlertSettings.activeOnlyRealtime === true;
|
|
319
|
-
const followRealtimeAtLatestAnchor = !userPinnedRealtime && this._isFollowingLatestTimelineAnchor();
|
|
320
|
-
const useRealtime = userPinnedRealtime || followRealtimeAtLatestAnchor;
|
|
321
|
-
return {
|
|
322
|
-
useRealtime,
|
|
323
|
-
timelineUnix: useRealtime ? undefined : this._timelineUnix,
|
|
324
|
-
};
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
_applyTimeFilters() {
|
|
328
|
-
const m = this.map;
|
|
329
|
-
if (!m?.getSource?.(this._sourceId)) return;
|
|
330
|
-
|
|
331
|
-
const timeMode = this._resolveDisplayTimeMode();
|
|
332
|
-
const referenceUnix = timeMode.useRealtime
|
|
333
|
-
? Math.floor(Date.now() / 1000)
|
|
334
|
-
: timeMode.timelineUnix;
|
|
335
|
-
const useOnsetWindow = this._nwsAlertSettings.activeOnlyRealtime === true;
|
|
336
|
-
|
|
337
|
-
/** @type {Map<number, boolean>} */
|
|
338
|
-
const tentativeActive = new Map();
|
|
339
|
-
|
|
340
|
-
for (const f of this._workingFc.features ?? []) {
|
|
341
|
-
if (f.id == null) continue;
|
|
342
|
-
const fid = typeof f.id === 'number' ? f.id : Number(f.id);
|
|
343
|
-
if (!Number.isFinite(fid)) continue;
|
|
344
|
-
|
|
345
|
-
let isActive = true;
|
|
346
|
-
if (referenceUnix != null && Number.isFinite(referenceUnix)) {
|
|
347
|
-
const p = f.properties ?? {};
|
|
348
|
-
const rev = nwsRevisionVisibilityActive(p, referenceUnix);
|
|
349
|
-
if (rev === false) {
|
|
350
|
-
isActive = false;
|
|
351
|
-
} else {
|
|
352
|
-
const startWindow = useOnsetWindow ? (p.active_start_unix ?? p.start_unix) : p.start_unix;
|
|
353
|
-
if (startWindow != null && referenceUnix < startWindow) {
|
|
354
|
-
isActive = false;
|
|
355
|
-
} else if (p.end_unix != null && referenceUnix > p.end_unix) {
|
|
356
|
-
isActive = false;
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
tentativeActive.set(fid, isActive);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
if (referenceUnix != null && Number.isFinite(referenceUnix)) {
|
|
365
|
-
const keyToFeatures = new globalThis.Map();
|
|
366
|
-
for (const f of this._workingFc.features ?? []) {
|
|
367
|
-
if (f.id == null) continue;
|
|
368
|
-
const fid = typeof f.id === 'number' ? f.id : Number(f.id);
|
|
369
|
-
if (!Number.isFinite(fid) || !tentativeActive.get(fid)) continue;
|
|
370
|
-
const pk = getNwwsProductKeyFromFeature(f);
|
|
371
|
-
if (!pk) continue;
|
|
372
|
-
let bucket = keyToFeatures.get(pk);
|
|
373
|
-
if (!bucket) {
|
|
374
|
-
bucket = [];
|
|
375
|
-
keyToFeatures.set(pk, bucket);
|
|
376
|
-
}
|
|
377
|
-
bucket.push(f);
|
|
378
|
-
}
|
|
379
|
-
for (const [, group] of keyToFeatures) {
|
|
380
|
-
if (group.length <= 1) continue;
|
|
381
|
-
const winner = pickBestNwsRevisionAmongConflictingFeatures(group, referenceUnix);
|
|
382
|
-
const winId = typeof winner?.id === 'number' ? winner.id : Number(winner?.id);
|
|
383
|
-
for (const f of group) {
|
|
384
|
-
const gfid = typeof f.id === 'number' ? f.id : Number(f.id);
|
|
385
|
-
if (Number.isFinite(gfid) && Number.isFinite(winId) && gfid !== winId) {
|
|
386
|
-
tentativeActive.set(gfid, false);
|
|
387
|
-
}
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
for (const f of this._workingFc.features ?? []) {
|
|
393
|
-
if (f.id == null) continue;
|
|
394
|
-
const fid = typeof f.id === 'number' ? f.id : Number(f.id);
|
|
395
|
-
if (!Number.isFinite(fid)) continue;
|
|
396
|
-
const isActive = tentativeActive.get(fid) ?? true;
|
|
397
|
-
try {
|
|
398
|
-
m.setFeatureState({ source: this._sourceId, id: f.id }, { active: isActive });
|
|
399
|
-
} catch {
|
|
400
|
-
/* ignore */
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
_buildPaint() {
|
|
406
|
-
const settings = this._nwsAlertSettings;
|
|
407
|
-
const visible = this.options.enabled !== false;
|
|
408
|
-
const fillOpacity = Number(this.options.fillOpacity ?? 1);
|
|
409
|
-
const lineOpacity = Number(this.options.lineOpacity ?? 0.9);
|
|
410
|
-
const lineWidth = Number(this.options.lineWidth ?? 2);
|
|
411
|
-
|
|
412
|
-
const userColors = this._userColors();
|
|
413
|
-
const exprKeys = collectNwsExpressionEventKeys(settings);
|
|
414
|
-
const fillColorExpr = buildNwsFillColorExpression(userColors, exprKeys);
|
|
415
|
-
const dualLine = buildNwsDualLinePaint(userColors, this._lineStyles(), lineWidth, exprKeys);
|
|
416
|
-
const lineDashExpr = buildNwsLineDasharrayExpression(this._lineStyles(), settings.lineDash ?? 'solid', exprKeys);
|
|
417
|
-
|
|
418
|
-
const baseFillOpacityExpr = buildWarningsFillOpacityExpr(
|
|
419
|
-
visible,
|
|
420
|
-
fillOpacity,
|
|
421
|
-
settings.fillHidden,
|
|
422
|
-
settings.fillOpacity,
|
|
423
|
-
exprKeys
|
|
424
|
-
);
|
|
425
|
-
const baseLineOpacityExpr = buildWarningsLineOpacityExpr(visible, lineOpacity, settings.lineHidden, exprKeys);
|
|
426
|
-
|
|
427
|
-
const fillOpacityExpr = ['case', ['boolean', ['feature-state', 'active'], false], baseFillOpacityExpr, 0];
|
|
428
|
-
const lineOpacityExpr = ['case', ['boolean', ['feature-state', 'active'], false], baseLineOpacityExpr, 0];
|
|
429
|
-
|
|
430
|
-
return { fillColorExpr, dualLine, lineDashExpr, fillOpacityExpr, lineOpacityExpr, lineLayout: {
|
|
431
|
-
'line-cap': 'round',
|
|
432
|
-
'line-join': 'round',
|
|
433
|
-
'line-sort-key': ['coalesce', ['get', '_nws_render_priority'], 0],
|
|
434
|
-
} };
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
_refreshPaint() {
|
|
438
|
-
const m = this.map;
|
|
439
|
-
if (!m?.getLayer?.(this._fillId)) return;
|
|
440
|
-
const p = this._buildPaint();
|
|
441
|
-
m.setPaintProperty(this._fillId, 'fill-color', p.fillColorExpr);
|
|
442
|
-
m.setPaintProperty(this._fillId, 'fill-opacity', p.fillOpacityExpr);
|
|
443
|
-
m.setPaintProperty(this._lineCasingId, 'line-color', p.dualLine.casingColor);
|
|
444
|
-
m.setPaintProperty(this._lineCasingId, 'line-width', p.dualLine.casingWidth);
|
|
445
|
-
m.setPaintProperty(this._lineCasingId, 'line-opacity', p.lineOpacityExpr);
|
|
446
|
-
m.setPaintProperty(this._lineCasingId, 'line-dasharray', p.lineDashExpr);
|
|
447
|
-
m.setPaintProperty(this._lineCoreId, 'line-color', p.dualLine.coreColor);
|
|
448
|
-
m.setPaintProperty(this._lineCoreId, 'line-width', p.dualLine.coreWidth);
|
|
449
|
-
m.setPaintProperty(this._lineCoreId, 'line-opacity', p.lineOpacityExpr);
|
|
450
|
-
m.setPaintProperty(this._lineCoreId, 'line-dasharray', p.lineDashExpr);
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
/**
|
|
454
|
-
* Re-apply `alertScope` / `includedAlerts` against the last full pre-scope snapshot (no Mapbox `disabled` filter).
|
|
455
|
-
*/
|
|
456
|
-
_resyncSourceDataFilters() {
|
|
457
|
-
const src = this.map?.getSource?.(this._sourceId);
|
|
458
|
-
if (!src || typeof src.setData !== 'function') return;
|
|
459
|
-
if (!this._preScopeFc?.features?.length) return;
|
|
460
|
-
const scope = this._nwsAlertSettings.alertScope ?? 'all';
|
|
461
|
-
const included = this._nwsAlertSettings.includedAlerts ?? [];
|
|
462
|
-
const next = normalizeFeatureCollectionForMapboxGl(
|
|
463
|
-
filterNwsAlertsByScope(this._preScopeFc, scope, included)
|
|
464
|
-
);
|
|
465
|
-
this._workingFc = next;
|
|
466
|
-
try {
|
|
467
|
-
src.setData(next);
|
|
468
|
-
} catch {
|
|
469
|
-
/* ignore */
|
|
470
|
-
}
|
|
471
|
-
this._applyTimeFilters();
|
|
472
|
-
}
|
|
473
|
-
|
|
474
|
-
_ensureLayers() {
|
|
475
|
-
const m = this.map;
|
|
476
|
-
if (!m?.addSource) return;
|
|
477
|
-
|
|
478
|
-
const paint = this._buildPaint();
|
|
479
|
-
|
|
480
|
-
const sourceData = normalizeFeatureCollectionForMapboxGl(this._workingFc);
|
|
481
|
-
if (!m.getSource(this._sourceId)) {
|
|
482
|
-
m.addSource(this._sourceId, {
|
|
483
|
-
type: 'geojson',
|
|
484
|
-
data: sourceData,
|
|
485
|
-
});
|
|
486
|
-
} else {
|
|
487
|
-
m.getSource(this._sourceId).setData(sourceData);
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
const fillLayer = {
|
|
491
|
-
id: this._fillId,
|
|
492
|
-
type: 'fill',
|
|
493
|
-
source: this._sourceId,
|
|
494
|
-
layout: { 'fill-sort-key': ['coalesce', ['get', '_nws_render_priority'], 0] },
|
|
495
|
-
paint: {
|
|
496
|
-
'fill-color': paint.fillColorExpr,
|
|
497
|
-
'fill-opacity': paint.fillOpacityExpr,
|
|
498
|
-
},
|
|
499
|
-
};
|
|
500
|
-
const casingLayer = {
|
|
501
|
-
id: this._lineCasingId,
|
|
502
|
-
type: 'line',
|
|
503
|
-
source: this._sourceId,
|
|
504
|
-
layout: paint.lineLayout,
|
|
505
|
-
paint: {
|
|
506
|
-
'line-color': paint.dualLine.casingColor,
|
|
507
|
-
'line-width': paint.dualLine.casingWidth,
|
|
508
|
-
'line-opacity': paint.lineOpacityExpr,
|
|
509
|
-
'line-dasharray': paint.lineDashExpr,
|
|
510
|
-
},
|
|
511
|
-
};
|
|
512
|
-
const coreLayer = {
|
|
513
|
-
id: this._lineCoreId,
|
|
514
|
-
type: 'line',
|
|
515
|
-
source: this._sourceId,
|
|
516
|
-
layout: paint.lineLayout,
|
|
517
|
-
paint: {
|
|
518
|
-
'line-color': paint.dualLine.coreColor,
|
|
519
|
-
'line-width': paint.dualLine.coreWidth,
|
|
520
|
-
'line-opacity': paint.lineOpacityExpr,
|
|
521
|
-
'line-dasharray': paint.lineDashExpr,
|
|
522
|
-
},
|
|
523
|
-
};
|
|
524
|
-
|
|
525
|
-
const fillAnchor = this._resolveFillAnchorLayerId();
|
|
526
|
-
const lineAnchorId = this.options.lineBeforeLayerId || NWS_DEFAULT_LINE_BEFORE_LAYER_ID;
|
|
527
|
-
const lineBefore = m.getLayer(lineAnchorId) ? lineAnchorId : undefined;
|
|
528
|
-
|
|
529
|
-
if (!m.getLayer(this._fillId)) {
|
|
530
|
-
m.addLayer(fillLayer, fillAnchor ?? undefined);
|
|
531
|
-
}
|
|
532
|
-
if (!m.getLayer(this._lineCasingId)) {
|
|
533
|
-
m.addLayer(casingLayer, lineBefore);
|
|
534
|
-
}
|
|
535
|
-
if (!m.getLayer(this._lineCoreId)) {
|
|
536
|
-
m.addLayer(coreLayer, lineBefore);
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
this._applyTimeFilters();
|
|
540
|
-
this._syncClickHandler();
|
|
541
|
-
this._applyLayerOrder();
|
|
542
|
-
requestAnimationFrame(() => this._applyLayerOrder());
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
_removeLayers() {
|
|
546
|
-
const m = this.map;
|
|
547
|
-
if (!m) return;
|
|
548
|
-
for (const id of [this._lineCoreId, this._lineCasingId, this._fillId]) {
|
|
549
|
-
if (m.getLayer(id)) {
|
|
550
|
-
m.removeLayer(id);
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
if (m.getSource(this._sourceId)) {
|
|
554
|
-
m.removeSource(this._sourceId);
|
|
555
|
-
}
|
|
556
|
-
this._workingFc = { type: 'FeatureCollection', features: [] };
|
|
557
|
-
this._preScopeFc = { type: 'FeatureCollection', features: [] };
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
_syncClickHandler() {
|
|
561
|
-
const m = this.map;
|
|
562
|
-
if (!m) return;
|
|
563
|
-
m.off('click', this._boundClick);
|
|
564
|
-
if (this.options.alertInteractionEnabled && this.options.enabled && m.getLayer(this._fillId)) {
|
|
565
|
-
m.on('click', this._boundClick);
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
_onMapClick(e) {
|
|
570
|
-
if (!this.options.alertInteractionEnabled) return;
|
|
571
|
-
const m = this.map;
|
|
572
|
-
const layers = [this._fillId, this._lineCasingId, this._lineCoreId].filter((id) => m.getLayer(id));
|
|
573
|
-
if (!layers.length) return;
|
|
574
|
-
const feats = m.queryRenderedFeatures(e.point, { layers });
|
|
575
|
-
const hit = feats.find((f) => f.source === this._sourceId);
|
|
576
|
-
if (!hit) return;
|
|
577
|
-
try {
|
|
578
|
-
const payload = buildAlertClickPayload(hit);
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
this.
|
|
595
|
-
const
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
if (
|
|
665
|
-
this._processInboundSocketText(
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
const
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
if (this.
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
this._sseBoundEventNames
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
}
|
|
764
|
-
|
|
765
|
-
this.
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
if (
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
this.
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
this.
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
1
|
+
/**
|
|
2
|
+
* NWS watches/warnings overlay for Mapbox GL (default: all alert types; optional user allowlist).
|
|
3
|
+
* Pairs with aguacero-frontend defaults and NWWS HTTP + SSE feeds.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
buildNwsDualLinePaint,
|
|
8
|
+
buildNwsFillColorExpression,
|
|
9
|
+
buildNwsLineDasharrayExpression,
|
|
10
|
+
buildWarningsFillOpacityExpr,
|
|
11
|
+
buildWarningsLineOpacityExpr,
|
|
12
|
+
collectNwsExpressionEventKeys,
|
|
13
|
+
NWS_DEFAULT_FILL_HIDDEN,
|
|
14
|
+
} from './nwsSdkConstants.js';
|
|
15
|
+
import {
|
|
16
|
+
applyNwwsDeltaToCollection,
|
|
17
|
+
buildAlertClickPayload,
|
|
18
|
+
cloneNwwsFeatureCollectionStrippingVolatile,
|
|
19
|
+
enrichWarningsWithColors,
|
|
20
|
+
filterNwwsTerminalStatusFeatures,
|
|
21
|
+
filterNwsAlertsByScope,
|
|
22
|
+
getNwwsProductKeyFromFeature,
|
|
23
|
+
normalizeFeatureCollectionForMapboxGl,
|
|
24
|
+
normalizeNwwsHttpAlertFeature,
|
|
25
|
+
nwsRevisionVisibilityActive,
|
|
26
|
+
parseNwwsWsDelta,
|
|
27
|
+
pickBestNwsRevisionAmongConflictingFeatures,
|
|
28
|
+
unwrapNwwsFeatureCollectionRoot,
|
|
29
|
+
} from './nwsAlertsSupport.js';
|
|
30
|
+
|
|
31
|
+
/** Aguacero base style: state polygon outlines — NWS alert lines render under this layer. */
|
|
32
|
+
export const NWS_DEFAULT_LINE_BEFORE_LAYER_ID = 'AML_-_states';
|
|
33
|
+
|
|
34
|
+
const DEFAULT_OPTS = {
|
|
35
|
+
enabled: false,
|
|
36
|
+
/**
|
|
37
|
+
* Optional manual override: insert NWS fill immediately **below** this layer id.
|
|
38
|
+
* When null, {@link NwsWatchesWarningsOverlay._resolveFillAnchorLayerId} picks the lower of
|
|
39
|
+
* `weatherLayerId` and {@link NWS_DEFAULT_LINE_BEFORE_LAYER_ID} so fill does not cover state outlines
|
|
40
|
+
* when the weather custom layer is stacked above `AML_-_states`.
|
|
41
|
+
*/
|
|
42
|
+
fillBeforeLayerId: null,
|
|
43
|
+
/**
|
|
44
|
+
* Active satellite / MRMS / NEXRAD Mapbox layer id (set by {@link WeatherLayerManager}).
|
|
45
|
+
* Used with {@link NWS_DEFAULT_LINE_BEFORE_LAYER_ID} for automatic fill stacking when `fillBeforeLayerId` is unset.
|
|
46
|
+
*/
|
|
47
|
+
weatherLayerId: null,
|
|
48
|
+
/**
|
|
49
|
+
* Insert NWS outline lines immediately **below** this layer (under state boundaries, above terrain).
|
|
50
|
+
* @default {@link NWS_DEFAULT_LINE_BEFORE_LAYER_ID}
|
|
51
|
+
*/
|
|
52
|
+
lineBeforeLayerId: NWS_DEFAULT_LINE_BEFORE_LAYER_ID,
|
|
53
|
+
/** Base URL without trailing slash; `/alerts` and `/alerts/stream` are appended. */
|
|
54
|
+
alertsBaseUrl: 'https://api.aguacerowx.com',
|
|
55
|
+
/** When true, time filter uses wall clock and onset window (matches frontend `activeOnlyRealtime`). */
|
|
56
|
+
activeOnlyRealtime: false,
|
|
57
|
+
/** When false, map clicks do not emit `nws:alert:click`. */
|
|
58
|
+
alertInteractionEnabled: true,
|
|
59
|
+
fillOpacity: 1,
|
|
60
|
+
lineOpacity: 0.9,
|
|
61
|
+
lineWidth: 2,
|
|
62
|
+
/** Debounce SSE delta merges (ms). */
|
|
63
|
+
deltaDebounceMs: 500,
|
|
64
|
+
nwsAlertSettings: null,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function normalizeAlertScope(d) {
|
|
68
|
+
const s = d?.alertScope;
|
|
69
|
+
if (s === 'user') {
|
|
70
|
+
return 'user';
|
|
71
|
+
}
|
|
72
|
+
return 'all';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function mergeNwsSettings(base) {
|
|
76
|
+
const d = base ?? {};
|
|
77
|
+
return {
|
|
78
|
+
colors: { ...(d.colors ?? {}) },
|
|
79
|
+
fillHidden: {
|
|
80
|
+
...NWS_DEFAULT_FILL_HIDDEN,
|
|
81
|
+
...(d.fillHidden ?? {}),
|
|
82
|
+
},
|
|
83
|
+
lineHidden: { ...(d.lineHidden ?? {}) },
|
|
84
|
+
fillOpacity: d.fillOpacity != null ? { ...d.fillOpacity } : {},
|
|
85
|
+
lineStyles: { ...(d.lineStyles ?? {}) },
|
|
86
|
+
lineDash: d.lineDash ?? 'solid',
|
|
87
|
+
flashStyles: { ...(d.flashStyles ?? {}) },
|
|
88
|
+
activeOnlyRealtime: d.activeOnlyRealtime ?? false,
|
|
89
|
+
/**
|
|
90
|
+
* `'all'` (default): every alert with a resolved `event_name`.
|
|
91
|
+
* `'user'`: only alerts listed in `includedAlerts` (exact NWS names; subtypes match if parent is listed).
|
|
92
|
+
*/
|
|
93
|
+
alertScope: normalizeAlertScope(d),
|
|
94
|
+
/** @type {string[]} */
|
|
95
|
+
includedAlerts: Array.isArray(d.includedAlerts)
|
|
96
|
+
? d.includedAlerts.map((x) => (x != null ? String(x).trim() : '')).filter(Boolean)
|
|
97
|
+
: [],
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class NwsWatchesWarningsOverlay {
|
|
102
|
+
/**
|
|
103
|
+
* @param {import('mapbox-gl').Map} map
|
|
104
|
+
* @param {object} [options]
|
|
105
|
+
*/
|
|
106
|
+
constructor(map, options = {}) {
|
|
107
|
+
this.map = map;
|
|
108
|
+
this.options = { ...DEFAULT_OPTS, ...options };
|
|
109
|
+
this._nwsAlertSettings = mergeNwsSettings({
|
|
110
|
+
...(options.nwsAlertSettings ?? {}),
|
|
111
|
+
...(options.activeOnlyRealtime !== undefined ? { activeOnlyRealtime: options.activeOnlyRealtime } : {}),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
this._sourceId = 'aguacero-nws-alerts-source';
|
|
115
|
+
this._fillId = 'aguacero-nws-alerts-fill';
|
|
116
|
+
this._lineCasingId = 'aguacero-nws-alerts-line-casing';
|
|
117
|
+
this._lineCoreId = 'aguacero-nws-alerts-line-core';
|
|
118
|
+
|
|
119
|
+
this._workingFc = { type: 'FeatureCollection', features: [] };
|
|
120
|
+
/** Terminal + enriched features before `alertScope` / `includedAlerts` filtering (full dataset for SSE + scope changes). */
|
|
121
|
+
this._preScopeFc = { type: 'FeatureCollection', features: [] };
|
|
122
|
+
this._eventSource = null;
|
|
123
|
+
this._deltaTimer = null;
|
|
124
|
+
this._pendingDeltas = [];
|
|
125
|
+
this._boundClick = this._onMapClick.bind(this);
|
|
126
|
+
/** @type {((ev: MessageEvent) => void) | null} */
|
|
127
|
+
this._sseInboundHandler = null;
|
|
128
|
+
/** @type {string[] | null} */
|
|
129
|
+
this._sseBoundEventNames = null;
|
|
130
|
+
this._sseFirstOpen = true;
|
|
131
|
+
this._destroyed = false;
|
|
132
|
+
/** When true, baseline fetch + SSE have been started for the current enabled session. */
|
|
133
|
+
this._started = false;
|
|
134
|
+
|
|
135
|
+
/** @type {number | null | undefined} */
|
|
136
|
+
this._timelineUnix = undefined;
|
|
137
|
+
/** Timeline unix list for the active layer (satellite / MRMS / NEXRAD) — used to match frontend “live edge” behavior. */
|
|
138
|
+
this._timelineTimes = null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
getAlertsUrl() {
|
|
142
|
+
const base = String(this.options.alertsBaseUrl || '').replace(/\/$/, '');
|
|
143
|
+
return `${base}/alerts`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
getStreamUrl() {
|
|
147
|
+
const base = String(this.options.alertsBaseUrl || '').replace(/\/$/, '');
|
|
148
|
+
return `${base}/alerts/stream`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Update merged NWS user settings (colors, allowlist, fill/line visibility, etc.).
|
|
153
|
+
* @param {object} partial
|
|
154
|
+
*/
|
|
155
|
+
setNwsAlertSettings(partial) {
|
|
156
|
+
this._nwsAlertSettings = mergeNwsSettings({ ...this._nwsAlertSettings, ...partial });
|
|
157
|
+
this._refreshPaint();
|
|
158
|
+
this._resyncSourceDataFilters();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Merge top-level options (e.g. `deltaDebounceMs`, `alertsBaseUrl`) and/or nested `nwsAlertSettings`. */
|
|
162
|
+
updateOptions(partial) {
|
|
163
|
+
if (!partial || typeof partial !== 'object') return;
|
|
164
|
+
for (const [k, v] of Object.entries(partial)) {
|
|
165
|
+
if (v === undefined) continue;
|
|
166
|
+
if (k === 'nwsAlertSettings' && v && typeof v === 'object') {
|
|
167
|
+
this._nwsAlertSettings = mergeNwsSettings({
|
|
168
|
+
...this._nwsAlertSettings,
|
|
169
|
+
...v,
|
|
170
|
+
});
|
|
171
|
+
} else {
|
|
172
|
+
this.options[k] = v;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (this.map?.getLayer?.(this._fillId)) {
|
|
176
|
+
this._refreshPaint();
|
|
177
|
+
this._resyncSourceDataFilters();
|
|
178
|
+
this._syncClickHandler();
|
|
179
|
+
this._applyLayerOrder();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Layer id to insert/move NWS fill **before** (Mapbox: directly under that layer).
|
|
185
|
+
* Manual `fillBeforeLayerId` wins. When the active weather layer exists, fill is always anchored under it
|
|
186
|
+
* so radar/satellite/MRMS draws above the alert fill. Without weather, falls back under state outlines when present.
|
|
187
|
+
* @returns {string | null}
|
|
188
|
+
*/
|
|
189
|
+
_resolveFillAnchorLayerId() {
|
|
190
|
+
const m = this.map;
|
|
191
|
+
const styleLayers = m.getStyle()?.layers;
|
|
192
|
+
if (!styleLayers) {
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const manual = this.options.fillBeforeLayerId;
|
|
197
|
+
if (manual != null && String(manual) !== '' && m.getLayer(String(manual))) {
|
|
198
|
+
return String(manual);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const layerIndex = (id) => styleLayers.findIndex((l) => l.id === id);
|
|
202
|
+
const weatherId = this.options.weatherLayerId;
|
|
203
|
+
const statesId = NWS_DEFAULT_LINE_BEFORE_LAYER_ID;
|
|
204
|
+
|
|
205
|
+
if (weatherId && m.getLayer(weatherId)) {
|
|
206
|
+
return weatherId;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const iS = m.getLayer(statesId) ? layerIndex(statesId) : -1;
|
|
210
|
+
if (iS >= 0) {
|
|
211
|
+
return statesId;
|
|
212
|
+
}
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Stack: alert fill below weather; NEXRAD/satellite/MRMS below alert lines.
|
|
218
|
+
* When weather is present, lines sit directly under whatever layer is above the weather layer (often state outlines).
|
|
219
|
+
*/
|
|
220
|
+
_applyLayerOrder() {
|
|
221
|
+
const m = this.map;
|
|
222
|
+
if (!m?.getStyle?.() || !m.getLayer(this._fillId)) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
const weatherId = this.options.weatherLayerId;
|
|
226
|
+
const statesFallback = this.options.lineBeforeLayerId || NWS_DEFAULT_LINE_BEFORE_LAYER_ID;
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
const fillAnchor = this._resolveFillAnchorLayerId();
|
|
230
|
+
if (fillAnchor) {
|
|
231
|
+
m.moveLayer(this._fillId, fillAnchor);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const layers = m.getStyle()?.layers ?? [];
|
|
235
|
+
let lineBeforeId = null;
|
|
236
|
+
if (weatherId && m.getLayer(weatherId)) {
|
|
237
|
+
const iw = layers.findIndex((l) => l.id === weatherId);
|
|
238
|
+
if (iw >= 0 && iw < layers.length - 1) {
|
|
239
|
+
lineBeforeId = layers[iw + 1].id;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
if (!lineBeforeId && m.getLayer(statesFallback)) {
|
|
243
|
+
lineBeforeId = statesFallback;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (lineBeforeId && m.getLayer(this._lineCasingId) && m.getLayer(lineBeforeId)) {
|
|
247
|
+
m.moveLayer(this._lineCasingId, lineBeforeId);
|
|
248
|
+
}
|
|
249
|
+
if (lineBeforeId && m.getLayer(this._lineCoreId) && m.getLayer(lineBeforeId)) {
|
|
250
|
+
m.moveLayer(this._lineCoreId, lineBeforeId);
|
|
251
|
+
}
|
|
252
|
+
} catch {
|
|
253
|
+
/* style still settling */
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* @param {boolean} enabled - Master switch; when false, stream is torn down and layers cleared.
|
|
259
|
+
*/
|
|
260
|
+
setEnabled(enabled) {
|
|
261
|
+
this.options.enabled = !!enabled;
|
|
262
|
+
this.syncWithMode({
|
|
263
|
+
enabled: this.options.enabled,
|
|
264
|
+
timelineUnix: this._timelineUnix,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Whether clicking alerts emits `nws:alert:click` on the map.
|
|
270
|
+
* @param {boolean} on
|
|
271
|
+
*/
|
|
272
|
+
setAlertInteractionEnabled(on) {
|
|
273
|
+
this.options.alertInteractionEnabled = !!on;
|
|
274
|
+
this._syncClickHandler();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Timeline unix (satellite / MRMS / NEXRAD slider) for time-of-day filtering.
|
|
279
|
+
* @param {number | null | undefined} unixSeconds
|
|
280
|
+
*/
|
|
281
|
+
setTimelineUnix(unixSeconds) {
|
|
282
|
+
this._timelineUnix = unixSeconds;
|
|
283
|
+
this._applyTimeFilters();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
_userColors() {
|
|
287
|
+
return this._nwsAlertSettings.colors ?? {};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
_lineStyles() {
|
|
291
|
+
return this._nwsAlertSettings.lineStyles ?? {};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
_flashStyles() {
|
|
295
|
+
return this._nwsAlertSettings.flashStyles ?? {};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
_isFollowingLatestTimelineAnchor() {
|
|
299
|
+
const current = this._timelineUnix;
|
|
300
|
+
if (current == null || !Number.isFinite(current)) return false;
|
|
301
|
+
const times = this._timelineTimes;
|
|
302
|
+
if (!Array.isArray(times) || times.length === 0) return false;
|
|
303
|
+
let latest = -Infinity;
|
|
304
|
+
for (const t of times) {
|
|
305
|
+
if (typeof t === 'number' && Number.isFinite(t) && t > latest) {
|
|
306
|
+
latest = t;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
if (!Number.isFinite(latest)) return false;
|
|
310
|
+
return Math.abs(current - latest) <= 1;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Matches aguacero-frontend {@link WatchesWarningsLayer.resolveNwsDisplayTimeMode}: at the last slider
|
|
315
|
+
* step, use wall clock so SSE/stream updates appear even when the timeline tick lags.
|
|
316
|
+
*/
|
|
317
|
+
_resolveDisplayTimeMode() {
|
|
318
|
+
const userPinnedRealtime = this._nwsAlertSettings.activeOnlyRealtime === true;
|
|
319
|
+
const followRealtimeAtLatestAnchor = !userPinnedRealtime && this._isFollowingLatestTimelineAnchor();
|
|
320
|
+
const useRealtime = userPinnedRealtime || followRealtimeAtLatestAnchor;
|
|
321
|
+
return {
|
|
322
|
+
useRealtime,
|
|
323
|
+
timelineUnix: useRealtime ? undefined : this._timelineUnix,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
_applyTimeFilters() {
|
|
328
|
+
const m = this.map;
|
|
329
|
+
if (!m?.getSource?.(this._sourceId)) return;
|
|
330
|
+
|
|
331
|
+
const timeMode = this._resolveDisplayTimeMode();
|
|
332
|
+
const referenceUnix = timeMode.useRealtime
|
|
333
|
+
? Math.floor(Date.now() / 1000)
|
|
334
|
+
: timeMode.timelineUnix;
|
|
335
|
+
const useOnsetWindow = this._nwsAlertSettings.activeOnlyRealtime === true;
|
|
336
|
+
|
|
337
|
+
/** @type {Map<number, boolean>} */
|
|
338
|
+
const tentativeActive = new Map();
|
|
339
|
+
|
|
340
|
+
for (const f of this._workingFc.features ?? []) {
|
|
341
|
+
if (f.id == null) continue;
|
|
342
|
+
const fid = typeof f.id === 'number' ? f.id : Number(f.id);
|
|
343
|
+
if (!Number.isFinite(fid)) continue;
|
|
344
|
+
|
|
345
|
+
let isActive = true;
|
|
346
|
+
if (referenceUnix != null && Number.isFinite(referenceUnix)) {
|
|
347
|
+
const p = f.properties ?? {};
|
|
348
|
+
const rev = nwsRevisionVisibilityActive(p, referenceUnix);
|
|
349
|
+
if (rev === false) {
|
|
350
|
+
isActive = false;
|
|
351
|
+
} else {
|
|
352
|
+
const startWindow = useOnsetWindow ? (p.active_start_unix ?? p.start_unix) : p.start_unix;
|
|
353
|
+
if (startWindow != null && referenceUnix < startWindow) {
|
|
354
|
+
isActive = false;
|
|
355
|
+
} else if (p.end_unix != null && referenceUnix > p.end_unix) {
|
|
356
|
+
isActive = false;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
tentativeActive.set(fid, isActive);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (referenceUnix != null && Number.isFinite(referenceUnix)) {
|
|
365
|
+
const keyToFeatures = new globalThis.Map();
|
|
366
|
+
for (const f of this._workingFc.features ?? []) {
|
|
367
|
+
if (f.id == null) continue;
|
|
368
|
+
const fid = typeof f.id === 'number' ? f.id : Number(f.id);
|
|
369
|
+
if (!Number.isFinite(fid) || !tentativeActive.get(fid)) continue;
|
|
370
|
+
const pk = getNwwsProductKeyFromFeature(f);
|
|
371
|
+
if (!pk) continue;
|
|
372
|
+
let bucket = keyToFeatures.get(pk);
|
|
373
|
+
if (!bucket) {
|
|
374
|
+
bucket = [];
|
|
375
|
+
keyToFeatures.set(pk, bucket);
|
|
376
|
+
}
|
|
377
|
+
bucket.push(f);
|
|
378
|
+
}
|
|
379
|
+
for (const [, group] of keyToFeatures) {
|
|
380
|
+
if (group.length <= 1) continue;
|
|
381
|
+
const winner = pickBestNwsRevisionAmongConflictingFeatures(group, referenceUnix);
|
|
382
|
+
const winId = typeof winner?.id === 'number' ? winner.id : Number(winner?.id);
|
|
383
|
+
for (const f of group) {
|
|
384
|
+
const gfid = typeof f.id === 'number' ? f.id : Number(f.id);
|
|
385
|
+
if (Number.isFinite(gfid) && Number.isFinite(winId) && gfid !== winId) {
|
|
386
|
+
tentativeActive.set(gfid, false);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
for (const f of this._workingFc.features ?? []) {
|
|
393
|
+
if (f.id == null) continue;
|
|
394
|
+
const fid = typeof f.id === 'number' ? f.id : Number(f.id);
|
|
395
|
+
if (!Number.isFinite(fid)) continue;
|
|
396
|
+
const isActive = tentativeActive.get(fid) ?? true;
|
|
397
|
+
try {
|
|
398
|
+
m.setFeatureState({ source: this._sourceId, id: f.id }, { active: isActive });
|
|
399
|
+
} catch {
|
|
400
|
+
/* ignore */
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
_buildPaint() {
|
|
406
|
+
const settings = this._nwsAlertSettings;
|
|
407
|
+
const visible = this.options.enabled !== false;
|
|
408
|
+
const fillOpacity = Number(this.options.fillOpacity ?? 1);
|
|
409
|
+
const lineOpacity = Number(this.options.lineOpacity ?? 0.9);
|
|
410
|
+
const lineWidth = Number(this.options.lineWidth ?? 2);
|
|
411
|
+
|
|
412
|
+
const userColors = this._userColors();
|
|
413
|
+
const exprKeys = collectNwsExpressionEventKeys(settings);
|
|
414
|
+
const fillColorExpr = buildNwsFillColorExpression(userColors, exprKeys);
|
|
415
|
+
const dualLine = buildNwsDualLinePaint(userColors, this._lineStyles(), lineWidth, exprKeys);
|
|
416
|
+
const lineDashExpr = buildNwsLineDasharrayExpression(this._lineStyles(), settings.lineDash ?? 'solid', exprKeys);
|
|
417
|
+
|
|
418
|
+
const baseFillOpacityExpr = buildWarningsFillOpacityExpr(
|
|
419
|
+
visible,
|
|
420
|
+
fillOpacity,
|
|
421
|
+
settings.fillHidden,
|
|
422
|
+
settings.fillOpacity,
|
|
423
|
+
exprKeys
|
|
424
|
+
);
|
|
425
|
+
const baseLineOpacityExpr = buildWarningsLineOpacityExpr(visible, lineOpacity, settings.lineHidden, exprKeys);
|
|
426
|
+
|
|
427
|
+
const fillOpacityExpr = ['case', ['boolean', ['feature-state', 'active'], false], baseFillOpacityExpr, 0];
|
|
428
|
+
const lineOpacityExpr = ['case', ['boolean', ['feature-state', 'active'], false], baseLineOpacityExpr, 0];
|
|
429
|
+
|
|
430
|
+
return { fillColorExpr, dualLine, lineDashExpr, fillOpacityExpr, lineOpacityExpr, lineLayout: {
|
|
431
|
+
'line-cap': 'round',
|
|
432
|
+
'line-join': 'round',
|
|
433
|
+
'line-sort-key': ['coalesce', ['get', '_nws_render_priority'], 0],
|
|
434
|
+
} };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
_refreshPaint() {
|
|
438
|
+
const m = this.map;
|
|
439
|
+
if (!m?.getLayer?.(this._fillId)) return;
|
|
440
|
+
const p = this._buildPaint();
|
|
441
|
+
m.setPaintProperty(this._fillId, 'fill-color', p.fillColorExpr);
|
|
442
|
+
m.setPaintProperty(this._fillId, 'fill-opacity', p.fillOpacityExpr);
|
|
443
|
+
m.setPaintProperty(this._lineCasingId, 'line-color', p.dualLine.casingColor);
|
|
444
|
+
m.setPaintProperty(this._lineCasingId, 'line-width', p.dualLine.casingWidth);
|
|
445
|
+
m.setPaintProperty(this._lineCasingId, 'line-opacity', p.lineOpacityExpr);
|
|
446
|
+
m.setPaintProperty(this._lineCasingId, 'line-dasharray', p.lineDashExpr);
|
|
447
|
+
m.setPaintProperty(this._lineCoreId, 'line-color', p.dualLine.coreColor);
|
|
448
|
+
m.setPaintProperty(this._lineCoreId, 'line-width', p.dualLine.coreWidth);
|
|
449
|
+
m.setPaintProperty(this._lineCoreId, 'line-opacity', p.lineOpacityExpr);
|
|
450
|
+
m.setPaintProperty(this._lineCoreId, 'line-dasharray', p.lineDashExpr);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Re-apply `alertScope` / `includedAlerts` against the last full pre-scope snapshot (no Mapbox `disabled` filter).
|
|
455
|
+
*/
|
|
456
|
+
_resyncSourceDataFilters() {
|
|
457
|
+
const src = this.map?.getSource?.(this._sourceId);
|
|
458
|
+
if (!src || typeof src.setData !== 'function') return;
|
|
459
|
+
if (!this._preScopeFc?.features?.length) return;
|
|
460
|
+
const scope = this._nwsAlertSettings.alertScope ?? 'all';
|
|
461
|
+
const included = this._nwsAlertSettings.includedAlerts ?? [];
|
|
462
|
+
const next = normalizeFeatureCollectionForMapboxGl(
|
|
463
|
+
filterNwsAlertsByScope(this._preScopeFc, scope, included)
|
|
464
|
+
);
|
|
465
|
+
this._workingFc = next;
|
|
466
|
+
try {
|
|
467
|
+
src.setData(next);
|
|
468
|
+
} catch {
|
|
469
|
+
/* ignore */
|
|
470
|
+
}
|
|
471
|
+
this._applyTimeFilters();
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
_ensureLayers() {
|
|
475
|
+
const m = this.map;
|
|
476
|
+
if (!m?.addSource) return;
|
|
477
|
+
|
|
478
|
+
const paint = this._buildPaint();
|
|
479
|
+
|
|
480
|
+
const sourceData = normalizeFeatureCollectionForMapboxGl(this._workingFc);
|
|
481
|
+
if (!m.getSource(this._sourceId)) {
|
|
482
|
+
m.addSource(this._sourceId, {
|
|
483
|
+
type: 'geojson',
|
|
484
|
+
data: sourceData,
|
|
485
|
+
});
|
|
486
|
+
} else {
|
|
487
|
+
m.getSource(this._sourceId).setData(sourceData);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const fillLayer = {
|
|
491
|
+
id: this._fillId,
|
|
492
|
+
type: 'fill',
|
|
493
|
+
source: this._sourceId,
|
|
494
|
+
layout: { 'fill-sort-key': ['coalesce', ['get', '_nws_render_priority'], 0] },
|
|
495
|
+
paint: {
|
|
496
|
+
'fill-color': paint.fillColorExpr,
|
|
497
|
+
'fill-opacity': paint.fillOpacityExpr,
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
const casingLayer = {
|
|
501
|
+
id: this._lineCasingId,
|
|
502
|
+
type: 'line',
|
|
503
|
+
source: this._sourceId,
|
|
504
|
+
layout: paint.lineLayout,
|
|
505
|
+
paint: {
|
|
506
|
+
'line-color': paint.dualLine.casingColor,
|
|
507
|
+
'line-width': paint.dualLine.casingWidth,
|
|
508
|
+
'line-opacity': paint.lineOpacityExpr,
|
|
509
|
+
'line-dasharray': paint.lineDashExpr,
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
const coreLayer = {
|
|
513
|
+
id: this._lineCoreId,
|
|
514
|
+
type: 'line',
|
|
515
|
+
source: this._sourceId,
|
|
516
|
+
layout: paint.lineLayout,
|
|
517
|
+
paint: {
|
|
518
|
+
'line-color': paint.dualLine.coreColor,
|
|
519
|
+
'line-width': paint.dualLine.coreWidth,
|
|
520
|
+
'line-opacity': paint.lineOpacityExpr,
|
|
521
|
+
'line-dasharray': paint.lineDashExpr,
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
|
|
525
|
+
const fillAnchor = this._resolveFillAnchorLayerId();
|
|
526
|
+
const lineAnchorId = this.options.lineBeforeLayerId || NWS_DEFAULT_LINE_BEFORE_LAYER_ID;
|
|
527
|
+
const lineBefore = m.getLayer(lineAnchorId) ? lineAnchorId : undefined;
|
|
528
|
+
|
|
529
|
+
if (!m.getLayer(this._fillId)) {
|
|
530
|
+
m.addLayer(fillLayer, fillAnchor ?? undefined);
|
|
531
|
+
}
|
|
532
|
+
if (!m.getLayer(this._lineCasingId)) {
|
|
533
|
+
m.addLayer(casingLayer, lineBefore);
|
|
534
|
+
}
|
|
535
|
+
if (!m.getLayer(this._lineCoreId)) {
|
|
536
|
+
m.addLayer(coreLayer, lineBefore);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
this._applyTimeFilters();
|
|
540
|
+
this._syncClickHandler();
|
|
541
|
+
this._applyLayerOrder();
|
|
542
|
+
requestAnimationFrame(() => this._applyLayerOrder());
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
_removeLayers() {
|
|
546
|
+
const m = this.map;
|
|
547
|
+
if (!m) return;
|
|
548
|
+
for (const id of [this._lineCoreId, this._lineCasingId, this._fillId]) {
|
|
549
|
+
if (m.getLayer(id)) {
|
|
550
|
+
m.removeLayer(id);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (m.getSource(this._sourceId)) {
|
|
554
|
+
m.removeSource(this._sourceId);
|
|
555
|
+
}
|
|
556
|
+
this._workingFc = { type: 'FeatureCollection', features: [] };
|
|
557
|
+
this._preScopeFc = { type: 'FeatureCollection', features: [] };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
_syncClickHandler() {
|
|
561
|
+
const m = this.map;
|
|
562
|
+
if (!m) return;
|
|
563
|
+
m.off('click', this._boundClick);
|
|
564
|
+
if (this.options.alertInteractionEnabled && this.options.enabled && m.getLayer(this._fillId)) {
|
|
565
|
+
m.on('click', this._boundClick);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
_onMapClick(e) {
|
|
570
|
+
if (!this.options.alertInteractionEnabled) return;
|
|
571
|
+
const m = this.map;
|
|
572
|
+
const layers = [this._fillId, this._lineCasingId, this._lineCoreId].filter((id) => m.getLayer(id));
|
|
573
|
+
if (!layers.length) return;
|
|
574
|
+
const feats = m.queryRenderedFeatures(e.point, { layers });
|
|
575
|
+
const hit = feats.find((f) => f.source === this._sourceId);
|
|
576
|
+
if (!hit) return;
|
|
577
|
+
try {
|
|
578
|
+
const payload = buildAlertClickPayload(hit);
|
|
579
|
+
const layerId = hit.layer && typeof hit.layer === 'object' ? hit.layer.id : hit.layer;
|
|
580
|
+
m.fire('nws:alert:click', {
|
|
581
|
+
originalEvent: e,
|
|
582
|
+
featureSummary: { id: hit.id, source: hit.source, layerId },
|
|
583
|
+
...payload,
|
|
584
|
+
feature: hit,
|
|
585
|
+
});
|
|
586
|
+
} catch {
|
|
587
|
+
/* ignore */
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
_normalizeSnapshot(fc) {
|
|
592
|
+
const pre = filterNwwsTerminalStatusFeatures(enrichWarningsWithColors(fc));
|
|
593
|
+
this._preScopeFc = pre;
|
|
594
|
+
const scope = this._nwsAlertSettings.alertScope ?? 'all';
|
|
595
|
+
const included = this._nwsAlertSettings.includedAlerts ?? [];
|
|
596
|
+
return filterNwsAlertsByScope(pre, scope, included);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
_setWorkingData(fc) {
|
|
600
|
+
this._workingFc = cloneNwwsFeatureCollectionStrippingVolatile(fc);
|
|
601
|
+
const src = this.map?.getSource?.(this._sourceId);
|
|
602
|
+
if (src) {
|
|
603
|
+
src.setData(this._workingFc);
|
|
604
|
+
}
|
|
605
|
+
this._applyTimeFilters();
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async _fetchBaseline() {
|
|
609
|
+
const url = this.getAlertsUrl();
|
|
610
|
+
const res = await fetch(url);
|
|
611
|
+
if (!res.ok) throw new Error(`NWS alerts HTTP ${res.status}`);
|
|
612
|
+
const parsed = await res.json();
|
|
613
|
+
const geojson = unwrapNwwsFeatureCollectionRoot(parsed);
|
|
614
|
+
if (!geojson) return null;
|
|
615
|
+
return this._normalizeSnapshot(geojson);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
_processInboundSocketText(text) {
|
|
619
|
+
let msg;
|
|
620
|
+
try {
|
|
621
|
+
msg = JSON.parse(text);
|
|
622
|
+
} catch {
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
if (!msg || typeof msg !== 'object') return;
|
|
626
|
+
if (msg.type === 'ping') return;
|
|
627
|
+
|
|
628
|
+
if (msg.type === 'alert' && msg.alert != null && typeof msg.alert === 'object') {
|
|
629
|
+
const raw = msg.alert;
|
|
630
|
+
const feature =
|
|
631
|
+
raw.type === 'Feature'
|
|
632
|
+
? raw
|
|
633
|
+
: {
|
|
634
|
+
type: 'Feature',
|
|
635
|
+
id: raw.id,
|
|
636
|
+
geometry: raw.geometry,
|
|
637
|
+
properties: raw.properties ?? {},
|
|
638
|
+
};
|
|
639
|
+
this._processInboundSocketText(JSON.stringify(normalizeNwwsHttpAlertFeature(feature, true)));
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (msg.type === 'history' && Array.isArray(msg.alerts)) {
|
|
644
|
+
const fc = {
|
|
645
|
+
type: 'FeatureCollection',
|
|
646
|
+
features: msg.alerts.map((f) => normalizeNwwsHttpAlertFeature(f, true)),
|
|
647
|
+
};
|
|
648
|
+
const next = this._normalizeSnapshot(fc);
|
|
649
|
+
this._workingFc = next;
|
|
650
|
+
if (this.map?.getSource?.(this._sourceId)) {
|
|
651
|
+
this._setWorkingData(next);
|
|
652
|
+
}
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
if (Array.isArray(msg.alerts)) {
|
|
657
|
+
const normalized = msg.alerts.map((f) =>
|
|
658
|
+
f?.type === 'Feature' ? normalizeNwwsHttpAlertFeature(f, true) : f
|
|
659
|
+
);
|
|
660
|
+
this._processInboundSocketText(JSON.stringify({ upserts: normalized }));
|
|
661
|
+
return;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
if (msg.type === 'Feature') {
|
|
665
|
+
this._processInboundSocketText(JSON.stringify({ upsert: [msg] }));
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
const maybeFeature = msg.feature;
|
|
670
|
+
if (maybeFeature && typeof maybeFeature === 'object' && maybeFeature.type === 'Feature') {
|
|
671
|
+
this._processInboundSocketText(
|
|
672
|
+
JSON.stringify({
|
|
673
|
+
...msg,
|
|
674
|
+
feature: normalizeNwwsHttpAlertFeature(maybeFeature, true),
|
|
675
|
+
})
|
|
676
|
+
);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
const delta = parseNwwsWsDelta(msg);
|
|
681
|
+
if (delta.upserts.length || delta.cancelIds.length || delta.expireIds.length) {
|
|
682
|
+
this._pendingDeltas.push(delta);
|
|
683
|
+
this._scheduleDeltaProcessing();
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const fcSnapshot = unwrapNwwsFeatureCollectionRoot(msg);
|
|
688
|
+
if (fcSnapshot) {
|
|
689
|
+
const next = this._normalizeSnapshot(fcSnapshot);
|
|
690
|
+
this._workingFc = next;
|
|
691
|
+
if (this.map?.getSource?.(this._sourceId)) {
|
|
692
|
+
this._setWorkingData(next);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
_scheduleDeltaProcessing() {
|
|
698
|
+
if (this._deltaTimer != null) return;
|
|
699
|
+
const ms = Math.max(0, Number(this.options.deltaDebounceMs) || 500);
|
|
700
|
+
this._deltaTimer = setTimeout(() => {
|
|
701
|
+
this._deltaTimer = null;
|
|
702
|
+
this._flushDeltas();
|
|
703
|
+
}, ms);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
_flushDeltas() {
|
|
707
|
+
if (this._destroyed) return;
|
|
708
|
+
if (!this._pendingDeltas.length) return;
|
|
709
|
+
const batches = this._pendingDeltas.splice(0, this._pendingDeltas.length);
|
|
710
|
+
let merged =
|
|
711
|
+
this._preScopeFc?.features?.length > 0 ? this._preScopeFc : this._workingFc;
|
|
712
|
+
for (const d of batches) {
|
|
713
|
+
merged = applyNwwsDeltaToCollection(merged, d);
|
|
714
|
+
}
|
|
715
|
+
const next = this._normalizeSnapshot(merged);
|
|
716
|
+
this._workingFc = next;
|
|
717
|
+
if (this.map?.getSource?.(this._sourceId)) {
|
|
718
|
+
this._setWorkingData(next);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
_tearDownStream() {
|
|
723
|
+
if (this._eventSource) {
|
|
724
|
+
try {
|
|
725
|
+
const es = this._eventSource;
|
|
726
|
+
es.onopen = null;
|
|
727
|
+
es.onmessage = null;
|
|
728
|
+
es.onerror = null;
|
|
729
|
+
if (this._sseInboundHandler && this._sseBoundEventNames) {
|
|
730
|
+
for (const name of this._sseBoundEventNames) {
|
|
731
|
+
es.removeEventListener(name, this._sseInboundHandler);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
this._sseInboundHandler = null;
|
|
735
|
+
this._sseBoundEventNames = null;
|
|
736
|
+
es.close();
|
|
737
|
+
} catch {
|
|
738
|
+
/* ignore */
|
|
739
|
+
}
|
|
740
|
+
this._eventSource = null;
|
|
741
|
+
}
|
|
742
|
+
if (this._deltaTimer != null) {
|
|
743
|
+
clearTimeout(this._deltaTimer);
|
|
744
|
+
this._deltaTimer = null;
|
|
745
|
+
}
|
|
746
|
+
this._pendingDeltas = [];
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
async _initialLoad() {
|
|
750
|
+
if (this._destroyed || !this.options.enabled) return;
|
|
751
|
+
try {
|
|
752
|
+
const baseline = await this._fetchBaseline();
|
|
753
|
+
if (baseline) {
|
|
754
|
+
this._workingFc = baseline;
|
|
755
|
+
}
|
|
756
|
+
} catch (err) {
|
|
757
|
+
console.warn('[NwsWatchesWarningsOverlay] Baseline fetch failed:', err);
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const paintWhenReady = () => {
|
|
761
|
+
this._ensureLayers();
|
|
762
|
+
this._setWorkingData(this._workingFc);
|
|
763
|
+
};
|
|
764
|
+
|
|
765
|
+
if (!this.map.isStyleLoaded()) {
|
|
766
|
+
this.map.once('style.load', paintWhenReady);
|
|
767
|
+
} else {
|
|
768
|
+
paintWhenReady();
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
this._connectSse();
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
_connectSse() {
|
|
775
|
+
if (this._destroyed || !this.options.enabled) return;
|
|
776
|
+
if (typeof EventSource === 'undefined') {
|
|
777
|
+
console.warn('[NwsWatchesWarningsOverlay] EventSource not available; live stream disabled.');
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
if (this._eventSource) return;
|
|
781
|
+
|
|
782
|
+
const url = this.getStreamUrl();
|
|
783
|
+
try {
|
|
784
|
+
const es = new EventSource(url);
|
|
785
|
+
this._eventSource = es;
|
|
786
|
+
es.onopen = () => {
|
|
787
|
+
if (!this._sseFirstOpen) {
|
|
788
|
+
void this._fetchBaseline().then((fresh) => {
|
|
789
|
+
if (fresh && !this._destroyed) {
|
|
790
|
+
this._workingFc = fresh;
|
|
791
|
+
this._setWorkingData(fresh);
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
this._sseFirstOpen = false;
|
|
796
|
+
};
|
|
797
|
+
this._sseInboundHandler = (ev) => {
|
|
798
|
+
if (typeof ev.data === 'string' && ev.data && !ev.data.startsWith(':')) {
|
|
799
|
+
this._processInboundSocketText(ev.data);
|
|
800
|
+
}
|
|
801
|
+
};
|
|
802
|
+
this._sseBoundEventNames = ['message', 'alert', 'update', 'delta', 'patch', 'data'];
|
|
803
|
+
for (const name of this._sseBoundEventNames) {
|
|
804
|
+
es.addEventListener(name, this._sseInboundHandler);
|
|
805
|
+
}
|
|
806
|
+
es.onerror = () => {
|
|
807
|
+
if (this._eventSource?.readyState === EventSource.CLOSED) {
|
|
808
|
+
this._tearDownStream();
|
|
809
|
+
this._sseFirstOpen = true;
|
|
810
|
+
setTimeout(() => {
|
|
811
|
+
if (this.options.enabled && !this._destroyed) {
|
|
812
|
+
this._connectSse();
|
|
813
|
+
}
|
|
814
|
+
}, 2000);
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
} catch (e) {
|
|
818
|
+
console.warn('[NwsWatchesWarningsOverlay] SSE connect failed:', e);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* Called by WeatherLayerManager when radar/satellite/MRMS mode is active.
|
|
824
|
+
* @param {object} opts
|
|
825
|
+
* @param {boolean} opts.enabled
|
|
826
|
+
* @param {number | null | undefined} opts.timelineUnix - Active scrubber unix for satellite / MRMS / NEXRAD.
|
|
827
|
+
* @param {number[] | null | undefined} [opts.timelineTimes] - Full timeline for the current layer (for “live edge” = wall clock).
|
|
828
|
+
*/
|
|
829
|
+
syncWithMode({ enabled, timelineUnix, timelineTimes }) {
|
|
830
|
+
if (this._destroyed) return;
|
|
831
|
+
this.options.enabled = !!enabled;
|
|
832
|
+
if (timelineTimes !== undefined) {
|
|
833
|
+
this._timelineTimes = Array.isArray(timelineTimes) ? timelineTimes : null;
|
|
834
|
+
}
|
|
835
|
+
this.setTimelineUnix(timelineUnix);
|
|
836
|
+
if (!this.options.enabled) {
|
|
837
|
+
this._started = false;
|
|
838
|
+
this._sseFirstOpen = true;
|
|
839
|
+
this._tearDownStream();
|
|
840
|
+
this._removeLayers();
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
if (!this._started) {
|
|
844
|
+
this._started = true;
|
|
845
|
+
void this._initialLoad();
|
|
846
|
+
} else {
|
|
847
|
+
this._applyTimeFilters();
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
destroy() {
|
|
852
|
+
this._destroyed = true;
|
|
853
|
+
this._started = false;
|
|
854
|
+
this.map?.off?.('click', this._boundClick);
|
|
855
|
+
this._tearDownStream();
|
|
856
|
+
this._removeLayers();
|
|
857
|
+
}
|
|
858
|
+
}
|