Files
server-26/drb-frontend/lib/useWeather.ts
T
Logan CusanoandClaude Opus 5.5 1a84bf46d7 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 <warning>" 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 <noreply@anthropic.com>
2026-10-05 03:46:05 -04:00

155 lines
6.2 KiB
TypeScript

"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<T> {
/** 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<T>(
fetcher: (() => Promise<T> | null) | null,
deps: unknown[],
pollMs: number,
subscribe?: (reload: () => void) => () => void,
): Polled<T> {
const [state, setState] = useState<Polled<T>>({ 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<WeatherAlertsResponse> {
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<typeof setTimeout> | 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<WeatherAlertsResponse> {
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<WindResponse> {
// ~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<Date> {
// 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 };
}