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
@@ -25,13 +25,16 @@ import { useAircraftTrail } from "@/lib/useAircraftTrail";
|
||||
import { useVessels } from "@/lib/useVessels";
|
||||
import { use511 } from "@/lib/use511";
|
||||
import { useMapCameras } from "@/lib/useCameras";
|
||||
import type { Camera, CameraCredit, CameraProviderFeed, CamerasResponse, Ny511Event, Ny511FeedStatus } from "@/lib/types";
|
||||
import type {
|
||||
Camera, CameraCredit, CameraProviderFeed, CamerasResponse, Ny511Event, Ny511FeedStatus, WeatherAlert, WeatherAlertsResponse, WeatherGeometry,
|
||||
} from "@/lib/types";
|
||||
import { useMapWeatherAlerts, useRadarMeta, type Polled, type RadarMeta } from "@/lib/useWeather";
|
||||
import { CameraFeed } from "@/components/CameraFeed";
|
||||
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
|
||||
import {
|
||||
BASE_NAMES, DEFAULT_BASE, DEFAULT_LAYERS, LAYER_NAMES, NEAR_CAMERA_KM, baseKeyForName, groupCallsByIncident,
|
||||
MAY_BE_MERGED_KM, PATH_GAP_DASH_MIN, incidentPoints, layerKeyForName, normaliseLayers, pathStops, roundViewport,
|
||||
viewshedWedge, worstFirst,
|
||||
viewshedWedge, worstFirst, RADAR_LOOP_OFFSETS_MIN, RADAR_STALE_MIN, n0qColor, radarTileUrl, wxStyle, wxTime, WX_KIND_DASH,
|
||||
type BaseKey, type LayerKey, type MapViewport,
|
||||
} from "@/lib/liveWall";
|
||||
import { OutageLayer, outageGlyphSvg } from "@/components/map/OutageLayer";
|
||||
@@ -326,27 +329,198 @@ function VesselLayer() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Weather radar ────────────────────────────────────────────────────────────
|
||||
// ── Weather radar (IEM n0q; server-26#237) ───────────────────────────────────
|
||||
// IEM tiles are static once loaded, so refresh them in place with a
|
||||
// cache-buster. Never remount the layer (e.g. via `key`) to refresh it: a
|
||||
// remounted child of an unchecked LayersControl.Overlay is re-registered with
|
||||
// the control but not re-added to the map, which switched radar off every 5 min.
|
||||
const RADAR_URL = "https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0r-900913/{z}/{x}/{y}.png";
|
||||
// The layers live in a FeatureGroup that never remounts; the loop frames are
|
||||
// that group's children, so adding/removing them is ordinary group membership.
|
||||
// The buster is the composite's valid time when known (so tiles, badge and
|
||||
// incident sample move together), else a 5-min clock.
|
||||
const RADAR_REFRESH_MS = 5 * 60 * 1000;
|
||||
const RADAR_OPACITY = 0.65;
|
||||
const RADAR_ATTRIBUTION = 'Radar © <a href="https://mesonet.agron.iastate.edu/">IEM/NWS</a>';
|
||||
const LOOP_FRAME_MS = 600;
|
||||
const LOOP_HOLD_FRAMES = 3; // the newest frame is held this many ticks before the loop restarts
|
||||
const RADAR_LEGEND_DBZ = [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75];
|
||||
const RADAR_LEGEND_TICKS = [5, 40, 75]; // evenly spaced over RADAR_LEGEND_DBZ, so justify-between places them right
|
||||
|
||||
function RadarLayer() {
|
||||
const ref = useRef<L.TileLayer>(null);
|
||||
function radarBuster(valid: Date | null, clock: number): string {
|
||||
return `?_=${valid ? valid.getTime() : clock}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One n0q tile layer. react-leaflet 4 applies a changed `url` with setUrl on
|
||||
* the same layer (no remount), which is exactly the in-place refresh above.
|
||||
*/
|
||||
function RadarFrame({ offsetMin, buster, opacity }: { offsetMin: number; buster: string; opacity: number }) {
|
||||
return <TileLayer url={`${radarTileUrl(offsetMin)}${buster}`} attribution={RADAR_ATTRIBUTION} opacity={opacity} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Current composite, plus — while `loop` is on — the 11 earlier 5-min frames.
|
||||
* All frames stay loaded; only the shown one is opaque, so the loop plays
|
||||
* without re-fetching tiles every cycle.
|
||||
*/
|
||||
function RadarLayer({ valid, loop, frame }: { valid: Date | null; loop: boolean; frame: number }) {
|
||||
const [clock, setClock] = useState(() => Math.floor(Date.now() / RADAR_REFRESH_MS));
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => ref.current?.setUrl(`${RADAR_URL}?_=${Date.now()}`), RADAR_REFRESH_MS);
|
||||
if (valid) return; // the valid time drives refreshes while it is known
|
||||
const id = setInterval(() => setClock(Math.floor(Date.now() / RADAR_REFRESH_MS)), 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [valid]);
|
||||
const buster = radarBuster(valid, clock);
|
||||
const last = RADAR_LOOP_OFFSETS_MIN.length - 1;
|
||||
return (
|
||||
<>
|
||||
{loop && RADAR_LOOP_OFFSETS_MIN.slice(0, last).map((off, i) => (
|
||||
<RadarFrame key={off} offsetMin={off} buster={buster} opacity={i === frame ? RADAR_OPACITY : 0} />
|
||||
))}
|
||||
<RadarFrame offsetMin={0} buster={buster} opacity={!loop || frame >= last ? RADAR_OPACITY : 0} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function hhmmLocal(d: Date): string {
|
||||
return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
|
||||
/** Radar valid-time badge + loop toggle: what time the map's radar shows, and whether that is stale. */
|
||||
function RadarBadge({ meta, loop, frame, onLoop }: {
|
||||
meta: RadarMeta; loop: boolean; frame: number; onLoop: (on: boolean) => void;
|
||||
}) {
|
||||
const [, tick] = useState(0);
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => tick((t) => t + 1), 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
const ageMin = meta.valid ? Math.max(0, Math.round((Date.now() - meta.valid.getTime()) / 60_000)) : null;
|
||||
const stale = ageMin == null ? meta.loaded : ageMin > RADAR_STALE_MIN;
|
||||
const last = RADAR_LOOP_OFFSETS_MIN.length - 1;
|
||||
const off = loop ? RADAR_LOOP_OFFSETS_MIN[Math.min(frame, last)] : 0;
|
||||
let text: string;
|
||||
if (!meta.loaded) text = "Radar: checking valid time…";
|
||||
else if (!meta.valid) text = `Radar: valid time unknown${meta.error ? " (IEM metadata unreachable)" : ""}`;
|
||||
else if (loop && off > 0) text = `Radar loop: ${hhmmLocal(new Date(meta.valid.getTime() - off * 60_000))} (−${off} min)`;
|
||||
else text = `Radar valid ${hhmmLocal(meta.valid)} · ${ageMin} min old`;
|
||||
return (
|
||||
<TileLayer
|
||||
ref={ref}
|
||||
url={RADAR_URL}
|
||||
attribution='Radar © <a href="https://mesonet.agron.iastate.edu/">IEM/NWS</a>'
|
||||
opacity={0.65}
|
||||
/>
|
||||
<div className={`flex items-center gap-1.5 bg-surface/90 border rounded px-2 py-1 text-xs text-ink-2 pointer-events-auto ${stale ? "border-accent" : "border-line"}`}>
|
||||
{stale && <span className="bg-accent text-white rounded px-1 text-[9px] font-semibold tracking-wide"
|
||||
title={`The newest radar frame is more than ${RADAR_STALE_MIN} min old, or its time could not be read`}>STALE</span>}
|
||||
<span className="tabular-nums whitespace-nowrap">{text}</span>
|
||||
{meta.valid && meta.error && <span className="text-ink-muted whitespace-nowrap" title={meta.error}>· time not refreshing</span>}
|
||||
<button type="button" onClick={() => onLoop(!loop)}
|
||||
title={loop ? "Stop the loop and show the newest frame" : "Play the last hour of radar in 5-min frames"}
|
||||
className="ml-1 px-1.5 rounded border border-line text-[10px] hover:bg-raised whitespace-nowrap">
|
||||
{loop ? "■ Stop loop" : "▶ 1 h loop"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── NWS weather warnings (server-26#237) ─────────────────────────────────────
|
||||
const NWS_CREDIT: CameraCredit = { attribution: "National Weather Service", attribution_url: "https://www.weather.gov/", disclaimer: null };
|
||||
|
||||
/** GeoJSON (Multi)Polygon → Leaflet positions ([lon, lat] → [lat, lon]). */
|
||||
function wxPositions(g: WeatherGeometry): L.LatLngExpression[][] | L.LatLngExpression[][][] {
|
||||
const ring = (r: number[][]) => r.map((p) => [p[1], p[0]] as L.LatLngTuple);
|
||||
return g.type === "Polygon" ? g.coordinates.map(ring) : g.coordinates.map((poly) => poly.map(ring));
|
||||
}
|
||||
|
||||
function WeatherAlertPopupBody({ a, fetchedAt }: { a: WeatherAlert; fetchedAt: number | null }) {
|
||||
const expires = wxTime(a.expires);
|
||||
const ends = wxTime(a.ends);
|
||||
const onset = wxTime(a.onset);
|
||||
return (
|
||||
<div className="space-y-1 text-xs">
|
||||
<div className="font-semibold text-sm">{a.event}</div>
|
||||
{/* Shown as NWS issued it — never summarised or rewritten. */}
|
||||
{a.headline && <div className="leading-snug">{a.headline}</div>}
|
||||
<div>
|
||||
{onset ? `From ${onset}` : ""}{onset && (expires || ends) ? " · " : ""}
|
||||
{ends ? `ends ${ends}` : ""}{ends && expires ? " · " : ""}{expires ? `expires ${expires}` : ""}
|
||||
</div>
|
||||
{a.area_desc && <div className="text-ink-muted leading-snug">{a.area_desc}</div>}
|
||||
{a.sender_name && <div className="text-ink-muted">Issued by {a.sender_name}</div>}
|
||||
{a.geometry_source === "zones" && (
|
||||
<div className="text-ink-muted leading-snug">
|
||||
Area drawn from the alert's forecast/county zones{a.zones_unresolved ? ` — ${a.zones_unresolved} zone${a.zones_unresolved !== 1 ? "s" : ""} could not be fetched, so the real area is larger` : ""}.
|
||||
</div>
|
||||
)}
|
||||
<SourceCredit credit={NWS_CREDIT} updated={fetchedLabel(fetchedAt) ? `fetched ${fetchedLabel(fetchedAt)}` : null} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WeatherWarningsLayer({ data }: { data: WeatherAlertsResponse | null }) {
|
||||
// Weakest first so warnings draw on top (and win the click where they overlap).
|
||||
const alerts = useMemo(() => {
|
||||
const rank = { statement: 0, advisory: 1, watch: 2, warning: 3 } as const;
|
||||
return (data?.alerts ?? []).slice().sort((a, b) => rank[wxStyle(a.event).kind] - rank[wxStyle(b.event).kind]);
|
||||
}, [data]);
|
||||
return (
|
||||
<>
|
||||
{alerts.map((a) => {
|
||||
const st = wxStyle(a.event);
|
||||
return (
|
||||
<Polygon key={a.id} positions={wxPositions(a.geometry)}
|
||||
pathOptions={{ color: st.color, weight: st.weight, dashArray: st.dashArray, fillColor: st.color, fillOpacity: st.kind === "warning" ? 0.16 : 0.07 }}>
|
||||
<Tooltip sticky>{a.event}{wxTime(a.expires) ? ` · until ${wxTime(a.expires)}` : ""}</Tooltip>
|
||||
<Popup minWidth={220} maxWidth={340}>
|
||||
<WeatherAlertPopupBody a={a} fetchedAt={data?.fetched_at ?? null} />
|
||||
</Popup>
|
||||
</Polygon>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Always-present status for the Weather Warnings overlay: never a silently
|
||||
* empty layer. Feed problems use the 511 FeedProblem wording ("could not reach
|
||||
* server" / "feed error, showing data from N min ago"); zone-only alerts —
|
||||
* no shape to draw — are listed here, not dropped.
|
||||
*/
|
||||
function WeatherWarningsStatus({ state }: { state: Polled<WeatherAlertsResponse> }) {
|
||||
const { data, error, loaded } = state;
|
||||
const zoneOnly = data?.zone_only ?? [];
|
||||
let msg: string;
|
||||
let problem = false;
|
||||
if (!loaded) msg = "Weather Warnings: loading…";
|
||||
else if (!data) { msg = "Weather Warnings: could not reach server"; problem = true; }
|
||||
else if (data.error) {
|
||||
problem = true;
|
||||
msg = data.fetched_at
|
||||
? `Weather Warnings: NWS feed error, showing data from ${Math.round((Date.now() / 1000 - data.fetched_at) / 60)} min ago`
|
||||
: "Weather Warnings: NWS feed error, no data yet";
|
||||
} else {
|
||||
const n = data.alerts.length;
|
||||
msg = n ? `Weather Warnings: ${n} active in view` : "Weather Warnings: no active NWS alert with a mapped area in view";
|
||||
}
|
||||
if (data && error) msg += " · could not refresh";
|
||||
return (
|
||||
<div className={`bg-surface/90 border rounded px-2 py-1 text-xs text-ink-2 pointer-events-auto max-w-full ${problem ? "border-accent" : "border-line"}`}>
|
||||
<div>
|
||||
{msg}
|
||||
{data?.fetched_at && !data.error ? ` · NWS, fetched ${fetchedLabel(data.fetched_at)}` : ""}
|
||||
</div>
|
||||
{zoneOnly.length > 0 && (
|
||||
<details className="mt-0.5">
|
||||
<summary className="cursor-pointer select-none text-ink-muted">
|
||||
{zoneOnly.length} alert{zoneOnly.length !== 1 ? "s" : ""} in the served area with no mapped shape (zone-based)
|
||||
</summary>
|
||||
<ul className="mt-1 space-y-0.5 max-h-32 overflow-y-auto">
|
||||
{zoneOnly.map((a) => (
|
||||
<li key={a.id} className="leading-snug">
|
||||
<b>{a.event}</b>{a.area_desc ? ` · ${a.area_desc}` : ""}{wxTime(a.expires) ? ` · until ${wxTime(a.expires)}` : ""}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1353,6 +1527,10 @@ export default function MapView({
|
||||
const layerOn = (k: LayerKey) => startLayers.has(k);
|
||||
const [aircraftShown, setAircraftShown] = useState(() => startLayers.has("air"));
|
||||
const [outagesShown, setOutagesShown] = useState(false);
|
||||
const [radarShown, setRadarShown] = useState(() => startLayers.has("radar"));
|
||||
const [wxShown, setWxShown] = useState(() => startLayers.has("wx"));
|
||||
const [radarLoop, setRadarLoop] = useState(false); // off by default (#237); not persisted
|
||||
const [loopFrame, setLoopFrame] = useState(0);
|
||||
|
||||
// The altitude key only belongs in the legend while the opt-in Aircraft
|
||||
// overlay is actually on (server-26#185); same for the outage key (#184).
|
||||
@@ -1361,10 +1539,14 @@ export default function MapView({
|
||||
const on = (e: L.LayersControlEvent) => {
|
||||
if (e.name === LAYER_NAMES.air) setAircraftShown(true);
|
||||
if (e.name === OVERLAY_OUTAGES) setOutagesShown(true);
|
||||
if (e.name === LAYER_NAMES.radar) setRadarShown(true);
|
||||
if (e.name === LAYER_NAMES.wx) setWxShown(true);
|
||||
};
|
||||
const off = (e: L.LayersControlEvent) => {
|
||||
if (e.name === LAYER_NAMES.air) setAircraftShown(false);
|
||||
if (e.name === OVERLAY_OUTAGES) setOutagesShown(false);
|
||||
if (e.name === LAYER_NAMES.radar) setRadarShown(false);
|
||||
if (e.name === LAYER_NAMES.wx) setWxShown(false);
|
||||
};
|
||||
mapInstance.on("overlayadd", on);
|
||||
mapInstance.on("overlayremove", off);
|
||||
@@ -1379,6 +1561,24 @@ export default function MapView({
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
// ── Weather (server-26#237): fetched only while the overlay is on ─────────
|
||||
const radarMeta = useRadarMeta(radarShown);
|
||||
const wxAlerts = useMapWeatherAlerts(mapInstance, wxShown);
|
||||
const loopOn = radarShown && radarLoop;
|
||||
useEffect(() => {
|
||||
if (!loopOn) { setLoopFrame(0); return; }
|
||||
const last = RADAR_LOOP_OFFSETS_MIN.length - 1;
|
||||
let tick = 0;
|
||||
const cycle = last + LOOP_HOLD_FRAMES;
|
||||
setLoopFrame(0);
|
||||
const id = setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
tick = (tick + 1) % cycle;
|
||||
setLoopFrame(Math.min(tick, last));
|
||||
}, LOOP_FRAME_MS);
|
||||
return () => clearInterval(id);
|
||||
}, [loopOn]);
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
|
||||
|
||||
@@ -1588,9 +1788,18 @@ export default function MapView({
|
||||
</FeatureGroup>
|
||||
</LayersControl.Overlay>
|
||||
|
||||
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet */}
|
||||
{/* Overlay: Weather Radar — NEXRAD n0q via Iowa Env Mesonet (server-26#237) */}
|
||||
<LayersControl.Overlay checked={layerOn("radar")} name={LAYER_NAMES.radar}>
|
||||
<RadarLayer />
|
||||
<FeatureGroup>
|
||||
<RadarLayer valid={radarMeta.valid} loop={loopOn} frame={loopFrame} />
|
||||
</FeatureGroup>
|
||||
</LayersControl.Overlay>
|
||||
|
||||
{/* Overlay: NWS watches/warnings/advisories (server-26#237) */}
|
||||
<LayersControl.Overlay checked={layerOn("wx")} name={LAYER_NAMES.wx}>
|
||||
<FeatureGroup>
|
||||
<WeatherWarningsLayer data={wxAlerts.data} />
|
||||
</FeatureGroup>
|
||||
</LayersControl.Overlay>
|
||||
</LayersControl>
|
||||
</InitialOverlays.Provider>
|
||||
@@ -1609,6 +1818,15 @@ export default function MapView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Weather status (server-26#237) — bottom centre, clear of the rail
|
||||
(left), the legend (right) and the 511/outage banners (bottom-left). */}
|
||||
{(radarShown || wxShown) && (
|
||||
<div className="absolute bottom-10 md:bottom-8 left-1/2 -translate-x-1/2 z-[1001] flex flex-col items-center gap-1 w-max max-w-[min(30rem,calc(100%-1.5rem))] pointer-events-none">
|
||||
{wxShown && <WeatherWarningsStatus state={wxAlerts} />}
|
||||
{radarShown && <RadarBadge meta={radarMeta} loop={radarLoop} frame={loopFrame} onLoop={setRadarLoop} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Map action buttons — top-left, below zoom controls ──────────────── */}
|
||||
<div className="absolute top-[4.5rem] left-3 z-[1002] flex flex-col gap-1">
|
||||
{mapInstance && allPositions.length > 0 && (
|
||||
@@ -1706,6 +1924,33 @@ export default function MapView({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{radarShown && (
|
||||
<div className="border-t border-line pt-1.5 space-y-1">
|
||||
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Radar reflectivity (dBZ)</p>
|
||||
<div
|
||||
className="h-2 w-32 rounded-sm border border-line"
|
||||
style={{ background: `linear-gradient(to right, ${RADAR_LEGEND_DBZ.map((d, i) => `${n0qColor(d)} ${(i / (RADAR_LEGEND_DBZ.length - 1)) * 100}%`).join(", ")})` }}
|
||||
/>
|
||||
<div className="flex justify-between w-32 text-[10px] text-ink-2 tabular-nums">
|
||||
{RADAR_LEGEND_TICKS.map((d) => <span key={d}>{d}</span>)}
|
||||
</div>
|
||||
<p className="text-[10px] text-ink-muted w-32 leading-snug">~20 light · 40 moderate · 50+ heavy rain / hail</p>
|
||||
</div>
|
||||
)}
|
||||
{wxShown && (
|
||||
<div className="border-t border-line pt-1.5 space-y-1">
|
||||
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">NWS alerts</p>
|
||||
{(["warning", "watch", "advisory"] as const).map((k) => (
|
||||
<div key={k} className="flex items-center gap-2">
|
||||
<span style={{ width: 16, display: "inline-flex", justifyContent: "center" }}>
|
||||
<svg width="16" height="10" viewBox="0 0 16 10"><rect x="1" y="1" width="14" height="8" fill="none" stroke="var(--ink-2)" strokeWidth={k === "warning" ? 2 : 1.5} strokeDasharray={WX_KIND_DASH[k]} /></svg>
|
||||
</span>
|
||||
<span className="text-ink-2">{k === "warning" ? "Warning" : k === "watch" ? "Watch" : "Advisory / statement"}</span>
|
||||
</div>
|
||||
))}
|
||||
<p className="text-[10px] text-ink-muted w-32 leading-snug">Fill colour = NWS hazard-map colour for the event</p>
|
||||
</div>
|
||||
)}
|
||||
{outagesShown && (
|
||||
<div className="border-t border-line pt-1.5 space-y-1">
|
||||
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Power outages</p>
|
||||
|
||||
@@ -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