/** * 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 { const map = new Map(); 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. */ export function incidentPoints(inc: IncidentRecord, calls: CallRecord[] | undefined): LatLon[] { const stops = (calls ?? []) .filter((c) => c.location_coords) .slice() .sort((a, b) => a.started_at.localeCompare(b.started_at)) .map((c) => [c.location_coords!.lat, c.location_coords!.lng] as LatLon); if (stops.length >= 2) return stops; if (inc.location_coords) return [[inc.location_coords.lat, inc.location_coords.lng]]; return stops; } /** 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 = (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=&cam=&tour&tourMs=30000&kiosk // &layers=nodes,inc,radar&z=11&lat=41.12345&lon=-73.98765&base=light // &f.= // 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; /** * Extension point for the filter bar (server-26#235): every `f.=` * hash key round-trips here untouched, so filters can be added without * reshaping WallLink or breaking older links. */ filter?: Record; } 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 = {}; 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) { for (const k of Object.keys(link.filter).sort()) parts.push(`f.${enc(k)}=${enc(link.filter[k])}`); } 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 { 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 { 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; }