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:
co-authored by
Claude Opus 5.5
parent
1d6e79ba25
commit
1a84bf46d7
@@ -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'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 && (
|
||||
|
||||
Reference in New Issue
Block a user