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>
This commit is contained in:
Logan Cusano
2026-10-05 03:46:05 -04:00
co-authored by Claude Opus 5.5
parent 1d6e79ba25
commit 1a84bf46d7
6 changed files with 864 additions and 44 deletions
+11
View File
@@ -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<WeatherAlertsResponse>(`/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<WindResponse>(`/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) }),
+236 -25
View File
@@ -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=<id>&cam=<cameraId>&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.<name>=<value>
// 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_<k>.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<number, number> | null = null;
function n0qLookup(): Map<number, number> {
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<number | null> {
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<string, string> = {
"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<WxKind, string> = {
warning: "#dc2626", watch: "#eab308", advisory: "#60a5fa", statement: "#a3a3a3",
};
export const WX_KIND_DASH: Record<WxKind, string | undefined> = {
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<IncidentRecord, "type" | "tags">): 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];
}
+71
View File
@@ -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";
+154
View File
@@ -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<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 };
}