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