Merge pull request 'Weather map layers: NWS warning polygons, n0q radar with valid time/legend/loop, wind at incident (#237)' (#277) from feat/237-weather-map into main
This commit was merged in pull request #277.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import {
|
import {
|
||||||
FeatureGroup,
|
FeatureGroup,
|
||||||
@@ -25,13 +25,16 @@ import { useAircraftTrail } from "@/lib/useAircraftTrail";
|
|||||||
import { useVessels } from "@/lib/useVessels";
|
import { useVessels } from "@/lib/useVessels";
|
||||||
import { use511 } from "@/lib/use511";
|
import { use511 } from "@/lib/use511";
|
||||||
import { useMapCameras } from "@/lib/useCameras";
|
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 { CameraFeed } from "@/components/CameraFeed";
|
||||||
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
|
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
|
||||||
import {
|
import {
|
||||||
BASE_NAMES, DEFAULT_BASE, DEFAULT_LAYERS, LAYER_NAMES, NEAR_CAMERA_KM, baseKeyForName, groupCallsByIncident,
|
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,
|
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,
|
type BaseKey, type LayerKey, type MapViewport,
|
||||||
} from "@/lib/liveWall";
|
} from "@/lib/liveWall";
|
||||||
import { OutageLayer, outageGlyphSvg } from "@/components/map/OutageLayer";
|
import { OutageLayer, outageGlyphSvg } from "@/components/map/OutageLayer";
|
||||||
@@ -326,27 +329,232 @@ function VesselLayer() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Weather radar ────────────────────────────────────────────────────────────
|
// ── Weather radar (IEM n0q; server-26#237) ───────────────────────────────────
|
||||||
// IEM tiles are static once loaded, so refresh them in place with a
|
// 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
|
// 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
|
// 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.
|
// 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_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);
|
* The loop's current frame, kept OUTSIDE MapView's state: it ticks every
|
||||||
|
* LOOP_FRAME_MS, and a MapView re-render rebuilds every marker icon on the map
|
||||||
|
* (setIcon on each), so only the radar frames and the badge subscribe to it.
|
||||||
|
*/
|
||||||
|
function createLoopClock() {
|
||||||
|
let frame = 0;
|
||||||
|
let id: ReturnType<typeof setInterval> | undefined;
|
||||||
|
const subs = new Set<() => void>();
|
||||||
|
const set = (f: number) => { if (f !== frame) { frame = f; subs.forEach((s) => s()); } };
|
||||||
|
return {
|
||||||
|
subscribe: (cb: () => void) => { subs.add(cb); return () => { subs.delete(cb); }; },
|
||||||
|
get: () => frame,
|
||||||
|
run(on: boolean) {
|
||||||
|
clearInterval(id);
|
||||||
|
id = undefined;
|
||||||
|
set(0);
|
||||||
|
if (!on) return;
|
||||||
|
const last = RADAR_LOOP_OFFSETS_MIN.length - 1;
|
||||||
|
const cycle = last + LOOP_HOLD_FRAMES;
|
||||||
|
let tick = 0;
|
||||||
|
id = setInterval(() => {
|
||||||
|
if (document.hidden) return;
|
||||||
|
tick = (tick + 1) % cycle;
|
||||||
|
set(Math.min(tick, last));
|
||||||
|
}, LOOP_FRAME_MS);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
type LoopClock = ReturnType<typeof createLoopClock>;
|
||||||
|
const useLoopFrame = (c: LoopClock) => useSyncExternalStore(c.subscribe, c.get, c.get);
|
||||||
|
|
||||||
|
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, loopClock }: { valid: Date | null; loop: boolean; loopClock: LoopClock }) {
|
||||||
|
const frame = useLoopFrame(loopClock);
|
||||||
|
const [clock, setClock] = useState(() => Math.floor(Date.now() / RADAR_REFRESH_MS));
|
||||||
useEffect(() => {
|
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, loopClock, onLoop }: {
|
||||||
|
meta: RadarMeta; loop: boolean; loopClock: LoopClock; onLoop: (on: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const frame = useLoopFrame(loopClock);
|
||||||
|
const [, tick] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const id = setInterval(() => tick((t) => t + 1), 30_000);
|
||||||
return () => clearInterval(id);
|
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 (
|
return (
|
||||||
<TileLayer
|
<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"}`}>
|
||||||
ref={ref}
|
{stale && <span className="bg-accent text-white rounded px-1 text-[9px] font-semibold tracking-wide"
|
||||||
url={RADAR_URL}
|
title={`The newest radar frame is more than ${RADAR_STALE_MIN} min old, or its time could not be read`}>STALE</span>}
|
||||||
attribution='Radar © <a href="https://mesonet.agron.iastate.edu/">IEM/NWS</a>'
|
<span className="tabular-nums whitespace-nowrap">{text}</span>
|
||||||
opacity={0.65}
|
{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 +1561,10 @@ export default function MapView({
|
|||||||
const layerOn = (k: LayerKey) => startLayers.has(k);
|
const layerOn = (k: LayerKey) => startLayers.has(k);
|
||||||
const [aircraftShown, setAircraftShown] = useState(() => startLayers.has("air"));
|
const [aircraftShown, setAircraftShown] = useState(() => startLayers.has("air"));
|
||||||
const [outagesShown, setOutagesShown] = useState(false);
|
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 loopClock = useMemo(createLoopClock, []);
|
||||||
|
|
||||||
// The altitude key only belongs in the legend while the opt-in Aircraft
|
// 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).
|
// overlay is actually on (server-26#185); same for the outage key (#184).
|
||||||
@@ -1361,10 +1573,14 @@ export default function MapView({
|
|||||||
const on = (e: L.LayersControlEvent) => {
|
const on = (e: L.LayersControlEvent) => {
|
||||||
if (e.name === LAYER_NAMES.air) setAircraftShown(true);
|
if (e.name === LAYER_NAMES.air) setAircraftShown(true);
|
||||||
if (e.name === OVERLAY_OUTAGES) setOutagesShown(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) => {
|
const off = (e: L.LayersControlEvent) => {
|
||||||
if (e.name === LAYER_NAMES.air) setAircraftShown(false);
|
if (e.name === LAYER_NAMES.air) setAircraftShown(false);
|
||||||
if (e.name === OVERLAY_OUTAGES) setOutagesShown(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("overlayadd", on);
|
||||||
mapInstance.on("overlayremove", off);
|
mapInstance.on("overlayremove", off);
|
||||||
@@ -1379,6 +1595,15 @@ export default function MapView({
|
|||||||
return () => clearInterval(id);
|
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(() => {
|
||||||
|
loopClock.run(loopOn);
|
||||||
|
return () => loopClock.run(false);
|
||||||
|
}, [loopClock, loopOn]);
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
|
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
|
||||||
|
|
||||||
@@ -1588,9 +1813,18 @@ export default function MapView({
|
|||||||
</FeatureGroup>
|
</FeatureGroup>
|
||||||
</LayersControl.Overlay>
|
</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}>
|
<LayersControl.Overlay checked={layerOn("radar")} name={LAYER_NAMES.radar}>
|
||||||
<RadarLayer />
|
<FeatureGroup>
|
||||||
|
<RadarLayer valid={radarMeta.valid} loop={loopOn} loopClock={loopClock} />
|
||||||
|
</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.Overlay>
|
||||||
</LayersControl>
|
</LayersControl>
|
||||||
</InitialOverlays.Provider>
|
</InitialOverlays.Provider>
|
||||||
@@ -1609,6 +1843,15 @@ export default function MapView({
|
|||||||
</div>
|
</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} loopClock={loopClock} onLoop={setRadarLoop} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ── Map action buttons — top-left, below zoom controls ──────────────── */}
|
{/* ── Map action buttons — top-left, below zoom controls ──────────────── */}
|
||||||
<div className="absolute top-[4.5rem] left-3 z-[1002] flex flex-col gap-1">
|
<div className="absolute top-[4.5rem] left-3 z-[1002] flex flex-col gap-1">
|
||||||
{mapInstance && allPositions.length > 0 && (
|
{mapInstance && allPositions.length > 0 && (
|
||||||
@@ -1706,6 +1949,33 @@ export default function MapView({
|
|||||||
</div>
|
</div>
|
||||||
</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 && (
|
{outagesShown && (
|
||||||
<div className="border-t border-line pt-1.5 space-y-1">
|
<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>
|
<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 { InlinePlayer } from "@/components/InlinePlayer";
|
||||||
import { canPlayAudio, hasAudio, playIncident, stop as stopAudio, useAudio } from "@/lib/audioQueue";
|
import { canPlayAudio, hasAudio, playIncident, stop as stopAudio, useAudio } from "@/lib/audioQueue";
|
||||||
import {
|
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,
|
type LatLon, type NearCamera,
|
||||||
} from "@/lib/liveWall";
|
} 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 { useOutagesIn, type OutagesState } from "@/lib/useOutages";
|
||||||
import { OUTAGE_POINT_KM, countyName, isUtilityHazard, matchOutages, staleCountiesAt } from "@/lib/outageMatch";
|
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 ──────────────────────────────────────────────────────────
|
// ── Level 1: region ──────────────────────────────────────────────────────────
|
||||||
export function RegionPanel({
|
export function RegionPanel({
|
||||||
incidents, calls, onAirCount, nodes, wallCameras, feeds, servingCount, aircraftCount, vesselCount,
|
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>
|
<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} />
|
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className={SEC}>
|
<div className={SEC}>
|
||||||
@@ -299,7 +423,8 @@ export function IncidentPanel({
|
|||||||
}) {
|
}) {
|
||||||
const color = sevColor(inc.severity);
|
const color = sevColor(inc.severity);
|
||||||
const ctx = useMemo(() => contextFor(points, aircraft, vessels, events), [points, aircraft, vessels, events]);
|
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
|
// 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
|
// spine numbers exactly what the map numbers (server-26#236): only movement
|
||||||
// incidents get numbers, and a ghosted (impossible-speed) stop gets none.
|
// incidents get numbers, and a ghosted (impossible-speed) stop gets none.
|
||||||
@@ -394,8 +519,11 @@ export function IncidentPanel({
|
|||||||
<li>
|
<li>
|
||||||
<span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span>
|
<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"
|
{!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>
|
</li>
|
||||||
|
<WeatherWarningContext head={head} />
|
||||||
|
{wantsWind(inc) && <WindContext head={head} />}
|
||||||
<OutageContext inc={inc} points={points} />
|
<OutageContext inc={inc} points={points} />
|
||||||
</ul>
|
</ul>
|
||||||
{!ctx.aircraft.length && !ctx.vessels.length && !ctx.road.length && (
|
{!ctx.aircraft.length && !ctx.vessels.length && !ctx.road.length && (
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { auth } from "@/lib/firebase";
|
import { auth } from "@/lib/firebase";
|
||||||
import type {
|
import type {
|
||||||
AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, OutageProviderAdmin, OutagesResponse, TalkgroupPending,
|
AreaContext, CameraProviderAdmin, CamerasResponse, Ny511Response, OutageProviderAdmin, OutagesResponse, TalkgroupPending,
|
||||||
|
WeatherAlertsResponse, WindResponse,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
import type { DiscordAdminOverview, DiscordStream, RadioBot, RadioBotAddResult } from "@/lib/discord";
|
import type { DiscordAdminOverview, DiscordStream, RadioBot, RadioBotAddResult } from "@/lib/discord";
|
||||||
|
|
||||||
@@ -532,6 +533,16 @@ export const c2api = {
|
|||||||
body: JSON.stringify({ enabled, acknowledge_licence: acknowledgeLicence }),
|
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
|
// Public waitlist — no auth, see routers/waitlist.py
|
||||||
joinWaitlist: (body: { email: string; org_name?: string; note?: string }) =>
|
joinWaitlist: (body: { email: string; org_name?: string; note?: string }) =>
|
||||||
request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }),
|
request<{ ok: boolean }>("/waitlist", { method: "POST", body: JSON.stringify(body) }),
|
||||||
|
|||||||
+235
-24
@@ -3,7 +3,9 @@
|
|||||||
* gods-eye-view-notes.md top 5). No React here, so the logic is testable in
|
* gods-eye-view-notes.md top 5). No React here, so the logic is testable in
|
||||||
* isolation and shared by MapView and LiveView.
|
* 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";
|
import { severityRank } from "@/lib/severity";
|
||||||
|
|
||||||
export type LatLon = [number, number];
|
export type LatLon = [number, number];
|
||||||
@@ -261,7 +263,7 @@ export function bboxAround(points: LatLon[], padKm: number) {
|
|||||||
|
|
||||||
// ── Deep links / saved views ────────────────────────────────────────────────
|
// ── Deep links / saved views ────────────────────────────────────────────────
|
||||||
// #inc=<id>&cam=<cameraId>&tour&tourMs=30000&kiosk
|
// #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>
|
// &f.<name>=<value>
|
||||||
// Every key is optional; unknown keys are ignored, so an old link (inc/cam/tour
|
// 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
|
// 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.)",
|
vs: "Camera Viewsheds (approx.)",
|
||||||
traffic: "Traffic Events",
|
traffic: "Traffic Events",
|
||||||
radar: "Weather Radar",
|
radar: "Weather Radar",
|
||||||
|
wx: "Weather Warnings",
|
||||||
} as const;
|
} as const;
|
||||||
export type LayerKey = keyof typeof LAYER_NAMES;
|
export type LayerKey = keyof typeof LAYER_NAMES;
|
||||||
export const LAYER_KEYS = Object.keys(LAYER_NAMES) as LayerKey[];
|
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 ───────────────────────────────────────────
|
// ── NEXRAD (IEM n0q composite) ───────────────────────────────────────────────
|
||||||
// Reads the same IEM n0r composite tile the Weather Radar layer draws (IEM
|
// The Weather Radar layer, its valid-time badge, its legend and the per-incident
|
||||||
// sends ACAO: *), and maps pixel colours back to the NWS reflectivity scale.
|
// sample all use ONE product: IEM's n0q (base reflectivity, 0.5 dBZ steps,
|
||||||
// Approximate by construction — label it so.
|
// -32..95 dBZ). Verified first-hand 2026-10-05:
|
||||||
const N0R_PALETTE: [number, number, number, number][] = [
|
// tiles https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0q-900913/{z}/{x}/{y}.png
|
||||||
[5, 4, 233, 231], [10, 1, 159, 244], [15, 3, 0, 244], [20, 2, 253, 2], [25, 1, 197, 1], [30, 0, 142, 0],
|
// (RGBA PNG, ACAO: *), and nexrad-n0q-900913-m05m … -m55m for the
|
||||||
[35, 253, 248, 2], [40, 229, 188, 0], [45, 253, 149, 0], [50, 253, 0, 0], [55, 212, 0, 0], [60, 188, 0, 0],
|
// 5-minute frames of the last hour (-m60m is a 404);
|
||||||
[65, 248, 0, 253], [70, 152, 84, 198], [75, 253, 253, 253],
|
// valid https://mesonet.agron.iastate.edu/data/gis/images/4326/USCOMP/n0q_0.json
|
||||||
];
|
// → {"meta": {"valid": "2026-10-05T07:35:00Z", "product": "N0Q", …}}
|
||||||
const RADAR_TILE = "https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0r-900913";
|
// (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 {
|
/** Tile URL for the composite `offsetMin` minutes before the current one (0 = current). */
|
||||||
let best = 0;
|
export function radarTileUrl(offsetMin = 0): string {
|
||||||
let bestD = Infinity;
|
const layer = offsetMin > 0 ? `${RADAR_TILE_BASE}-m${String(offsetMin).padStart(2, "0")}m` : RADAR_TILE_BASE;
|
||||||
for (const [dbz, pr, pg, pb] of N0R_PALETTE) {
|
return `${layer}/{z}/{x}/{y}.png`;
|
||||||
const d = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2;
|
}
|
||||||
|
|
||||||
|
/** 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; }
|
if (d < bestD) { bestD = d; best = dbz; }
|
||||||
}
|
});
|
||||||
return bestD < 60 * 60 ? best : 0;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Max reflectivity (dBZ) within ~radiusKm of a point, or null if it can't be read. */
|
/** 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> {
|
export async function sampleRadarDbz(lat: number, lon: number, radiusKm = 5): Promise<number | null> {
|
||||||
const z = 9;
|
const z = 9;
|
||||||
const n = 2 ** z;
|
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 ty = Math.floor(yf);
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.crossOrigin = "anonymous";
|
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 {
|
try {
|
||||||
await img.decode();
|
await img.decode();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -466,10 +544,143 @@ export async function sampleRadarDbz(lat: number, lon: number, radiusKm = 5): Pr
|
|||||||
} catch {
|
} catch {
|
||||||
return null; // tainted canvas — CORS changed upstream
|
return null; // tainted canvas — CORS changed upstream
|
||||||
}
|
}
|
||||||
let max = 0;
|
let max = NO_ECHO_DBZ;
|
||||||
for (let i = 0; i < data.length; i += 4) {
|
for (let i = 0; i < data.length; i += 4) {
|
||||||
if (data[i + 3] < 32) continue;
|
if (data[i + 3] < 128) continue; // transparent: nothing drawn
|
||||||
max = Math.max(max, pixelDbz(data[i], data[i + 1], data[i + 2]));
|
const dbz = pixelDbz(data[i], data[i + 1], data[i + 2]);
|
||||||
|
if (dbz != null && dbz > max) max = dbz;
|
||||||
}
|
}
|
||||||
return max;
|
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];
|
||||||
|
}
|
||||||
|
|||||||
@@ -319,6 +319,77 @@ export interface Ny511Response {
|
|||||||
credit?: CameraCredit;
|
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 {
|
export interface VocabularyPendingTerm {
|
||||||
term: string;
|
term: string;
|
||||||
source: "induction" | "correction";
|
source: "induction" | "correction";
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user