"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 }; }