- 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>
155 lines
6.2 KiB
TypeScript
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 };
|
|
}
|