Files
server-26/drb-frontend/lib/liveWall.ts
T
Logan CusanoandClaude Opus 5.5 6bf072973c Honest pins and paths: geocode precision, type-gated paths, impossible-speed stops (#236)
Backend: _geocode_location now returns {lat, lng, location_type}, so the
precision rides onto the call doc and, unchanged via _resolve_location_pair,
onto the incident pin. Display-only: every correlator read indexes lat/lng.

Frontend: pins styled by precision (rooftop / interpolated ring /
GEOMETRIC_CENTER dashed uncertainty circle labelled with the spoken place /
unverified pre-#23 pins ghosted). liveWall.pathStops() is shared by the map
and the call spine: only police/accident incidents with a pursuit tag draw a
numbered path; others get unnumbered call dots. Segments over >10 min gaps or
talkgroup changes are dashed, stops implying >8 km/min are ghosted off the
path, paths >20 km get a "may be merged" badge. New optional MapView prop
pulseIncidentIds (wired by #238).

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

476 lines
20 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 } 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&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",
} 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 sample near an incident ───────────────────────────────────────────
// Reads the same IEM n0r composite tile the Weather Radar layer draws (IEM
// sends ACAO: *), and maps pixel colours back to the NWS reflectivity scale.
// Approximate by construction — label it so.
const N0R_PALETTE: [number, number, number, number][] = [
[5, 4, 233, 231], [10, 1, 159, 244], [15, 3, 0, 244], [20, 2, 253, 2], [25, 1, 197, 1], [30, 0, 142, 0],
[35, 253, 248, 2], [40, 229, 188, 0], [45, 253, 149, 0], [50, 253, 0, 0], [55, 212, 0, 0], [60, 188, 0, 0],
[65, 248, 0, 253], [70, 152, 84, 198], [75, 253, 253, 253],
];
const RADAR_TILE = "https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0r-900913";
function pixelDbz(r: number, g: number, b: number): number {
let best = 0;
let bestD = Infinity;
for (const [dbz, pr, pg, pb] of N0R_PALETTE) {
const d = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2;
if (d < bestD) { bestD = d; best = dbz; }
}
return bestD < 60 * 60 ? best : 0;
}
/** Max reflectivity (dBZ) within ~radiusKm of a point, or null if it 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}/${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 = 0;
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] < 32) continue;
max = Math.max(max, pixelDbz(data[i], data[i + 1], data[i + 2]));
}
return max;
}