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
+132 -4
View File
@@ -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<WeatherAlertsResponse>): 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<WeatherAlertsResponse>): 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 = <span className="font-mono text-[10px] text-ink-muted mr-1.5">NWS</span>;
if (!head) return <li>{tag}—</li>;
const problem = nwsProblem(state);
if (problem) return <li>{tag}{problem}</li>;
const zoneOnly = state.data!.zone_only.length;
return (
<li>
{tag}
{inside.length ? (
<span className="inline-flex flex-wrap gap-1 align-middle">
{inside.map((a) => {
const st = wxStyle(a.event);
return (
<span key={a.id} title={a.headline ?? a.event}
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-raised text-ink"
style={{ borderLeft: `3px ${st.dashArray ? "dashed" : "solid"} ${st.color}` }}>
inside {a.event}{wxTime(a.expires) ? ` · until ${wxTime(a.expires)}` : ""}
</span>
);
})}
</span>
) : (
<>not inside any active NWS alert area</>
)}
{zoneOnly > 0 && <span className="text-ink-muted"> · {zoneOnly} zone-based alert{zoneOnly !== 1 ? "s" : ""} in the served area can&apos;t be placed (Weather Warnings layer lists them)</span>}
<SourceCredit credit={NWS_CREDIT} updated={nwsFreshness(state)} />
</li>
);
}
/** Arrow pointing where the wind blows TO (METAR gives where it comes FROM). */
function WindArrow({ fromDeg }: { fromDeg: number }) {
return (
<svg width="14" height="14" viewBox="0 0 14 14" className="inline-block align-[-2px] mr-1" aria-hidden>
<g transform={`rotate(${(fromDeg + 180) % 360} 7 7)`}>
<line x1="7" y1="12" x2="7" y2="2.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<polyline points="3.8,5.6 7,2.2 10.2,5.6" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
</g>
</svg>
);
}
/** Incident panel "WIND" line for fire/hazmat incidents: nearest METAR station. */
function WindContext({ head }: { head: LatLon | null }) {
const state = useWind(head);
const tag = <span className="font-mono text-[10px] text-ink-muted mr-1.5">WIND</span>;
if (!head) return <li>{tag}— (no geocoded location)</li>;
if (!state.loaded) return <li>{tag}checking…</li>;
const d = state.data;
if (!d) return <li>{tag}could not reach server{state.error ? ` (${state.error})` : ""}</li>;
if (!d.available || !d.wind) return <li>{tag}{d.reason ?? "no wind report"}</li>;
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 (
<li>
{tag}
{!calm && !w.variable && w.wind_dir_deg != null && <WindArrow fromDeg={w.wind_dir_deg} />}
<b>{calm ? "Calm" : `${Math.round(w.wind_kt)} kt`}</b>
{w.gust_kt != null && !calm ? <b> G {Math.round(w.gust_kt)} kt</b> : null}
{calm ? "" : ` ${dir}`}
{" · "}{w.station}{w.station_name ? ` (${w.station_name})` : ""}, {w.distance_km.toFixed(1)} km away
{obsLabel ? ` · observed ${obsLabel}` : ""}
{w.stale && <span className="ml-1 bg-accent text-white rounded px-1 text-[9px] font-semibold" title="This station's latest report is over 2 h old">STALE</span>}
<SourceCredit credit={METAR_CREDIT}
updated={`fetched ${fetchedLabel(d.fetched_at) ?? "—"}${d.error ? " · feed error, showing the last good report" : ""}${state.error ? " · could not refresh" : ""}`} />
</li>
);
}
/** 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 = <span className="font-mono text-[10px] text-ink-muted mr-1.5">NWS</span>;
if (!box) return <li>{tag}no region to check (no nodes or geocoded incidents)</li>;
const problem = nwsProblem(state);
if (problem) return <li>{tag}{problem}</li>;
const d = state.data!;
const warnings = d.alerts.filter((a) => wxStyle(a.event).kind === "warning").length;
return (
<li>{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)</li>
);
}
// ── Level 1: region ──────────────────────────────────────────────────────────
export function RegionPanel({
incidents, calls, onAirCount, nodes, wallCameras, feeds, servingCount, aircraftCount, vesselCount,
@@ -244,7 +367,8 @@ export function RegionPanel({
<li key={p.id}><span className="font-mono text-[10px] text-ink-muted mr-1.5">CAM</span>{p.name}: {p.total_in_bbox} in region{p.error ? " · feed error" : ""}{p.fetched_at ? ` · list ${fetchedLabel(p.fetched_at)}` : ""}</li>
))}
<OutageFeedRow incidents={incidents} nodes={nodes} />
<li><span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span>Weather Radar layer (map menu); sampled per incident</li>
<WeatherFeedRow incidents={incidents} nodes={nodes} />
<li><span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span>Weather Radar layer (map menu, IEM n0q, with valid time); sampled per incident</li>
</ul>
</div>
<div className={SEC}>
@@ -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({
<li>
<span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span>
{!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)`}
</li>
<WeatherWarningContext head={head} />
{wantsWind(inc) && <WindContext head={head} />}
<OutageContext inc={inc} points={points} />
</ul>
{!ctx.aircraft.length && !ctx.vessels.length && !ctx.road.length && (