- 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>
687 lines
30 KiB
TypeScript
687 lines
30 KiB
TypeScript
/**
|
||
* Live ops wall — pure helpers (design-canvas/live-ops-wall-mockup.html,
|
||
* gods-eye-view-notes.md top 5). No React here, so the logic is testable in
|
||
* isolation and shared by MapView and LiveView.
|
||
*/
|
||
import type {
|
||
AircraftTrack, CallRecord, Camera, IncidentRecord, Ny511Event, VesselTrack, WeatherAlert, WeatherGeometry,
|
||
} from "@/lib/types";
|
||
import { severityRank } from "@/lib/severity";
|
||
|
||
export type LatLon = [number, number];
|
||
|
||
export function km(a: LatLon, b: LatLon): number {
|
||
const R = 6371;
|
||
const dLa = ((b[0] - a[0]) * Math.PI) / 180;
|
||
const dLo = ((b[1] - a[1]) * Math.PI) / 180;
|
||
const x =
|
||
Math.sin(dLa / 2) ** 2 +
|
||
Math.cos((a[0] * Math.PI) / 180) * Math.cos((b[0] * Math.PI) / 180) * Math.sin(dLo / 2) ** 2;
|
||
return 2 * R * Math.asin(Math.sqrt(x));
|
||
}
|
||
|
||
/** Calls grouped by incident id (multi-scene calls count toward each incident). */
|
||
export function groupCallsByIncident(calls: CallRecord[]): Map<string, CallRecord[]> {
|
||
const map = new Map<string, CallRecord[]>();
|
||
for (const c of calls) {
|
||
const ids = c.incident_ids?.length ? c.incident_ids : c.incident_id ? [c.incident_id] : [];
|
||
for (const id of ids) {
|
||
if (!map.has(id)) map.set(id, []);
|
||
map.get(id)!.push(c);
|
||
}
|
||
}
|
||
return map;
|
||
}
|
||
|
||
/**
|
||
* An incident's geography, oldest first: its geocoded call stops (the path —
|
||
* same order as the call spine) or, failing that, its pin. Empty = not plotted.
|
||
* Ghosted stops (an implied speed no vehicle makes, server-26#236) are left
|
||
* out, so one bad geocode can't drag "nearest cameras" across the county.
|
||
*/
|
||
export function incidentPoints(inc: IncidentRecord, calls: CallRecord[] | undefined): LatLon[] {
|
||
const stops = pathStops(inc, calls).stops.filter((s) => !s.ghost).map((s) => s.at);
|
||
if (stops.length >= 2) return stops;
|
||
if (inc.location_coords) return [[inc.location_coords.lat, inc.location_coords.lng]];
|
||
return stops;
|
||
}
|
||
|
||
// ── Honest paths (server-26#236) ─────────────────────────────────────────────
|
||
// Pure, and shared by MapView and the call spine (WallPanels), so a stop's
|
||
// number is identical on both — and only what the map numbers is numbered.
|
||
|
||
/**
|
||
* Mirrors `_PURSUIT_TAGS` in drb-c2-core/app/internal/incident_correlator.py.
|
||
* Copied, not imported: keep the two in step by hand.
|
||
*/
|
||
export const PURSUIT_TAGS: ReadonlySet<string> = new Set([
|
||
"vehicle-pursuit", "pursuit", "foot-pursuit", "chase",
|
||
"fleeing-vehicle", "suspect-vehicle", "eluding",
|
||
]);
|
||
|
||
/** Mirrors `_MAX_PURSUIT_SPEED_KM_PER_MIN` (incident_correlator.py) — ~300 km/h. */
|
||
export const MAX_PATH_SPEED_KM_PER_MIN = 8.0;
|
||
/**
|
||
* Mirrors settings.location_proximity_km: the backend only runs its speed
|
||
* check past this distance, so two geocodes of one scene a few seconds apart
|
||
* are never called impossible.
|
||
*/
|
||
export const SPEED_CHECK_MIN_KM = 0.5;
|
||
/** A segment spanning a longer silence than this is drawn dashed — we don't know the route. */
|
||
export const PATH_GAP_DASH_MIN = 10;
|
||
/** Mirrors `_PURSUIT_PROXIMITY_KM`: a path longer than a pursuit's own link radius is a merge suspect. */
|
||
export const MAY_BE_MERGED_KM = 20;
|
||
|
||
/** Police or accident incident carrying a pursuit tag — the only kind that draws a numbered path. */
|
||
export function isMovementIncident(inc: Pick<IncidentRecord, "type" | "tags">): boolean {
|
||
if (inc.type !== "police" && inc.type !== "accident") return false;
|
||
return (inc.tags ?? []).some((t) => PURSUIT_TAGS.has(t.toLowerCase()));
|
||
}
|
||
|
||
export interface PathStop {
|
||
call: CallRecord;
|
||
at: LatLon;
|
||
/** Shared map/spine stop number; null = not numbered (ghosted, or not a movement incident). */
|
||
n: number | null;
|
||
/** Reaching this stop from the previous kept one implies > MAX_PATH_SPEED_KM_PER_MIN. */
|
||
ghost: boolean;
|
||
/** The segment INTO this stop is dashed: a long silence or a talkgroup change. Never set on the first kept stop. */
|
||
dashedIn: boolean;
|
||
}
|
||
|
||
export interface IncidentPath {
|
||
movement: boolean;
|
||
/** Every geocoded call, oldest first — ghosts included, so the map can still show them faintly. */
|
||
stops: PathStop[];
|
||
/** Sum of the kept (non-ghost) segments. */
|
||
lengthKm: number;
|
||
mayBeMerged: boolean;
|
||
}
|
||
|
||
function minutesBetween(a: CallRecord, b: CallRecord): number {
|
||
return Math.abs(Date.parse(b.started_at) - Date.parse(a.started_at)) / 60_000;
|
||
}
|
||
|
||
function impossible(a: PathStop, b: PathStop): boolean {
|
||
const d = km(a.at, b.at);
|
||
if (d <= SPEED_CHECK_MIN_KM) return false;
|
||
// Same floor the backend uses (incident_correlator.py: max(idle, 0.1)).
|
||
return d / Math.max(minutesBetween(a.call, b.call), 0.1) > MAX_PATH_SPEED_KM_PER_MIN;
|
||
}
|
||
|
||
/**
|
||
* An incident's geocoded calls as path stops, oldest first.
|
||
*
|
||
* Ghosting walks forward from the last kept stop: a stop that could only be
|
||
* reached faster than a pursuit can drive is ghosted and does not move the
|
||
* anchor. A bad FIRST geocode would otherwise ghost everything after it, so
|
||
* when stop 1 is impossible from stop 0 but stop 2 agrees with stop 1 and not
|
||
* with stop 0, stop 0 is the one ghosted.
|
||
*/
|
||
export function pathStops(inc: IncidentRecord, calls: CallRecord[] | undefined): IncidentPath {
|
||
const movement = isMovementIncident(inc);
|
||
const stops: PathStop[] = (calls ?? [])
|
||
.filter((c) => c.location_coords)
|
||
.slice()
|
||
.sort((a, b) => a.started_at.localeCompare(b.started_at))
|
||
.map((c) => ({ call: c, at: [c.location_coords!.lat, c.location_coords!.lng] as LatLon, n: null, ghost: false, dashedIn: false }));
|
||
|
||
let start = 0;
|
||
if (stops.length >= 3 && impossible(stops[0], stops[1]) && !impossible(stops[1], stops[2]) && impossible(stops[0], stops[2])) {
|
||
stops[0].ghost = true;
|
||
start = 1;
|
||
}
|
||
let prev: PathStop | null = null;
|
||
let lengthKm = 0;
|
||
let n = 0;
|
||
for (let i = start; i < stops.length; i++) {
|
||
const s = stops[i];
|
||
if (prev && impossible(prev, s)) { s.ghost = true; continue; }
|
||
if (prev) {
|
||
lengthKm += km(prev.at, s.at);
|
||
s.dashedIn = minutesBetween(prev.call, s.call) > PATH_GAP_DASH_MIN
|
||
|| (prev.call.talkgroup_id != null && s.call.talkgroup_id != null && prev.call.talkgroup_id !== s.call.talkgroup_id);
|
||
}
|
||
if (movement) s.n = ++n;
|
||
prev = s;
|
||
}
|
||
return { movement, stops, lengthKm, mayBeMerged: lengthKm > MAY_BE_MERGED_KM };
|
||
}
|
||
|
||
/** call_id → stop number, for the spine. Empty for a non-movement incident. */
|
||
export function stopNumbers(path: IncidentPath): Map<string, number> {
|
||
const m = new Map<string, number>();
|
||
for (const s of path.stops) if (s.n != null) m.set(s.call.call_id, s.n);
|
||
return m;
|
||
}
|
||
|
||
/** Worst-first, then most recent (UI_REDESIGN.md §5.1). */
|
||
export function worstFirst(incidents: IncidentRecord[]): IncidentRecord[] {
|
||
return incidents
|
||
.slice()
|
||
.sort((a, b) => severityRank(b.severity) - severityRank(a.severity) || b.started_at.localeCompare(a.started_at));
|
||
}
|
||
|
||
export const NEAR_CAMERA_KM = 3.5;
|
||
|
||
export interface NearCamera { cam: Camera; km: number }
|
||
|
||
/**
|
||
* Nearest cameras to an incident. For a path, distance is to ANY stop but
|
||
* weighted toward the newest one, so a pursuit surfaces the cameras at its
|
||
* head first (GEV "NEAREST", path-aware as in the mockup). `km` is the plain
|
||
* distance to the nearest stop/pin; ranking uses the weighted score.
|
||
*/
|
||
export function nearestCameras(points: LatLon[], cameras: Camera[], k = 4, maxKm = NEAR_CAMERA_KM): NearCamera[] {
|
||
if (!points.length) return [];
|
||
const last = points.length - 1;
|
||
return cameras
|
||
.map((cam) => {
|
||
let raw = Infinity;
|
||
let score = Infinity;
|
||
points.forEach((p, i) => {
|
||
const d = km(p, [cam.lat, cam.lon]);
|
||
raw = Math.min(raw, d);
|
||
score = Math.min(score, d * (1 + (last - i) * 0.5));
|
||
});
|
||
return { cam, km: raw, score };
|
||
})
|
||
.filter((x) => x.score < maxKm)
|
||
.sort((a, b) => a.score - b.score)
|
||
.slice(0, k)
|
||
.map(({ cam, km: d }) => ({ cam, km: d }));
|
||
}
|
||
|
||
/**
|
||
* Coarse 2D viewshed cone (GEV idea #4). APPROXIMATE: built from the
|
||
* published direction of travel, not a surveyed heading/FOV — label it so
|
||
* wherever it is drawn. Cameras with no usable direction get no cone rather
|
||
* than a made-up one.
|
||
*/
|
||
export function viewshedWedge(cam: Camera, rangeKm = 0.8, fovDeg = 50): LatLon[] | null {
|
||
if (cam.heading_deg == null) return null;
|
||
const pts: LatLon[] = [[cam.lat, cam.lon]];
|
||
const cosLat = Math.cos((cam.lat * Math.PI) / 180);
|
||
for (let a = -fovDeg / 2; a <= fovDeg / 2; a += 5) {
|
||
const b = ((cam.heading_deg + a) * Math.PI) / 180;
|
||
pts.push([cam.lat + (rangeKm / 111) * Math.cos(b), cam.lon + (rangeKm / (111 * cosLat)) * Math.sin(b)]);
|
||
}
|
||
return pts;
|
||
}
|
||
|
||
// ── Fused situational context ────────────────────────────────────────────────
|
||
export const CONTEXT_KM = { aircraft: 4, vessel: 3, road: 2 };
|
||
|
||
export interface IncidentContext {
|
||
aircraft: { a: AircraftTrack; km: number }[];
|
||
vessels: { v: VesselTrack; km: number }[];
|
||
road: { e: Ny511Event; km: number }[];
|
||
}
|
||
|
||
function nearestKm(points: LatLon[], lat: number, lon: number): number {
|
||
return points.reduce((m, p) => Math.min(m, km(p, [lat, lon])), Infinity);
|
||
}
|
||
|
||
export function contextFor(
|
||
points: LatLon[],
|
||
aircraft: AircraftTrack[],
|
||
vessels: VesselTrack[],
|
||
events: Ny511Event[],
|
||
): IncidentContext {
|
||
const near = <T,>(items: T[], pos: (t: T) => LatLon | null, r: number) =>
|
||
items
|
||
.map((t) => {
|
||
const p = pos(t);
|
||
return { t, km: p ? nearestKm(points, p[0], p[1]) : Infinity };
|
||
})
|
||
.filter((x) => x.km <= r)
|
||
.sort((a, b) => a.km - b.km);
|
||
return {
|
||
aircraft: near(aircraft, (a) => (a.lat != null && a.lon != null ? [a.lat, a.lon] : null), CONTEXT_KM.aircraft)
|
||
.map(({ t, km: d }) => ({ a: t, km: d })),
|
||
vessels: near(vessels, (v) => (v.lat != null && v.lon != null ? [v.lat, v.lon] : null), CONTEXT_KM.vessel)
|
||
.map(({ t, km: d }) => ({ v: t, km: d })),
|
||
road: near(events, (e) => [e.lat, e.lon], CONTEXT_KM.road).map(({ t, km: d }) => ({ e: t, km: d })),
|
||
};
|
||
}
|
||
|
||
/** Bbox around a set of points, padded by `padKm`. */
|
||
export function bboxAround(points: LatLon[], padKm: number) {
|
||
if (!points.length) return null;
|
||
const lats = points.map((p) => p[0]);
|
||
const lons = points.map((p) => p[1]);
|
||
const dLat = padKm / 111;
|
||
const midLat = (Math.min(...lats) + Math.max(...lats)) / 2;
|
||
const dLon = padKm / (111 * Math.max(0.2, Math.cos((midLat * Math.PI) / 180)));
|
||
return {
|
||
south: Math.max(-90, Math.min(...lats) - dLat),
|
||
north: Math.min(90, Math.max(...lats) + dLat),
|
||
west: Math.max(-180, Math.min(...lons) - dLon),
|
||
east: Math.min(180, Math.max(...lons) + dLon),
|
||
};
|
||
}
|
||
|
||
// ── Deep links / saved views ────────────────────────────────────────────────
|
||
// #inc=<id>&cam=<cameraId>&tour&tourMs=30000&kiosk
|
||
// &layers=nodes,inc,radar,wx&z=11&lat=41.12345&lon=-73.98765&base=light
|
||
// &f.<name>=<value>
|
||
// Every key is optional; unknown keys are ignored, so an old link (inc/cam/tour
|
||
// only) still parses and a newer one degrades on an older build. Pure string
|
||
// in/out — no window access — so it can be unit-tested once a JS runner exists.
|
||
|
||
/** Map overlays, by short stable hash key → the LayersControl overlay name. */
|
||
export const LAYER_NAMES = {
|
||
nodes: "Nodes",
|
||
inc: "Active Incidents",
|
||
paths: "Incident Paths",
|
||
air: "Aircraft",
|
||
ais: "Vessels",
|
||
dotcam: "DOT Cameras",
|
||
vs: "Camera Viewsheds (approx.)",
|
||
traffic: "Traffic Events",
|
||
radar: "Weather Radar",
|
||
wx: "Weather Warnings",
|
||
} as const;
|
||
export type LayerKey = keyof typeof LAYER_NAMES;
|
||
export const LAYER_KEYS = Object.keys(LAYER_NAMES) as LayerKey[];
|
||
/** What the map shows with no hash and nothing remembered (unchanged from before #234). */
|
||
export const DEFAULT_LAYERS: LayerKey[] = ["nodes", "inc", "paths"];
|
||
|
||
export const BASE_NAMES = { dark: "Dark", light: "Light", streets: "Streets" } as const;
|
||
export type BaseKey = keyof typeof BASE_NAMES;
|
||
export const DEFAULT_BASE: BaseKey = "dark";
|
||
|
||
export function isLayerKey(k: string): k is LayerKey {
|
||
return Object.prototype.hasOwnProperty.call(LAYER_NAMES, k);
|
||
}
|
||
export function isBaseKey(k: string): k is BaseKey {
|
||
return Object.prototype.hasOwnProperty.call(BASE_NAMES, k);
|
||
}
|
||
export function layerKeyForName(name: string): LayerKey | null {
|
||
return LAYER_KEYS.find((k) => LAYER_NAMES[k] === name) ?? null;
|
||
}
|
||
export function baseKeyForName(name: string): BaseKey | null {
|
||
return (Object.keys(BASE_NAMES) as BaseKey[]).find((k) => BASE_NAMES[k] === name) ?? null;
|
||
}
|
||
|
||
export interface MapViewport { z: number; lat: number; lon: number }
|
||
|
||
/** What the map looks like — the part a viewer's browser also remembers (lib/wallPrefs.ts). */
|
||
export interface WallView {
|
||
/** Overlays switched on, in LAYER_KEYS order. [] = all off; absent = not specified. */
|
||
layers?: LayerKey[];
|
||
view?: MapViewport;
|
||
base?: BaseKey;
|
||
}
|
||
|
||
export interface WallLink extends WallView {
|
||
inc: string | null;
|
||
cam: string | null;
|
||
tour: boolean;
|
||
/** Auto-tour dwell per incident; absent = the default. */
|
||
tourMs?: number;
|
||
/** Wall/kiosk presentation (no nav, large type, wake lock). */
|
||
kiosk?: boolean;
|
||
/**
|
||
* The filter bar's state (server-26#235): every `f.<name>=<v>` hash key
|
||
* round-trips here untouched, so filters can be added without reshaping
|
||
* WallLink or breaking older links. lib/liveFilter.ts parseFilter /
|
||
* serialiseFilter give it meaning.
|
||
*/
|
||
filter?: Record<string, string>;
|
||
}
|
||
|
||
export const TOUR_MS_MIN = 5_000;
|
||
export const TOUR_MS_MAX = 10 * 60_000;
|
||
|
||
function num(v: string | null): number | null {
|
||
if (v == null || v.trim() === "") return null;
|
||
const n = Number(v);
|
||
return Number.isFinite(n) ? n : null;
|
||
}
|
||
|
||
/** Normalises a layer list: known keys only, de-duplicated, in LAYER_KEYS order. */
|
||
export function normaliseLayers(keys: readonly string[]): LayerKey[] {
|
||
const set = new Set(keys);
|
||
return LAYER_KEYS.filter((k) => set.has(k));
|
||
}
|
||
|
||
export function parseViewport(z: unknown, lat: unknown, lon: unknown): MapViewport | undefined {
|
||
if (typeof z !== "number" || typeof lat !== "number" || typeof lon !== "number") return undefined;
|
||
if (!Number.isFinite(z) || !Number.isFinite(lat) || !Number.isFinite(lon)) return undefined;
|
||
if (z < 0 || z > 22 || lat < -90 || lat > 90 || lon < -180 || lon > 180) return undefined;
|
||
return { z, lat, lon };
|
||
}
|
||
|
||
/** Rounds a viewport for the URL: ~1 m position, 1/100 zoom. */
|
||
export function roundViewport(v: MapViewport): MapViewport {
|
||
return { z: Math.round(v.z * 100) / 100, lat: Math.round(v.lat * 1e5) / 1e5, lon: Math.round(v.lon * 1e5) / 1e5 };
|
||
}
|
||
|
||
export function parseWallHash(hash: string): WallLink {
|
||
const p = new URLSearchParams(hash.replace(/^#/, ""));
|
||
const link: WallLink = { inc: p.get("inc") || null, cam: p.get("cam") || null, tour: p.has("tour") };
|
||
const tourMs = num(p.get("tourMs"));
|
||
if (tourMs != null) link.tourMs = Math.min(TOUR_MS_MAX, Math.max(TOUR_MS_MIN, Math.round(tourMs)));
|
||
if (p.has("kiosk") && p.get("kiosk") !== "0") link.kiosk = true;
|
||
if (p.has("layers")) link.layers = normaliseLayers((p.get("layers") ?? "").split(","));
|
||
const view = parseViewport(num(p.get("z")), num(p.get("lat")), num(p.get("lon")));
|
||
if (view) link.view = view;
|
||
const base = p.get("base");
|
||
if (base && isBaseKey(base)) link.base = base;
|
||
const filter: Record<string, string> = {};
|
||
p.forEach((v, k) => { if (k.startsWith("f.") && k.length > 2) filter[k.slice(2)] = v; });
|
||
if (Object.keys(filter).length) link.filter = filter;
|
||
return link;
|
||
}
|
||
|
||
export function wallHash(link: WallLink): string {
|
||
const parts: string[] = [];
|
||
const enc = encodeURIComponent;
|
||
if (link.inc) parts.push(`inc=${enc(link.inc)}`);
|
||
if (link.cam) parts.push(`cam=${enc(link.cam)}`);
|
||
if (link.tour) parts.push("tour");
|
||
if (link.tourMs != null) parts.push(`tourMs=${Math.round(link.tourMs)}`);
|
||
if (link.kiosk) parts.push("kiosk");
|
||
// Commas stay literal (URLSearchParams reads them back fine) so the link is readable.
|
||
if (link.layers) parts.push(`layers=${normaliseLayers(link.layers).join(",")}`);
|
||
if (link.view) {
|
||
const v = roundViewport(link.view);
|
||
parts.push(`z=${v.z}`, `lat=${v.lat}`, `lon=${v.lon}`);
|
||
}
|
||
if (link.base) parts.push(`base=${link.base}`);
|
||
if (link.filter) {
|
||
// Filter lists (f.type=fire,ems) keep literal commas too (server-26#235).
|
||
for (const k of Object.keys(link.filter).sort()) parts.push(`f.${enc(k)}=${enc(link.filter[k]).replace(/%2C/g, ",")}`);
|
||
}
|
||
return parts.length ? `#${parts.join("&")}` : "";
|
||
}
|
||
|
||
/** True when two views would serialise identically (for "did this actually change"). */
|
||
export function sameView(a: WallView, b: WallView): boolean {
|
||
return wallHash({ inc: null, cam: null, tour: false, ...a }) === wallHash({ inc: null, cam: null, tour: false, ...b });
|
||
}
|
||
|
||
/** "On air": a live call is on one of the incident's talkgroups right now. */
|
||
export function onAirIncidentIds(incidents: IncidentRecord[], activeCalls: CallRecord[]): Set<string> {
|
||
const live = new Set(activeCalls.map((c) => c.talkgroup_id).filter((t): t is number => t != null).map(String));
|
||
return new Set(
|
||
incidents.filter((i) => (i.talkgroup_ids ?? []).some((t) => live.has(String(t)))).map((i) => i.incident_id),
|
||
);
|
||
}
|
||
|
||
// ── NEXRAD (IEM n0q composite) ───────────────────────────────────────────────
|
||
// The Weather Radar layer, its valid-time badge, its legend and the per-incident
|
||
// sample all use ONE product: IEM's n0q (base reflectivity, 0.5 dBZ steps,
|
||
// -32..95 dBZ). Verified first-hand 2026-10-05:
|
||
// tiles https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0q-900913/{z}/{x}/{y}.png
|
||
// (RGBA PNG, ACAO: *), and nexrad-n0q-900913-m05m … -m55m for the
|
||
// 5-minute frames of the last hour (-m60m is a 404);
|
||
// valid https://mesonet.agron.iastate.edu/data/gis/images/4326/USCOMP/n0q_0.json
|
||
// → {"meta": {"valid": "2026-10-05T07:35:00Z", "product": "N0Q", …}}
|
||
// (n0q_<k>.json is k × 5 min earlier; ACAO: *);
|
||
// ramp https://mesonet.agron.iastate.edu/GIS/rasters.php?rid=2 — 255 colours,
|
||
// index i (1..255) = -32 + (i-1)/2 dBZ. Every opaque pixel of five
|
||
// sampled live tiles matched this table exactly.
|
||
export const RADAR_TILE_BASE = "https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0q-900913";
|
||
export const RADAR_META_URL = "https://mesonet.agron.iastate.edu/data/gis/images/4326/USCOMP/n0q_0.json";
|
||
/** A frame older than this is flagged STALE on the map (#237 acceptance). */
|
||
export const RADAR_STALE_MIN = 15;
|
||
/** Loop frames, oldest first, as minutes before the current composite (IEM keeps -m05m … -m55m). */
|
||
export const RADAR_LOOP_OFFSETS_MIN = [55, 50, 45, 40, 35, 30, 25, 20, 15, 10, 5, 0] as const;
|
||
/** Light rain starts around here; weaker returns are clear-air / clutter. */
|
||
export const PRECIP_ECHO_DBZ = 15;
|
||
|
||
/** Tile URL for the composite `offsetMin` minutes before the current one (0 = current). */
|
||
export function radarTileUrl(offsetMin = 0): string {
|
||
const layer = offsetMin > 0 ? `${RADAR_TILE_BASE}-m${String(offsetMin).padStart(2, "0")}m` : RADAR_TILE_BASE;
|
||
return `${layer}/{z}/{x}/{y}.png`;
|
||
}
|
||
|
||
/** The composite's valid time from IEM's n0q metadata JSON, or null if the shape isn't the verified one. */
|
||
export function parseRadarMeta(body: unknown): Date | null {
|
||
const valid = (body as { meta?: { valid?: unknown } } | null)?.meta?.valid;
|
||
if (typeof valid !== "string") return null;
|
||
const d = new Date(valid);
|
||
return isNaN(d.getTime()) ? null : d;
|
||
}
|
||
|
||
/** IEM's published n0q colour table, 6 hex digits per entry, index 1..255 (index 0 = missing). */
|
||
const N0Q_RAMP_HEX =
|
||
"85718f85728f86738d87758b87768b887789897987897a878a7b858b7d848b7e848c7f828d81808d82808e837e8f847c" +
|
||
"8f857c90877b918879918979928b77938d759691539894579b975b9d9a60a09d64a3a068a5a36da8a671aaa976adac7a" +
|
||
"b0af7eb2b283b7b88cbabb90bdbe94bfc199c2c49dc4c7a2c7caa6cacdaaccd0afd2d4b4cfd2b4c9ccb4c6c9b4c3c7b4" +
|
||
"c0c4b4bdc1b4b9beb4b6bbb4b3b9b4b0b6b4adb3b4aab0b4a4abb4a0a8b49da5b49aa2b497a0b4949db4919ab4949bb5" +
|
||
"9098b48c95b38892b2808cb07c89af7886ae7483ac7080ab6c7daa6779a96376a85f73a75b70a6576da44f67a24b64a1" +
|
||
"4761a0435e9f415b9e4361a24568a6486faa4a76ae4d7db24f84b6518bbb5699c3599fc75ba6cb5eadcf60b4d462bbd8" +
|
||
"65c2dc67c9e06ad0e46fd6e868d6d759d6b352d6a24bd69043d67e3cd66d35d65b11d51811d11710cd1710c81610c416" +
|
||
"0fbc150fb7140eb3140eaf130eab130da6120da2120d9e110c99110c95100c91100b880f0b840e0a800e0a7c0d0a770d" +
|
||
"09730c096f0c096b0b08660b08620a095e09327308467d085b88076f9207849d0698a806adb205c1bd05d6c704ead204" +
|
||
"ffe200ffd800ffd300ffce00ffc900ffc400ffc000ffbb00ffb600ffb100ffac00ffa700ffa200ff9900ff9400ff8f00" +
|
||
"ff8a00ff8500ff8000ff0000f80000f10000ea0000e30000d50000cd0000c60000bf0000b80000b10000aa0000a30000" +
|
||
"9b00009400008d00007f0000780000710000fffffffff5ffffeaffffdfffffd4ffffc9ffffbeffffb3ffff9dffff92ff" +
|
||
"ff75fffc6bfdf960faf656f7f34bf4f040f1ed36efea2bece720e9e10be3b200ffac00fca400f79b00f49300ef8800ea" +
|
||
"8300e87900e27200dd6900db05ecf005ebf005eaf005dde005dce005dbe005cdd005ccd004bdc004bcc004bbc004aeb0" +
|
||
"04adb0049ea0049da0049ca0038e90038d90038c90037e80037d80036f70036e70036d70025f60025e60024f50024e50" +
|
||
"024d50023f40023e40023d40013030012f30012020011f20011e203a67b53a66b53a65b53a64b53a63b53a62b5";
|
||
|
||
function n0qIndex(dbz: number): number {
|
||
return Math.min(255, Math.max(1, Math.round((dbz + 32) * 2) + 1));
|
||
}
|
||
|
||
/** "#rrggbb" the radar layer draws for a reflectivity. */
|
||
export function n0qColor(dbz: number): string {
|
||
const i = n0qIndex(dbz) - 1;
|
||
return `#${N0Q_RAMP_HEX.slice(i * 6, i * 6 + 6)}`;
|
||
}
|
||
|
||
let rgbToDbz: Map<number, number> | null = null;
|
||
function n0qLookup(): Map<number, number> {
|
||
if (!rgbToDbz) {
|
||
rgbToDbz = new Map();
|
||
for (let i = 0; i < 255; i++) rgbToDbz.set(parseInt(N0Q_RAMP_HEX.slice(i * 6, i * 6 + 6), 16), -32 + i / 2);
|
||
}
|
||
return rgbToDbz;
|
||
}
|
||
|
||
/** dBZ for a radar-tile pixel. Exact table hit (tiles are lossless); else nearest colour within a small distance; else null. */
|
||
export function pixelDbz(r: number, g: number, b: number): number | null {
|
||
const lut = n0qLookup();
|
||
const exact = lut.get((r << 16) | (g << 8) | b);
|
||
if (exact != null) return exact;
|
||
let best: number | null = null;
|
||
let bestD = 12 * 12;
|
||
lut.forEach((dbz, rgb) => {
|
||
const d = (r - ((rgb >> 16) & 255)) ** 2 + (g - ((rgb >> 8) & 255)) ** 2 + (b - (rgb & 255)) ** 2;
|
||
if (d < bestD) { bestD = d; best = dbz; }
|
||
});
|
||
return best;
|
||
}
|
||
|
||
/** No opaque radar pixel near the point (the tile is transparent there). */
|
||
export const NO_ECHO_DBZ = -Infinity;
|
||
|
||
/**
|
||
* Max reflectivity (dBZ) within ~radiusKm of a point, read from the same n0q
|
||
* tile the Weather Radar layer draws (cache-busted on the same 5-min clock).
|
||
* Approximate by construction — label it so. NO_ECHO_DBZ when nothing is
|
||
* drawn there; null when the tile can't be read.
|
||
*/
|
||
export async function sampleRadarDbz(lat: number, lon: number, radiusKm = 5): Promise<number | null> {
|
||
const z = 9;
|
||
const n = 2 ** z;
|
||
const xf = ((lon + 180) / 360) * n;
|
||
const latR = (lat * Math.PI) / 180;
|
||
const yf = ((1 - Math.log(Math.tan(latR) + 1 / Math.cos(latR)) / Math.PI) / 2) * n;
|
||
const tx = Math.floor(xf);
|
||
const ty = Math.floor(yf);
|
||
const img = new Image();
|
||
img.crossOrigin = "anonymous";
|
||
img.src = `${RADAR_TILE_BASE}/${z}/${tx}/${ty}.png?_=${Math.floor(Date.now() / 300000)}`;
|
||
try {
|
||
await img.decode();
|
||
} catch {
|
||
return null;
|
||
}
|
||
const canvas = document.createElement("canvas");
|
||
canvas.width = 256;
|
||
canvas.height = 256;
|
||
const ctx = canvas.getContext("2d");
|
||
if (!ctx) return null;
|
||
ctx.drawImage(img, 0, 0);
|
||
const px = Math.floor((xf - tx) * 256);
|
||
const py = Math.floor((yf - ty) * 256);
|
||
const kmPerPx = (40075 * Math.cos(latR)) / (n * 256);
|
||
const rad = Math.max(1, Math.round(radiusKm / kmPerPx));
|
||
const x0 = Math.max(0, px - rad);
|
||
const y0 = Math.max(0, py - rad);
|
||
const w = Math.min(256, px + rad) - x0;
|
||
const h = Math.min(256, py + rad) - y0;
|
||
if (w <= 0 || h <= 0) return null;
|
||
let data: Uint8ClampedArray;
|
||
try {
|
||
data = ctx.getImageData(x0, y0, w, h).data;
|
||
} catch {
|
||
return null; // tainted canvas — CORS changed upstream
|
||
}
|
||
let max = NO_ECHO_DBZ;
|
||
for (let i = 0; i < data.length; i += 4) {
|
||
if (data[i + 3] < 128) continue; // transparent: nothing drawn
|
||
const dbz = pixelDbz(data[i], data[i + 1], data[i + 2]);
|
||
if (dbz != null && dbz > max) max = dbz;
|
||
}
|
||
return max;
|
||
}
|
||
|
||
// ── NWS warnings (server-26#237) ─────────────────────────────────────────────
|
||
|
||
/** Ray-cast point-in-ring; `ring` is GeoJSON [lon, lat] pairs. */
|
||
function inRing(lat: number, lon: number, ring: number[][]): boolean {
|
||
let inside = false;
|
||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||
const [xi, yi] = ring[i];
|
||
const [xj, yj] = ring[j];
|
||
if ((yi > lat) !== (yj > lat) && lon < ((xj - xi) * (lat - yi)) / (yj - yi) + xi) inside = !inside;
|
||
}
|
||
return inside;
|
||
}
|
||
|
||
/**
|
||
* True when [lat, lon] is inside a GeoJSON Polygon / MultiPolygon (holes
|
||
* respected: inside the outer ring and outside every hole).
|
||
*/
|
||
export function pointInPolygon(point: LatLon, geom: WeatherGeometry | null | undefined): boolean {
|
||
if (!geom) return false;
|
||
const polys = geom.type === "Polygon" ? [geom.coordinates] : geom.type === "MultiPolygon" ? geom.coordinates : [];
|
||
const [lat, lon] = point;
|
||
return polys.some((rings) =>
|
||
rings.length > 0 && inRing(lat, lon, rings[0]) && !rings.slice(1).some((hole) => inRing(lat, lon, hole)));
|
||
}
|
||
|
||
/** Active alerts whose drawn area contains the point, worst-looking (Warning) first. */
|
||
export function alertsAt(point: LatLon, alerts: WeatherAlert[]): WeatherAlert[] {
|
||
return alerts
|
||
.filter((a) => pointInPolygon(point, a.geometry))
|
||
.sort((a, b) => wxKindRank(a.event) - wxKindRank(b.event));
|
||
}
|
||
|
||
export type WxKind = "warning" | "watch" | "advisory" | "statement";
|
||
|
||
export function wxKind(event: string): WxKind {
|
||
const e = event.toLowerCase();
|
||
if (e.includes("warning") || e.includes("emergency")) return "warning";
|
||
if (e.includes("watch")) return "watch";
|
||
if (e.includes("advisory") || e.includes("alert")) return "advisory";
|
||
return "statement";
|
||
}
|
||
function wxKindRank(event: string): number {
|
||
return ["warning", "watch", "advisory", "statement"].indexOf(wxKind(event));
|
||
}
|
||
|
||
/**
|
||
* NWS hazard-map colours (weather.gov/help-map) for the events this region
|
||
* sees most. Unlisted events fall back by kind. Colour is never the only cue:
|
||
* the outline dash says the kind (warning solid, watch dashed, advisory and
|
||
* statement dotted), and the legend explains it.
|
||
*/
|
||
const NWS_EVENT_COLORS: Record<string, string> = {
|
||
"tornado warning": "#ff0000",
|
||
"severe thunderstorm warning": "#ffa500",
|
||
"flash flood warning": "#8b0000",
|
||
"flood warning": "#00ff00",
|
||
"flood advisory": "#00ff7f",
|
||
"flood watch": "#2e8b57",
|
||
"flash flood watch": "#2e8b57",
|
||
"tornado watch": "#ffff00",
|
||
"severe thunderstorm watch": "#db7093",
|
||
"special marine warning": "#ffa500",
|
||
"winter storm warning": "#ff69b4",
|
||
"winter storm watch": "#4682b4",
|
||
"winter weather advisory": "#7b68ee",
|
||
"blizzard warning": "#ff4500",
|
||
"ice storm warning": "#8b008b",
|
||
"lake effect snow warning": "#008b8b",
|
||
"high wind warning": "#daa520",
|
||
"high wind watch": "#b8860b",
|
||
"wind advisory": "#d2b48c",
|
||
"heat advisory": "#ff7f50",
|
||
"excessive heat warning": "#c71585",
|
||
"extreme heat warning": "#c71585",
|
||
"excessive heat watch": "#800000",
|
||
"extreme heat watch": "#800000",
|
||
"coastal flood warning": "#228b22",
|
||
"coastal flood watch": "#66cdaa",
|
||
"coastal flood advisory": "#7cfc00",
|
||
"dense fog advisory": "#708090",
|
||
"special weather statement": "#ffe4b5",
|
||
"red flag warning": "#ff1493",
|
||
"fire weather watch": "#ffdead",
|
||
"freeze warning": "#483d8b",
|
||
"frost advisory": "#6495ed",
|
||
"small craft advisory": "#d8bfd8",
|
||
"gale warning": "#dda0dd",
|
||
"storm warning": "#9400d3",
|
||
"hurricane warning": "#dc143c",
|
||
"tropical storm warning": "#b22222",
|
||
"air quality alert": "#808080",
|
||
};
|
||
const WX_KIND_FALLBACK: Record<WxKind, string> = {
|
||
warning: "#dc2626", watch: "#eab308", advisory: "#60a5fa", statement: "#a3a3a3",
|
||
};
|
||
export const WX_KIND_DASH: Record<WxKind, string | undefined> = {
|
||
warning: undefined, watch: "8 5", advisory: "2 4", statement: "2 4",
|
||
};
|
||
|
||
export function wxStyle(event: string): { color: string; kind: WxKind; dashArray: string | undefined; weight: number } {
|
||
const kind = wxKind(event);
|
||
return {
|
||
color: NWS_EVENT_COLORS[event.toLowerCase()] ?? WX_KIND_FALLBACK[kind],
|
||
kind,
|
||
dashArray: WX_KIND_DASH[kind],
|
||
weight: kind === "warning" ? 2.5 : kind === "watch" ? 2 : 1.5,
|
||
};
|
||
}
|
||
|
||
/** Local "Oct 5, 2:30 PM" for an NWS ISO timestamp (they carry their own offset). */
|
||
export function wxTime(iso: string | null | undefined): string | null {
|
||
if (!iso) return null;
|
||
const d = new Date(iso);
|
||
return isNaN(d.getTime()) ? null : d.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
||
}
|
||
|
||
// ── Wind at the incident (server-26#237) ─────────────────────────────────────
|
||
// Fire and hazmat are where wind direction changes the call (smoke / plume
|
||
// drift, approach side). There is no hazmat incident type, so tags decide.
|
||
const WIND_TAG_RE = /(^|-)(hazmat|haz-mat|gas-leak|natural-gas|chemical|spill|smoke|fumes|odor|odour|plume|brush|wildfire|propane|ammonia|chlorine|carbon-monoxide)(-|$)/;
|
||
|
||
export function wantsWind(inc: Pick<IncidentRecord, "type" | "tags">): boolean {
|
||
if (inc.type === "fire") return true;
|
||
return (inc.tags ?? []).some((t) => WIND_TAG_RE.test(t.toLowerCase()));
|
||
}
|
||
|
||
/** 16-point compass name for the direction the wind blows FROM. */
|
||
export function compass(deg: number): string {
|
||
const pts = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"];
|
||
return pts[Math.round((((deg % 360) + 360) % 360) / 22.5) % 16];
|
||
}
|