Files
server-26/drb-frontend/lib/liveWall.ts
T
Logan CusanoandClaude Opus 5.5 1a84bf46d7 Weather map layers: NWS warning polygons, n0q radar with valid time/legend/loop, wind at incident (#237)
- Weather Warnings overlay (layer key `wx`, saved in the view hash): NWS alert
  polygons coloured by event (NWS hazard-map colours), outline dash by kind,
  popup with event/headline/expiry/office and National Weather Service credit.
  Polls GET /weather/alerts every 2 min while on and visible, refetches on pan.
  A status chip always says what the layer shows; feed errors use the 511
  FeedProblem wording; zone-only alerts are listed, never dropped.
- Radar: IEM nexrad-n0r -> n0q. Valid-time badge from IEM's n0q_0.json,
  STALE past 15 min or when the time can't be read; dBZ legend; optional 1 h
  loop (nexrad-n0q-900913-m05m..m55m), off by default. Still refreshed in
  place, never remounted.
- liveWall: sampleRadarDbz reads the n0q tile with IEM's published 255-colour
  ramp (verified against live tiles); pointInPolygon/alertsAt, wxStyle,
  wantsWind helpers.
- Wall incident panel: "inside <warning>" chips, wind arrow + speed/gust +
  station/obs time on fire/hazmat incidents; region Feeds gains an NWS row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:46:05 -04:00

687 lines
30 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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];
}