From 1a84bf46d718f7283fee3aec1c1545b27119c3ac Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Mon, 5 Oct 2026 03:46:05 -0400 Subject: [PATCH] Weather map layers: NWS warning polygons, n0q radar with valid time/legend/loop, wind at incident (#237) - Weather Warnings overlay (layer key `wx`, saved in the view hash): NWS alert polygons coloured by event (NWS hazard-map colours), outline dash by kind, popup with event/headline/expiry/office and National Weather Service credit. Polls GET /weather/alerts every 2 min while on and visible, refetches on pan. A status chip always says what the layer shows; feed errors use the 511 FeedProblem wording; zone-only alerts are listed, never dropped. - Radar: IEM nexrad-n0r -> n0q. Valid-time badge from IEM's n0q_0.json, STALE past 15 min or when the time can't be read; dBZ legend; optional 1 h loop (nexrad-n0q-900913-m05m..m55m), off by default. Still refreshed in place, never remounted. - liveWall: sampleRadarDbz reads the n0q tile with IEM's published 255-colour ramp (verified against live tiles); pointInPolygon/alertsAt, wxStyle, wantsWind helpers. - Wall incident panel: "inside " chips, wind arrow + speed/gust + station/obs time on fire/hazmat incidents; region Feeds gains an NWS row. Co-Authored-By: Claude Opus 5.5 --- drb-frontend/components/MapView.tsx | 275 ++++++++++++++++++-- drb-frontend/components/live/WallPanels.tsx | 136 +++++++++- drb-frontend/lib/c2api.ts | 11 + drb-frontend/lib/liveWall.ts | 261 +++++++++++++++++-- drb-frontend/lib/types.ts | 71 +++++ drb-frontend/lib/useWeather.ts | 154 +++++++++++ 6 files changed, 864 insertions(+), 44 deletions(-) create mode 100644 drb-frontend/lib/useWeather.ts diff --git a/drb-frontend/components/MapView.tsx b/drb-frontend/components/MapView.tsx index aae37ee..a01ca90 100644 --- a/drb-frontend/components/MapView.tsx +++ b/drb-frontend/components/MapView.tsx @@ -25,13 +25,16 @@ import { useAircraftTrail } from "@/lib/useAircraftTrail"; import { useVessels } from "@/lib/useVessels"; import { use511 } from "@/lib/use511"; import { useMapCameras } from "@/lib/useCameras"; -import type { Camera, CameraCredit, CameraProviderFeed, CamerasResponse, Ny511Event, Ny511FeedStatus } from "@/lib/types"; +import type { + Camera, CameraCredit, CameraProviderFeed, CamerasResponse, Ny511Event, Ny511FeedStatus, WeatherAlert, WeatherAlertsResponse, WeatherGeometry, +} from "@/lib/types"; +import { useMapWeatherAlerts, useRadarMeta, type Polled, type RadarMeta } from "@/lib/useWeather"; import { CameraFeed } from "@/components/CameraFeed"; import { SourceCredit, fetchedLabel } from "@/components/SourceCredit"; import { BASE_NAMES, DEFAULT_BASE, DEFAULT_LAYERS, LAYER_NAMES, NEAR_CAMERA_KM, baseKeyForName, groupCallsByIncident, MAY_BE_MERGED_KM, PATH_GAP_DASH_MIN, incidentPoints, layerKeyForName, normaliseLayers, pathStops, roundViewport, - viewshedWedge, worstFirst, + viewshedWedge, worstFirst, RADAR_LOOP_OFFSETS_MIN, RADAR_STALE_MIN, n0qColor, radarTileUrl, wxStyle, wxTime, WX_KIND_DASH, type BaseKey, type LayerKey, type MapViewport, } from "@/lib/liveWall"; import { OutageLayer, outageGlyphSvg } from "@/components/map/OutageLayer"; @@ -326,27 +329,198 @@ function VesselLayer() { ); } -// ── Weather radar ──────────────────────────────────────────────────────────── +// ── Weather radar (IEM n0q; server-26#237) ─────────────────────────────────── // IEM tiles are static once loaded, so refresh them in place with a // cache-buster. Never remount the layer (e.g. via `key`) to refresh it: a // remounted child of an unchecked LayersControl.Overlay is re-registered with // the control but not re-added to the map, which switched radar off every 5 min. -const RADAR_URL = "https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0r-900913/{z}/{x}/{y}.png"; +// The layers live in a FeatureGroup that never remounts; the loop frames are +// that group's children, so adding/removing them is ordinary group membership. +// The buster is the composite's valid time when known (so tiles, badge and +// incident sample move together), else a 5-min clock. const RADAR_REFRESH_MS = 5 * 60 * 1000; +const RADAR_OPACITY = 0.65; +const RADAR_ATTRIBUTION = 'Radar © IEM/NWS'; +const LOOP_FRAME_MS = 600; +const LOOP_HOLD_FRAMES = 3; // the newest frame is held this many ticks before the loop restarts +const RADAR_LEGEND_DBZ = [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75]; +const RADAR_LEGEND_TICKS = [5, 40, 75]; // evenly spaced over RADAR_LEGEND_DBZ, so justify-between places them right -function RadarLayer() { - const ref = useRef(null); +function radarBuster(valid: Date | null, clock: number): string { + return `?_=${valid ? valid.getTime() : clock}`; +} + +/** + * One n0q tile layer. react-leaflet 4 applies a changed `url` with setUrl on + * the same layer (no remount), which is exactly the in-place refresh above. + */ +function RadarFrame({ offsetMin, buster, opacity }: { offsetMin: number; buster: string; opacity: number }) { + return ; +} + +/** + * Current composite, plus — while `loop` is on — the 11 earlier 5-min frames. + * All frames stay loaded; only the shown one is opaque, so the loop plays + * without re-fetching tiles every cycle. + */ +function RadarLayer({ valid, loop, frame }: { valid: Date | null; loop: boolean; frame: number }) { + const [clock, setClock] = useState(() => Math.floor(Date.now() / RADAR_REFRESH_MS)); useEffect(() => { - const id = setInterval(() => ref.current?.setUrl(`${RADAR_URL}?_=${Date.now()}`), RADAR_REFRESH_MS); + if (valid) return; // the valid time drives refreshes while it is known + const id = setInterval(() => setClock(Math.floor(Date.now() / RADAR_REFRESH_MS)), 30_000); + return () => clearInterval(id); + }, [valid]); + const buster = radarBuster(valid, clock); + const last = RADAR_LOOP_OFFSETS_MIN.length - 1; + return ( + <> + {loop && RADAR_LOOP_OFFSETS_MIN.slice(0, last).map((off, i) => ( + + ))} + = last ? RADAR_OPACITY : 0} /> + + ); +} + +function hhmmLocal(d: Date): string { + return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); +} + +/** Radar valid-time badge + loop toggle: what time the map's radar shows, and whether that is stale. */ +function RadarBadge({ meta, loop, frame, onLoop }: { + meta: RadarMeta; loop: boolean; frame: number; onLoop: (on: boolean) => void; +}) { + const [, tick] = useState(0); + useEffect(() => { + const id = setInterval(() => tick((t) => t + 1), 30_000); return () => clearInterval(id); }, []); + const ageMin = meta.valid ? Math.max(0, Math.round((Date.now() - meta.valid.getTime()) / 60_000)) : null; + const stale = ageMin == null ? meta.loaded : ageMin > RADAR_STALE_MIN; + const last = RADAR_LOOP_OFFSETS_MIN.length - 1; + const off = loop ? RADAR_LOOP_OFFSETS_MIN[Math.min(frame, last)] : 0; + let text: string; + if (!meta.loaded) text = "Radar: checking valid time…"; + else if (!meta.valid) text = `Radar: valid time unknown${meta.error ? " (IEM metadata unreachable)" : ""}`; + else if (loop && off > 0) text = `Radar loop: ${hhmmLocal(new Date(meta.valid.getTime() - off * 60_000))} (−${off} min)`; + else text = `Radar valid ${hhmmLocal(meta.valid)} · ${ageMin} min old`; return ( - +
+ {stale && STALE} + {text} + {meta.valid && meta.error && · time not refreshing} + +
+ ); +} + +// ── NWS weather warnings (server-26#237) ───────────────────────────────────── +const NWS_CREDIT: CameraCredit = { attribution: "National Weather Service", attribution_url: "https://www.weather.gov/", disclaimer: null }; + +/** GeoJSON (Multi)Polygon → Leaflet positions ([lon, lat] → [lat, lon]). */ +function wxPositions(g: WeatherGeometry): L.LatLngExpression[][] | L.LatLngExpression[][][] { + const ring = (r: number[][]) => r.map((p) => [p[1], p[0]] as L.LatLngTuple); + return g.type === "Polygon" ? g.coordinates.map(ring) : g.coordinates.map((poly) => poly.map(ring)); +} + +function WeatherAlertPopupBody({ a, fetchedAt }: { a: WeatherAlert; fetchedAt: number | null }) { + const expires = wxTime(a.expires); + const ends = wxTime(a.ends); + const onset = wxTime(a.onset); + return ( +
+
{a.event}
+ {/* Shown as NWS issued it — never summarised or rewritten. */} + {a.headline &&
{a.headline}
} +
+ {onset ? `From ${onset}` : ""}{onset && (expires || ends) ? " · " : ""} + {ends ? `ends ${ends}` : ""}{ends && expires ? " · " : ""}{expires ? `expires ${expires}` : ""} +
+ {a.area_desc &&
{a.area_desc}
} + {a.sender_name &&
Issued by {a.sender_name}
} + {a.geometry_source === "zones" && ( +
+ Area drawn from the alert's forecast/county zones{a.zones_unresolved ? ` — ${a.zones_unresolved} zone${a.zones_unresolved !== 1 ? "s" : ""} could not be fetched, so the real area is larger` : ""}. +
+ )} + +
+ ); +} + +function WeatherWarningsLayer({ data }: { data: WeatherAlertsResponse | null }) { + // Weakest first so warnings draw on top (and win the click where they overlap). + const alerts = useMemo(() => { + const rank = { statement: 0, advisory: 1, watch: 2, warning: 3 } as const; + return (data?.alerts ?? []).slice().sort((a, b) => rank[wxStyle(a.event).kind] - rank[wxStyle(b.event).kind]); + }, [data]); + return ( + <> + {alerts.map((a) => { + const st = wxStyle(a.event); + return ( + + {a.event}{wxTime(a.expires) ? ` · until ${wxTime(a.expires)}` : ""} + + + + + ); + })} + + ); +} + +/** + * Always-present status for the Weather Warnings overlay: never a silently + * empty layer. Feed problems use the 511 FeedProblem wording ("could not reach + * server" / "feed error, showing data from N min ago"); zone-only alerts — + * no shape to draw — are listed here, not dropped. + */ +function WeatherWarningsStatus({ state }: { state: Polled }) { + const { data, error, loaded } = state; + const zoneOnly = data?.zone_only ?? []; + let msg: string; + let problem = false; + if (!loaded) msg = "Weather Warnings: loading…"; + else if (!data) { msg = "Weather Warnings: could not reach server"; problem = true; } + else if (data.error) { + problem = true; + msg = data.fetched_at + ? `Weather Warnings: NWS feed error, showing data from ${Math.round((Date.now() / 1000 - data.fetched_at) / 60)} min ago` + : "Weather Warnings: NWS feed error, no data yet"; + } else { + const n = data.alerts.length; + msg = n ? `Weather Warnings: ${n} active in view` : "Weather Warnings: no active NWS alert with a mapped area in view"; + } + if (data && error) msg += " · could not refresh"; + return ( +
+
+ {msg} + {data?.fetched_at && !data.error ? ` · NWS, fetched ${fetchedLabel(data.fetched_at)}` : ""} +
+ {zoneOnly.length > 0 && ( +
+ + {zoneOnly.length} alert{zoneOnly.length !== 1 ? "s" : ""} in the served area with no mapped shape (zone-based) + +
    + {zoneOnly.map((a) => ( +
  • + {a.event}{a.area_desc ? ` · ${a.area_desc}` : ""}{wxTime(a.expires) ? ` · until ${wxTime(a.expires)}` : ""} +
  • + ))} +
+
+ )} +
); } @@ -1353,6 +1527,10 @@ export default function MapView({ const layerOn = (k: LayerKey) => startLayers.has(k); const [aircraftShown, setAircraftShown] = useState(() => startLayers.has("air")); const [outagesShown, setOutagesShown] = useState(false); + const [radarShown, setRadarShown] = useState(() => startLayers.has("radar")); + const [wxShown, setWxShown] = useState(() => startLayers.has("wx")); + const [radarLoop, setRadarLoop] = useState(false); // off by default (#237); not persisted + const [loopFrame, setLoopFrame] = useState(0); // The altitude key only belongs in the legend while the opt-in Aircraft // overlay is actually on (server-26#185); same for the outage key (#184). @@ -1361,10 +1539,14 @@ export default function MapView({ const on = (e: L.LayersControlEvent) => { if (e.name === LAYER_NAMES.air) setAircraftShown(true); if (e.name === OVERLAY_OUTAGES) setOutagesShown(true); + if (e.name === LAYER_NAMES.radar) setRadarShown(true); + if (e.name === LAYER_NAMES.wx) setWxShown(true); }; const off = (e: L.LayersControlEvent) => { if (e.name === LAYER_NAMES.air) setAircraftShown(false); if (e.name === OVERLAY_OUTAGES) setOutagesShown(false); + if (e.name === LAYER_NAMES.radar) setRadarShown(false); + if (e.name === LAYER_NAMES.wx) setWxShown(false); }; mapInstance.on("overlayadd", on); mapInstance.on("overlayremove", off); @@ -1379,6 +1561,24 @@ export default function MapView({ return () => clearInterval(id); }, []); + // ── Weather (server-26#237): fetched only while the overlay is on ───────── + const radarMeta = useRadarMeta(radarShown); + const wxAlerts = useMapWeatherAlerts(mapInstance, wxShown); + const loopOn = radarShown && radarLoop; + useEffect(() => { + if (!loopOn) { setLoopFrame(0); return; } + const last = RADAR_LOOP_OFFSETS_MIN.length - 1; + let tick = 0; + const cycle = last + LOOP_HOLD_FRAMES; + setLoopFrame(0); + const id = setInterval(() => { + if (document.hidden) return; + tick = (tick + 1) % cycle; + setLoopFrame(Math.min(tick, last)); + }, LOOP_FRAME_MS); + return () => clearInterval(id); + }, [loopOn]); + // eslint-disable-next-line react-hooks/exhaustive-deps const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]); @@ -1588,9 +1788,18 @@ export default function MapView({ - {/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet */} + {/* Overlay: Weather Radar — NEXRAD n0q via Iowa Env Mesonet (server-26#237) */} - + + + + + + {/* Overlay: NWS watches/warnings/advisories (server-26#237) */} + + + + @@ -1609,6 +1818,15 @@ export default function MapView({ )} + {/* ── Weather status (server-26#237) — bottom centre, clear of the rail + (left), the legend (right) and the 511/outage banners (bottom-left). */} + {(radarShown || wxShown) && ( +
+ {wxShown && } + {radarShown && } +
+ )} + {/* ── Map action buttons — top-left, below zoom controls ──────────────── */}
{mapInstance && allPositions.length > 0 && ( @@ -1706,6 +1924,33 @@ export default function MapView({
)} + {radarShown && ( +
+

Radar reflectivity (dBZ)

+
`${n0qColor(d)} ${(i / (RADAR_LEGEND_DBZ.length - 1)) * 100}%`).join(", ")})` }} + /> +
+ {RADAR_LEGEND_TICKS.map((d) => {d})} +
+

~20 light · 40 moderate · 50+ heavy rain / hail

+
+ )} + {wxShown && ( +
+

NWS alerts

+ {(["warning", "watch", "advisory"] as const).map((k) => ( +
+ + + + {k === "warning" ? "Warning" : k === "watch" ? "Watch" : "Advisory / statement"} +
+ ))} +

Fill colour = NWS hazard-map colour for the event

+
+ )} {outagesShown && (

Power outages

diff --git a/drb-frontend/components/live/WallPanels.tsx b/drb-frontend/components/live/WallPanels.tsx index 79b3596..a41a9ba 100644 --- a/drb-frontend/components/live/WallPanels.tsx +++ b/drb-frontend/components/live/WallPanels.tsx @@ -17,9 +17,12 @@ import { SourceCredit, fetchedLabel } from "@/components/SourceCredit"; import { InlinePlayer } from "@/components/InlinePlayer"; import { canPlayAudio, hasAudio, playIncident, stop as stopAudio, useAudio } from "@/lib/audioQueue"; import { - CONTEXT_KM, MAY_BE_MERGED_KM, NEAR_CAMERA_KM, bboxAround, contextFor, pathStops, sampleRadarDbz, stopNumbers, + CONTEXT_KM, MAY_BE_MERGED_KM, NEAR_CAMERA_KM, PRECIP_ECHO_DBZ, alertsAt, bboxAround, compass, contextFor, pathStops, + sampleRadarDbz, stopNumbers, wantsWind, wxStyle, wxTime, type LatLon, type NearCamera, } from "@/lib/liveWall"; +import { useWeatherAlertsIn, useWind, type Polled } from "@/lib/useWeather"; +import type { WeatherAlertsResponse } from "@/lib/types"; import { useOutagesIn, type OutagesState } from "@/lib/useOutages"; import { OUTAGE_POINT_KM, countyName, isUtilityHazard, matchOutages, staleCountiesAt } from "@/lib/outageMatch"; @@ -181,6 +184,126 @@ function OutageFeedRow({ incidents, nodes }: { incidents: IncidentRecord[]; node ); } + +// ── Weather (server-26#237) ────────────────────────────────────────────────── +const NWS_CREDIT: CameraCredit = { attribution: "National Weather Service", attribution_url: "https://www.weather.gov/", disclaimer: null }; +const METAR_CREDIT: CameraCredit = { attribution: "NOAA Aviation Weather Center (METAR)", attribution_url: "https://aviationweather.gov/", disclaimer: null }; + +/** + * Why the NWS answer is unusable, in one line (same wording as the map's + * banner) — or null when it is fine to read. + */ +function nwsProblem(state: Polled): string | null { + const { data, error, loaded } = state; + if (!loaded) return "checking…"; + if (!data) return `could not reach server${error ? ` (${error})` : ""}`; + if (data.error && !data.fetched_at) return "NWS feed error, no data yet"; + return null; +} +function nwsFreshness(state: Polled): string { + const d = state.data!; + return `fetched ${fetchedLabel(d.fetched_at) ?? "—"}${d.error ? " · NWS feed error, showing the last good data" : ""}${state.error ? " · could not refresh" : ""}`; +} + +/** Incident panel "NWS" lines: the warning polygons the incident's head point sits inside. */ +function WeatherWarningContext({ head }: { head: LatLon | null }) { + const box = useMemo(() => (head ? bboxAround([head], 1) : null), [head]); + const state = useWeatherAlertsIn(box); + const inside = useMemo(() => (head && state.data ? alertsAt(head, state.data.alerts) : []), [head, state.data]); + const tag = NWS; + if (!head) return
  • {tag}—
  • ; + const problem = nwsProblem(state); + if (problem) return
  • {tag}{problem}
  • ; + const zoneOnly = state.data!.zone_only.length; + return ( +
  • + {tag} + {inside.length ? ( + + {inside.map((a) => { + const st = wxStyle(a.event); + return ( + + inside {a.event}{wxTime(a.expires) ? ` · until ${wxTime(a.expires)}` : ""} + + ); + })} + + ) : ( + <>not inside any active NWS alert area + )} + {zoneOnly > 0 && · {zoneOnly} zone-based alert{zoneOnly !== 1 ? "s" : ""} in the served area can't be placed (Weather Warnings layer lists them)} + +
  • + ); +} + +/** Arrow pointing where the wind blows TO (METAR gives where it comes FROM). */ +function WindArrow({ fromDeg }: { fromDeg: number }) { + return ( + + + + + + + ); +} + +/** Incident panel "WIND" line for fire/hazmat incidents: nearest METAR station. */ +function WindContext({ head }: { head: LatLon | null }) { + const state = useWind(head); + const tag = WIND; + if (!head) return
  • {tag}— (no geocoded location)
  • ; + if (!state.loaded) return
  • {tag}checking…
  • ; + const d = state.data; + if (!d) return
  • {tag}could not reach server{state.error ? ` (${state.error})` : ""}
  • ; + if (!d.available || !d.wind) return
  • {tag}{d.reason ?? "no wind report"}
  • ; + const w = d.wind; + const calm = w.wind_kt === 0; + const dir = calm ? "calm" : w.variable || w.wind_dir_deg == null ? "variable" : `from ${compass(w.wind_dir_deg)} (${Math.round(w.wind_dir_deg)}°)`; + const obs = w.obs_time ? new Date(w.obs_time) : null; + const obsLabel = obs && !isNaN(obs.getTime()) ? obs.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) : null; + return ( +
  • + {tag} + {!calm && !w.variable && w.wind_dir_deg != null && } + {calm ? "Calm" : `${Math.round(w.wind_kt)} kt`} + {w.gust_kt != null && !calm ? G {Math.round(w.gust_kt)} kt : null} + {calm ? "" : ` ${dir}`} + {" · "}{w.station}{w.station_name ? ` (${w.station_name})` : ""}, {w.distance_km.toFixed(1)} km away + {obsLabel ? ` · observed ${obsLabel}` : ""} + {w.stale && STALE} + +
  • + ); +} + +/** Region Feeds "NWS" row: active alerts over the wall's region, with fetch time or the error. */ +function WeatherFeedRow({ incidents, nodes }: { incidents: IncidentRecord[]; nodes: NodeRecord[] }) { + const pts = useMemo(() => { + const inc = incidents.filter((i) => i.location_coords).map((i) => [i.location_coords!.lat, i.location_coords!.lng] as LatLon); + return inc.length ? inc : nodes.map((n) => [n.lat, n.lon] as LatLon); + }, [incidents, nodes]); + const box = useMemo(() => bboxAround(pts, 10), [pts]); + const state = useWeatherAlertsIn(box); + const tag = NWS; + if (!box) return
  • {tag}no region to check (no nodes or geocoded incidents)
  • ; + const problem = nwsProblem(state); + if (problem) return
  • {tag}{problem}
  • ; + const d = state.data!; + const warnings = d.alerts.filter((a) => wxStyle(a.event).kind === "warning").length; + return ( +
  • {tag}{d.alerts.length + ? `${d.alerts.length} active alert${d.alerts.length !== 1 ? "s" : ""} in region${warnings ? ` (${warnings} warning${warnings !== 1 ? "s" : ""})` : ""}` + : "no active alert areas in region"} + {d.zone_only.length ? ` · ${d.zone_only.length} zone-based in served area` : ""} · {nwsFreshness(state)} · Weather Warnings layer (map menu)
  • + ); +} + // ── Level 1: region ────────────────────────────────────────────────────────── export function RegionPanel({ incidents, calls, onAirCount, nodes, wallCameras, feeds, servingCount, aircraftCount, vesselCount, @@ -244,7 +367,8 @@ export function RegionPanel({
  • CAM{p.name}: {p.total_in_bbox} in region{p.error ? " · feed error" : ""}{p.fetched_at ? ` · list ${fetchedLabel(p.fetched_at)}` : ""}
  • ))} -
  • NEXRADWeather Radar layer (map menu); sampled per incident
  • + +
  • NEXRADWeather Radar layer (map menu, IEM n0q, with valid time); sampled per incident
  • @@ -299,7 +423,8 @@ export function IncidentPanel({ }) { const color = sevColor(inc.severity); const ctx = useMemo(() => contextFor(points, aircraft, vessels, events), [points, aircraft, vessels, events]); - const radar = useRadarDbz(points.at(-1) ?? null); + const head = points.at(-1) ?? null; + const radar = useRadarDbz(head); // Stop numbers come from the same pathStops() the map draws from, so the // spine numbers exactly what the map numbers (server-26#236): only movement // incidents get numbers, and a ghosted (impossible-speed) stop gets none. @@ -394,8 +519,11 @@ export function IncidentPanel({
  • NEXRAD {!points.length ? "—" : !radar.done ? "sampling…" : radar.dbz == null ? "radar sample unavailable" - : radar.dbz === 0 ? "no precipitation echo within ~5 km" : `up to ~${radar.dbz} dBZ within ~5 km (approx., from the radar tile)`} + : radar.dbz < PRECIP_ECHO_DBZ ? "no precipitation echo within ~5 km" + : `up to ~${Math.round(radar.dbz)} dBZ within ~5 km (approx., read from the radar layer's n0q tile)`}
  • + + {wantsWind(inc) && } {!ctx.aircraft.length && !ctx.vessels.length && !ctx.road.length && ( diff --git a/drb-frontend/lib/c2api.ts b/drb-frontend/lib/c2api.ts index 9af8410..8501294 100644 --- a/drb-frontend/lib/c2api.ts +++ b/drb-frontend/lib/c2api.ts @@ -1,6 +1,7 @@ import { auth } from "@/lib/firebase"; import type { AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, OutageProviderAdmin, OutagesResponse, TalkgroupPending, + WeatherAlertsResponse, WindResponse, } from "@/lib/types"; import type { DiscordAdminOverview, DiscordStream, RadioBot, RadioBotAddResult } from "@/lib/discord"; @@ -532,6 +533,16 @@ export const c2api = { body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }), }), + // Weather context (server-26#237): active NWS alerts intersecting a bbox (bbox + // optional — omitted, the whole served area), and nearest-METAR wind. Both are + // cached server-side; an upstream failure comes back as `error`, not an HTTP error. + getWeatherAlerts: (bbox?: { south: number; west: number; north: number; east: number } | null) => + request(`/weather/alerts${bbox ? `?${new URLSearchParams({ + south: String(bbox.south), west: String(bbox.west), north: String(bbox.north), east: String(bbox.east), + })}` : ""}`), + getWind: (lat: number, lon: number) => + request(`/weather/wind?${new URLSearchParams({ lat: String(lat), lon: String(lon) })}`), + // Public waitlist — no auth, see routers/waitlist.py joinWaitlist: (body: { email: string; org_name?: string; note?: string }) => request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }), diff --git a/drb-frontend/lib/liveWall.ts b/drb-frontend/lib/liveWall.ts index 2c83eaa..ac00a3c 100644 --- a/drb-frontend/lib/liveWall.ts +++ b/drb-frontend/lib/liveWall.ts @@ -3,7 +3,9 @@ * gods-eye-view-notes.md top 5). No React here, so the logic is testable in * isolation and shared by MapView and LiveView. */ -import type { AircraftTrack, CallRecord, Camera, IncidentRecord, Ny511Event, VesselTrack } from "@/lib/types"; +import type { + AircraftTrack, CallRecord, Camera, IncidentRecord, Ny511Event, VesselTrack, WeatherAlert, WeatherGeometry, +} from "@/lib/types"; import { severityRank } from "@/lib/severity"; export type LatLon = [number, number]; @@ -261,7 +263,7 @@ export function bboxAround(points: LatLon[], padKm: number) { // ── Deep links / saved views ──────────────────────────────────────────────── // #inc=&cam=&tour&tourMs=30000&kiosk -// &layers=nodes,inc,radar&z=11&lat=41.12345&lon=-73.98765&base=light +// &layers=nodes,inc,radar,wx&z=11&lat=41.12345&lon=-73.98765&base=light // &f.= // Every key is optional; unknown keys are ignored, so an old link (inc/cam/tour // only) still parses and a newer one degrades on an older build. Pure string @@ -278,6 +280,7 @@ export const LAYER_NAMES = { vs: "Camera Viewsheds (approx.)", traffic: "Traffic Events", radar: "Weather Radar", + wx: "Weather Warnings", } as const; export type LayerKey = keyof typeof LAYER_NAMES; export const LAYER_KEYS = Object.keys(LAYER_NAMES) as LayerKey[]; @@ -407,28 +410,103 @@ export function onAirIncidentIds(incidents: IncidentRecord[], activeCalls: CallR ); } -// ── NEXRAD sample near an incident ─────────────────────────────────────────── -// Reads the same IEM n0r composite tile the Weather Radar layer draws (IEM -// sends ACAO: *), and maps pixel colours back to the NWS reflectivity scale. -// Approximate by construction — label it so. -const N0R_PALETTE: [number, number, number, number][] = [ - [5, 4, 233, 231], [10, 1, 159, 244], [15, 3, 0, 244], [20, 2, 253, 2], [25, 1, 197, 1], [30, 0, 142, 0], - [35, 253, 248, 2], [40, 229, 188, 0], [45, 253, 149, 0], [50, 253, 0, 0], [55, 212, 0, 0], [60, 188, 0, 0], - [65, 248, 0, 253], [70, 152, 84, 198], [75, 253, 253, 253], -]; -const RADAR_TILE = "https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0r-900913"; +// ── NEXRAD (IEM n0q composite) ─────────────────────────────────────────────── +// The Weather Radar layer, its valid-time badge, its legend and the per-incident +// sample all use ONE product: IEM's n0q (base reflectivity, 0.5 dBZ steps, +// -32..95 dBZ). Verified first-hand 2026-10-05: +// tiles https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0q-900913/{z}/{x}/{y}.png +// (RGBA PNG, ACAO: *), and nexrad-n0q-900913-m05m … -m55m for the +// 5-minute frames of the last hour (-m60m is a 404); +// valid https://mesonet.agron.iastate.edu/data/gis/images/4326/USCOMP/n0q_0.json +// → {"meta": {"valid": "2026-10-05T07:35:00Z", "product": "N0Q", …}} +// (n0q_.json is k × 5 min earlier; ACAO: *); +// ramp https://mesonet.agron.iastate.edu/GIS/rasters.php?rid=2 — 255 colours, +// index i (1..255) = -32 + (i-1)/2 dBZ. Every opaque pixel of five +// sampled live tiles matched this table exactly. +export const RADAR_TILE_BASE = "https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0q-900913"; +export const RADAR_META_URL = "https://mesonet.agron.iastate.edu/data/gis/images/4326/USCOMP/n0q_0.json"; +/** A frame older than this is flagged STALE on the map (#237 acceptance). */ +export const RADAR_STALE_MIN = 15; +/** Loop frames, oldest first, as minutes before the current composite (IEM keeps -m05m … -m55m). */ +export const RADAR_LOOP_OFFSETS_MIN = [55, 50, 45, 40, 35, 30, 25, 20, 15, 10, 5, 0] as const; +/** Light rain starts around here; weaker returns are clear-air / clutter. */ +export const PRECIP_ECHO_DBZ = 15; -function pixelDbz(r: number, g: number, b: number): number { - let best = 0; - let bestD = Infinity; - for (const [dbz, pr, pg, pb] of N0R_PALETTE) { - const d = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2; - if (d < bestD) { bestD = d; best = dbz; } - } - return bestD < 60 * 60 ? best : 0; +/** Tile URL for the composite `offsetMin` minutes before the current one (0 = current). */ +export function radarTileUrl(offsetMin = 0): string { + const layer = offsetMin > 0 ? `${RADAR_TILE_BASE}-m${String(offsetMin).padStart(2, "0")}m` : RADAR_TILE_BASE; + return `${layer}/{z}/{x}/{y}.png`; } -/** Max reflectivity (dBZ) within ~radiusKm of a point, or null if it can't be read. */ +/** The composite's valid time from IEM's n0q metadata JSON, or null if the shape isn't the verified one. */ +export function parseRadarMeta(body: unknown): Date | null { + const valid = (body as { meta?: { valid?: unknown } } | null)?.meta?.valid; + if (typeof valid !== "string") return null; + const d = new Date(valid); + return isNaN(d.getTime()) ? null : d; +} + +/** IEM's published n0q colour table, 6 hex digits per entry, index 1..255 (index 0 = missing). */ +const N0Q_RAMP_HEX = + "85718f85728f86738d87758b87768b887789897987897a878a7b858b7d848b7e848c7f828d81808d82808e837e8f847c" + + "8f857c90877b918879918979928b77938d759691539894579b975b9d9a60a09d64a3a068a5a36da8a671aaa976adac7a" + + "b0af7eb2b283b7b88cbabb90bdbe94bfc199c2c49dc4c7a2c7caa6cacdaaccd0afd2d4b4cfd2b4c9ccb4c6c9b4c3c7b4" + + "c0c4b4bdc1b4b9beb4b6bbb4b3b9b4b0b6b4adb3b4aab0b4a4abb4a0a8b49da5b49aa2b497a0b4949db4919ab4949bb5" + + "9098b48c95b38892b2808cb07c89af7886ae7483ac7080ab6c7daa6779a96376a85f73a75b70a6576da44f67a24b64a1" + + "4761a0435e9f415b9e4361a24568a6486faa4a76ae4d7db24f84b6518bbb5699c3599fc75ba6cb5eadcf60b4d462bbd8" + + "65c2dc67c9e06ad0e46fd6e868d6d759d6b352d6a24bd69043d67e3cd66d35d65b11d51811d11710cd1710c81610c416" + + "0fbc150fb7140eb3140eaf130eab130da6120da2120d9e110c99110c95100c91100b880f0b840e0a800e0a7c0d0a770d" + + "09730c096f0c096b0b08660b08620a095e09327308467d085b88076f9207849d0698a806adb205c1bd05d6c704ead204" + + "ffe200ffd800ffd300ffce00ffc900ffc400ffc000ffbb00ffb600ffb100ffac00ffa700ffa200ff9900ff9400ff8f00" + + "ff8a00ff8500ff8000ff0000f80000f10000ea0000e30000d50000cd0000c60000bf0000b80000b10000aa0000a30000" + + "9b00009400008d00007f0000780000710000fffffffff5ffffeaffffdfffffd4ffffc9ffffbeffffb3ffff9dffff92ff" + + "ff75fffc6bfdf960faf656f7f34bf4f040f1ed36efea2bece720e9e10be3b200ffac00fca400f79b00f49300ef8800ea" + + "8300e87900e27200dd6900db05ecf005ebf005eaf005dde005dce005dbe005cdd005ccd004bdc004bcc004bbc004aeb0" + + "04adb0049ea0049da0049ca0038e90038d90038c90037e80037d80036f70036e70036d70025f60025e60024f50024e50" + + "024d50023f40023e40023d40013030012f30012020011f20011e203a67b53a66b53a65b53a64b53a63b53a62b5"; + +function n0qIndex(dbz: number): number { + return Math.min(255, Math.max(1, Math.round((dbz + 32) * 2) + 1)); +} + +/** "#rrggbb" the radar layer draws for a reflectivity. */ +export function n0qColor(dbz: number): string { + const i = n0qIndex(dbz) - 1; + return `#${N0Q_RAMP_HEX.slice(i * 6, i * 6 + 6)}`; +} + +let rgbToDbz: Map | null = null; +function n0qLookup(): Map { + if (!rgbToDbz) { + rgbToDbz = new Map(); + for (let i = 0; i < 255; i++) rgbToDbz.set(parseInt(N0Q_RAMP_HEX.slice(i * 6, i * 6 + 6), 16), -32 + i / 2); + } + return rgbToDbz; +} + +/** dBZ for a radar-tile pixel. Exact table hit (tiles are lossless); else nearest colour within a small distance; else null. */ +export function pixelDbz(r: number, g: number, b: number): number | null { + const lut = n0qLookup(); + const exact = lut.get((r << 16) | (g << 8) | b); + if (exact != null) return exact; + let best: number | null = null; + let bestD = 12 * 12; + lut.forEach((dbz, rgb) => { + const d = (r - ((rgb >> 16) & 255)) ** 2 + (g - ((rgb >> 8) & 255)) ** 2 + (b - (rgb & 255)) ** 2; + if (d < bestD) { bestD = d; best = dbz; } + }); + return best; +} + +/** No opaque radar pixel near the point (the tile is transparent there). */ +export const NO_ECHO_DBZ = -Infinity; + +/** + * Max reflectivity (dBZ) within ~radiusKm of a point, read from the same n0q + * tile the Weather Radar layer draws (cache-busted on the same 5-min clock). + * Approximate by construction — label it so. NO_ECHO_DBZ when nothing is + * drawn there; null when the tile can't be read. + */ export async function sampleRadarDbz(lat: number, lon: number, radiusKm = 5): Promise { const z = 9; const n = 2 ** z; @@ -439,7 +517,7 @@ export async function sampleRadarDbz(lat: number, lon: number, radiusKm = 5): Pr const ty = Math.floor(yf); const img = new Image(); img.crossOrigin = "anonymous"; - img.src = `${RADAR_TILE}/${z}/${tx}/${ty}.png?_=${Math.floor(Date.now() / 300000)}`; + img.src = `${RADAR_TILE_BASE}/${z}/${tx}/${ty}.png?_=${Math.floor(Date.now() / 300000)}`; try { await img.decode(); } catch { @@ -466,10 +544,143 @@ export async function sampleRadarDbz(lat: number, lon: number, radiusKm = 5): Pr } catch { return null; // tainted canvas — CORS changed upstream } - let max = 0; + let max = NO_ECHO_DBZ; for (let i = 0; i < data.length; i += 4) { - if (data[i + 3] < 32) continue; - max = Math.max(max, pixelDbz(data[i], data[i + 1], data[i + 2])); + if (data[i + 3] < 128) continue; // transparent: nothing drawn + const dbz = pixelDbz(data[i], data[i + 1], data[i + 2]); + if (dbz != null && dbz > max) max = dbz; } return max; } + +// ── NWS warnings (server-26#237) ───────────────────────────────────────────── + +/** Ray-cast point-in-ring; `ring` is GeoJSON [lon, lat] pairs. */ +function inRing(lat: number, lon: number, ring: number[][]): boolean { + let inside = false; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const [xi, yi] = ring[i]; + const [xj, yj] = ring[j]; + if ((yi > lat) !== (yj > lat) && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside; + } + return inside; +} + +/** + * True when [lat, lon] is inside a GeoJSON Polygon / MultiPolygon (holes + * respected: inside the outer ring and outside every hole). + */ +export function pointInPolygon(point: LatLon, geom: WeatherGeometry | null | undefined): boolean { + if (!geom) return false; + const polys = geom.type === "Polygon" ? [geom.coordinates] : geom.type === "MultiPolygon" ? geom.coordinates : []; + const [lat, lon] = point; + return polys.some((rings) => + rings.length > 0 && inRing(lat, lon, rings[0]) && !rings.slice(1).some((hole) => inRing(lat, lon, hole))); +} + +/** Active alerts whose drawn area contains the point, worst-looking (Warning) first. */ +export function alertsAt(point: LatLon, alerts: WeatherAlert[]): WeatherAlert[] { + return alerts + .filter((a) => pointInPolygon(point, a.geometry)) + .sort((a, b) => wxKindRank(a.event) - wxKindRank(b.event)); +} + +export type WxKind = "warning" | "watch" | "advisory" | "statement"; + +export function wxKind(event: string): WxKind { + const e = event.toLowerCase(); + if (e.includes("warning") || e.includes("emergency")) return "warning"; + if (e.includes("watch")) return "watch"; + if (e.includes("advisory") || e.includes("alert")) return "advisory"; + return "statement"; +} +function wxKindRank(event: string): number { + return ["warning", "watch", "advisory", "statement"].indexOf(wxKind(event)); +} + +/** + * NWS hazard-map colours (weather.gov/help-map) for the events this region + * sees most. Unlisted events fall back by kind. Colour is never the only cue: + * the outline dash says the kind (warning solid, watch dashed, advisory and + * statement dotted), and the legend explains it. + */ +const NWS_EVENT_COLORS: Record = { + "tornado warning": "#ff0000", + "severe thunderstorm warning": "#ffa500", + "flash flood warning": "#8b0000", + "flood warning": "#00ff00", + "flood advisory": "#00ff7f", + "flood watch": "#2e8b57", + "flash flood watch": "#2e8b57", + "tornado watch": "#ffff00", + "severe thunderstorm watch": "#db7093", + "special marine warning": "#ffa500", + "winter storm warning": "#ff69b4", + "winter storm watch": "#4682b4", + "winter weather advisory": "#7b68ee", + "blizzard warning": "#ff4500", + "ice storm warning": "#8b008b", + "lake effect snow warning": "#008b8b", + "high wind warning": "#daa520", + "high wind watch": "#b8860b", + "wind advisory": "#d2b48c", + "heat advisory": "#ff7f50", + "excessive heat warning": "#c71585", + "extreme heat warning": "#c71585", + "excessive heat watch": "#800000", + "extreme heat watch": "#800000", + "coastal flood warning": "#228b22", + "coastal flood watch": "#66cdaa", + "coastal flood advisory": "#7cfc00", + "dense fog advisory": "#708090", + "special weather statement": "#ffe4b5", + "red flag warning": "#ff1493", + "fire weather watch": "#ffdead", + "freeze warning": "#483d8b", + "frost advisory": "#6495ed", + "small craft advisory": "#d8bfd8", + "gale warning": "#dda0dd", + "storm warning": "#9400d3", + "hurricane warning": "#dc143c", + "tropical storm warning": "#b22222", + "air quality alert": "#808080", +}; +const WX_KIND_FALLBACK: Record = { + warning: "#dc2626", watch: "#eab308", advisory: "#60a5fa", statement: "#a3a3a3", +}; +export const WX_KIND_DASH: Record = { + warning: undefined, watch: "8 5", advisory: "2 4", statement: "2 4", +}; + +export function wxStyle(event: string): { color: string; kind: WxKind; dashArray: string | undefined; weight: number } { + const kind = wxKind(event); + return { + color: NWS_EVENT_COLORS[event.toLowerCase()] ?? WX_KIND_FALLBACK[kind], + kind, + dashArray: WX_KIND_DASH[kind], + weight: kind === "warning" ? 2.5 : kind === "watch" ? 2 : 1.5, + }; +} + +/** Local "Oct 5, 2:30 PM" for an NWS ISO timestamp (they carry their own offset). */ +export function wxTime(iso: string | null | undefined): string | null { + if (!iso) return null; + const d = new Date(iso); + return isNaN(d.getTime()) ? null : d.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); +} + +// ── Wind at the incident (server-26#237) ───────────────────────────────────── +// Fire and hazmat are where wind direction changes the call (smoke / plume +// drift, approach side). There is no hazmat incident type, so tags decide. +const WIND_TAG_RE = /(^|-)(hazmat|haz-mat|gas-leak|natural-gas|chemical|spill|smoke|fumes|odor|odour|plume|brush|wildfire|propane|ammonia|chlorine|carbon-monoxide)(-|$)/; + +export function wantsWind(inc: Pick): boolean { + if (inc.type === "fire") return true; + return (inc.tags ?? []).some((t) => WIND_TAG_RE.test(t.toLowerCase())); +} + +/** 16-point compass name for the direction the wind blows FROM. */ +export function compass(deg: number): string { + const pts = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"]; + return pts[Math.round((((deg % 360) + 360) % 360) / 22.5) % 16]; +} diff --git a/drb-frontend/lib/types.ts b/drb-frontend/lib/types.ts index 660deeb..ba83071 100644 --- a/drb-frontend/lib/types.ts +++ b/drb-frontend/lib/types.ts @@ -319,6 +319,77 @@ export interface Ny511Response { credit?: CameraCredit; } +// ── Weather context (server-26#237) — GET /weather/alerts, GET /weather/wind ── + +/** GeoJSON (Multi)Polygon, [lon, lat] order. */ +export type WeatherGeometry = + | { type: "Polygon"; coordinates: number[][][] } + | { type: "MultiPolygon"; coordinates: number[][][][] }; + +/** One active NWS alert as c2-core normalises it (app/internal/weather.py normalize_alert). */ +export interface WeatherAlertBase { + id: string; + event: string; + severity: string | null; + urgency: string | null; + headline: string | null; + onset: string | null; + expires: string | null; + ends: string | null; + /** An NWS office ("NWS Upton NY"), never a person. */ + sender_name: string | null; + area_desc: string | null; + zones: string[]; +} + +export interface WeatherAlert extends WeatherAlertBase { + geometry: WeatherGeometry; + /** "alert": NWS drew the polygon; "zones": assembled from the affected forecast/county zones. */ + geometry_source: "alert" | "zones"; + /** [south, west, north, east]. */ + bbox: [number, number, number, number] | null; + /** >0: some zones could not be fetched, so the drawn area is smaller than the real one. */ + zones_unresolved?: number; +} + +export interface WeatherAlertsResponse { + alerts: WeatherAlert[]; + /** Alerts with no resolvable shape — listed, never dropped, never bbox-filtered. */ + zone_only: WeatherAlertBase[]; + /** Epoch s of the server's last SUCCESSFUL NWS fetch; null = never. */ + fetched_at: number | null; + /** Set when the latest refresh failed; `alerts` is then the last good data. */ + error: string | null; + credit: string; +} + +export interface WindObs { + station: string; + station_name: string; + lat: number; + lon: number; + obs_time: string | null; + /** Direction the wind blows FROM, degrees true; null when variable or calm. */ + wind_dir_deg: number | null; + variable: boolean; + wind_kt: number; + gust_kt: number | null; + raw: string | null; + distance_km: number; + /** Report older than 2 h. */ + stale: boolean; +} + +export interface WindResponse { + available: boolean; + /** Why there is no wind, when `available` is false. */ + reason: string | null; + wind: WindObs | null; + fetched_at: number | null; + error: string | null; + credit: string; +} + export interface VocabularyPendingTerm { term: string; source: "induction" | "correction"; diff --git a/drb-frontend/lib/useWeather.ts b/drb-frontend/lib/useWeather.ts new file mode 100644 index 0000000..e99bdb1 --- /dev/null +++ b/drb-frontend/lib/useWeather.ts @@ -0,0 +1,154 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type L from "leaflet"; +import { c2api } from "@/lib/c2api"; +import type { WeatherAlertsResponse, WindResponse } from "@/lib/types"; +import type { BBox } from "@/lib/useCameras"; +import { RADAR_META_URL, parseRadarMeta, type LatLon } from "@/lib/liveWall"; + +// Weather context (server-26#237). NWS alerts and METAR wind come from c2-core +// (cached server-side, shared by every viewer — NWS rate-limits and wants a +// contact User-Agent); the radar valid time comes straight from IEM's n0q +// metadata JSON, the same product the radar tiles are cut from. +// +// Everything polls only while wanted AND the tab is visible, and refetches as +// soon as a hidden tab comes back if a poll was missed — a wall left in a +// background tab must never show an hour-old warning set as current. +const ALERTS_POLL_MS = 2 * 60 * 1000; +const RADAR_META_POLL_MS = 2 * 60 * 1000; +const WIND_POLL_MS = 5 * 60 * 1000; +const MOVE_DEBOUNCE_MS = 400; +const BBOX_PAD = 0.2; + +export interface Polled { + /** Last successful answer for the current target; null until the first one. */ + data: T | null; + /** Last request failure (network/HTTP), cleared by the next success. */ + error: string | null; + /** At least one request for the current target has finished. */ + loaded: boolean; +} + +/** + * Fetch `fetcher()` now, every `pollMs` while the tab is visible, and on return + * to a visible tab once a poll is due. A newer request's answer always wins. + * A new target (deps change) drops the previous answer, so it is never shown + * as if it were about the new one. + */ +function usePolled( + fetcher: (() => Promise | null) | null, + deps: unknown[], + pollMs: number, + subscribe?: (reload: () => void) => () => void, +): Polled { + const [state, setState] = useState>({ data: null, error: null, loaded: false }); + const seq = useRef(0); + + useEffect(() => { + setState({ data: null, error: null, loaded: false }); + if (!fetcher) return; + const run = fetcher; + let last = 0; + const load = async () => { + const p = run(); + if (!p) return; + const mine = ++seq.current; + last = Date.now(); + try { + const data = await p; + if (mine !== seq.current) return; + setState({ data, error: null, loaded: true }); + } catch (e) { + if (mine !== seq.current) return; + console.error("useWeather:", e); + setState((s) => ({ ...s, error: e instanceof Error ? e.message : String(e), loaded: true })); + } + }; + const onVisible = () => { if (!document.hidden && Date.now() - last >= pollMs) load(); }; + load(); + const unsub = subscribe?.(load); + const poll = setInterval(() => { if (!document.hidden) load(); }, pollMs); + document.addEventListener("visibilitychange", onVisible); + return () => { + unsub?.(); + clearInterval(poll); + document.removeEventListener("visibilitychange", onVisible); + seq.current++; // drop any in-flight answer + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); + + return state; +} + +const clamp = (v: number, lim: number) => Math.max(-lim, Math.min(lim, v)); + +/** Active NWS alerts intersecting the current map view, while `enabled` (the Weather Warnings overlay is on). */ +export function useMapWeatherAlerts(map: L.Map | null, enabled: boolean): Polled { + return usePolled( + map && enabled + ? () => { + // Leaflet bounds run past ±180/±90 zoomed out; the API answers 422 to that. + const b = map.getBounds().pad(BBOX_PAD); + return c2api.getWeatherAlerts({ + south: clamp(b.getSouth(), 90), west: clamp(b.getWest(), 180), + north: clamp(b.getNorth(), 90), east: clamp(b.getEast(), 180), + }); + } + : null, + [map, enabled], + ALERTS_POLL_MS, + map + ? (reload) => { + let t: ReturnType | undefined; + const onMove = () => { clearTimeout(t); t = setTimeout(reload, MOVE_DEBOUNCE_MS); }; + map.on("moveend", onMove); + return () => { map.off("moveend", onMove); clearTimeout(t); }; + } + : undefined, + ); +} + +/** Active NWS alerts intersecting a fixed bbox (an incident, the wall's region). `null` = don't fetch. */ +export function useWeatherAlertsIn(box: BBox | null): Polled { + const key = box ? [box.south, box.west, box.north, box.east].map((v) => v.toFixed(3)).join(",") : ""; + const boxRef = useRef(box); + boxRef.current = box; + return usePolled(key ? () => (boxRef.current ? c2api.getWeatherAlerts(boxRef.current) : null) : null, [key], ALERTS_POLL_MS); +} + +/** Nearest-METAR wind at a point. `null` = don't fetch. */ +export function useWind(point: LatLon | null): Polled { + // ~100 m key: a re-render with an equal point (new array) must not refetch. + const key = point ? `${point[0].toFixed(3)},${point[1].toFixed(3)}` : ""; + const ref = useRef(point); + ref.current = point; + return usePolled(key ? () => (ref.current ? c2api.getWind(ref.current[0], ref.current[1]) : null) : null, [key], WIND_POLL_MS); +} + +export interface RadarMeta { + /** Valid time of the n0q composite (last one successfully read); null when never read. */ + valid: Date | null; + /** Latest metadata refresh failure (fetch failed, or IEM changed the JSON shape). */ + error: string | null; + loaded: boolean; +} + +async function fetchRadarMeta(): Promise { + // IEM serves this with ACAO: *, so the browser reads it directly — one tiny + // JSON per viewer per poll, like the tiles themselves. + const res = await fetch(RADAR_META_URL, { cache: "no-store" }); + if (!res.ok) throw new Error(`IEM radar metadata HTTP ${res.status}`); + const valid = parseRadarMeta(await res.json()); + if (!valid) throw new Error("IEM radar metadata has no meta.valid — format changed?"); + return valid; +} + +/** The radar composite's valid time, polled while `enabled` (the Weather Radar overlay is on). */ +export function useRadarMeta(enabled: boolean): RadarMeta { + const s = usePolled(enabled ? fetchRadarMeta : null, [enabled], RADAR_META_POLL_MS); + // On a failed refresh the last known valid time is kept AND the error is + // reported; the badge shows both, and the stale check keeps ageing it. + return { valid: s.data, error: s.error, loaded: s.loaded }; +}