diff --git a/web/src/layers/alerts.tsx b/web/src/layers/alerts.tsx index 0da6fc3..c10d633 100644 --- a/web/src/layers/alerts.tsx +++ b/web/src/layers/alerts.tsx @@ -1,13 +1,18 @@ import type { Color } from '@deck.gl/core'; import { GeoJsonLayer } from '@deck.gl/layers'; import type { Geometry } from '../generated/geojson'; -import type { AlertProps } from '../generated/weather'; +import type { AlertProps, CityTileIndex } from '../generated/weather'; import { age, type WeatherFc } from '../weather'; import { Swatch } from './swatch'; import type { WeatherLayer } from './types'; type AlertFc = WeatherFc; +/** Alerts plus the timeline axis (the citytile snapshot) so the layer can show + * only the alerts in effect at the map-wide forecast time, like every other + * time-aware layer. */ +type AlertData = { fc: AlertFc; axis: CityTileIndex | null }; + const FILL: Record = { Extreme: [168, 0, 90, 80], Severe: [220, 60, 30, 70], @@ -24,29 +29,67 @@ const LINE: Record = { Unknown: [110, 110, 110, 220], }; -export const alerts: WeatherLayer = { +/** Wall-clock ms the timeline currently points at, or null before the axis + * loads (in which case we don't filter — show all active alerts). */ +function validMs(axis: CityTileIndex | null, time: number): number | null { + return axis ? axis.snapshotMs + time * 3_600_000 : null; +} + +/** Alerts in effect at `at` (ms). A null onset/expires is treated as unbounded + * on that side; a null `at` means "don't filter". NWS `/alerts/active` only + * returns currently-active alerts, so scrubbing forward expires them and any + * future-onset alert in the feed appears at its onset. */ +function activeAt( + features: AlertFc['features'], + at: number | null, +): AlertFc['features'] { + if (at == null) return features; + return features.filter((f) => { + const onset = f.properties.onset ? Date.parse(f.properties.onset) : NaN; + const expires = f.properties.expires + ? Date.parse(f.properties.expires) + : NaN; + if (!Number.isNaN(onset) && at < onset) return false; + if (!Number.isNaN(expires) && at > expires) return false; + return true; + }); +} + +export const alerts: WeatherLayer = { id: 'alerts', - label: (fc) => `NWS alerts${fc ? ` (${fc.features.length})` : ''}`, + label: (d) => `NWS alerts${d ? ` (${d.fc.features.length})` : ''}`, legend: , defaultVisible: true, - select: (w) => w.alerts, - build: (fc) => [ - new GeoJsonLayer({ - id: 'alerts', - data: fc as any, - pickable: true, - autoHighlight: true, - highlightColor: [255, 255, 255, 40], - stroked: true, - filled: true, - lineWidthMinPixels: 1.5, - getFillColor: (f: any) => FILL[f.properties.severity] ?? FILL.Unknown, - getLineColor: (f: any) => LINE[f.properties.severity] ?? LINE.Unknown, - }), - ], - controls: (_ctx, fc) => ( -
{age(fc?.generated_ms)}
- ), + select: (w) => (w.alerts ? { fc: w.alerts, axis: w.cityTiles } : null), + build: (d, ctx) => { + const features = activeAt(d.fc.features, validMs(d.axis, ctx.time)); + const fc = { ...d.fc, features }; + return [ + new GeoJsonLayer({ + id: 'alerts', + data: fc as any, + pickable: true, + autoHighlight: true, + highlightColor: [255, 255, 255, 40], + stroked: true, + filled: true, + lineWidthMinPixels: 1.5, + getFillColor: (f: any) => FILL[f.properties.severity] ?? FILL.Unknown, + getLineColor: (f: any) => LINE[f.properties.severity] ?? LINE.Unknown, + }), + ]; + }, + controls: (ctx, d) => { + if (!d) return null; + const total = d.fc.features.length; + const shown = activeAt(d.fc.features, validMs(d.axis, ctx.time)).length; + return ( +
+ {shown < total ? `${shown} of ${total} in effect · ` : ''} + {age(d.fc.generated_ms)} +
+ ); + }, tooltip: (o) => { const p = o?.properties; if (!p?.event) return null;