"use client"; /** * Live — the default landing for every authenticated user (UI_REDESIGN.md * §3, §5.1), now the live ops wall (design-canvas/live-ops-wall-mockup.html): * Region → Incident → Camera drill-down, worst-first incident rail (MapView), * KPIs, transcript ticker, scrubber with incident bands, auto-tour and deep * links (#inc=…&cam=…&tour). Every MapView layer stays — nodes, incidents, * paths, ADS-B, AIS, cameras, viewsheds, traffic events, NEXRAD. * * Saved views (server-26#234): overlays, basemap and viewport live in the * URL hash too (lib/liveWall.ts wallHash), so a reload or a copied link comes * back the same; each browser also remembers its last view (lib/wallPrefs.ts). * Precedence: hash > remembered > defaults. `?kiosk=1` / `#kiosk` is the * wall-display mode (lib/useKiosk.ts). * * Filter bar (server-26#235): the `f.*` hash keys (lib/liveFilter.ts) are * applied ONCE here and the filtered lists feed the map (pins, paths, rail), * the region panel, ticker, scrubber bands, auto-tour and KPIs. Pipeline * health stays on the unfiltered data — a filter must never hide an outage. * * Call window (server-26#239): the 500-call window feeds the ticker, scrubber * and region; the SELECTED incident also loads its own calls by incident id * and merges them in, so a long incident's path and spine are complete. An * `#inc=` that is not active is fetched by id (resolved → banner; missing or * another org → "not found"). The scrubber hatches time the window didn't load. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import dynamic from "next/dynamic"; import { useNodes } from "@/lib/useNodes"; import { useSystems } from "@/lib/useSystems"; import { useActiveCalls, useCalls, useCallsByIncident } from "@/lib/useCalls"; import { useActiveIncidents, useIncident } from "@/lib/useIncidents"; import { useAuth } from "@/components/AuthProvider"; import { useAircraft } from "@/lib/useAircraft"; import { useVessels } from "@/lib/useVessels"; import { useCamerasInBbox } from "@/lib/useCameras"; import { c2api } from "@/lib/c2api"; import { severityRank } from "@/lib/severity"; import type { CallRecord, Camera, IncidentRecord, Ny511Response } from "@/lib/types"; import { DEFAULT_LAYERS, NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, normaliseLayers, onAirIncidentIds, parseWallHash, sameView, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink, } from "@/lib/liveWall"; import { PRESETS, applyFilter, parseFilter, serialiseFilter, type PresetKey, type WallFilter } from "@/lib/liveFilter"; import { FilterBar, type FilterOption } from "@/components/live/FilterBar"; import { loadWallPrefs, resolveWallView, saveWallPrefs } from "@/lib/wallPrefs"; import { announceKioskChange, kioskInQuery, stripKioskQuery, useKiosk } from "@/lib/useKiosk"; import { KioskBanner, KioskControls } from "@/components/live/KioskControls"; import type { MapViewState } from "@/components/MapView"; import { TimeScrubber } from "@/components/TimeScrubber"; import { TranscriptTicker } from "@/components/live/TranscriptTicker"; import { CameraDrill } from "@/components/live/CameraDrill"; import { IncidentPanel, RegionPanel } from "@/components/live/WallPanels"; import { TruthStrip } from "@/components/live/TruthStrip"; import { usePipelineHealth } from "@/lib/usePipelineHealth"; const MapView = dynamic(() => import("@/components/MapView"), { ssr: false }); const TOUR_MS = 20_000; /** The wall's recent-call window (server-26#239: a selected incident loads past it). */ const RECENT_CALLS = 500; // 10-foot type for kiosk walls (server-26#234): scales every small text class // under the wall root — including MapView's rail and WallPanels, which are not // edited for it — to 18-20 px. Literal strings so Tailwind's scanner sees them. const KIOSK_TYPE = [ "[&[data-kiosk]_.text-xs]:text-[18px]", "[&[data-kiosk]_.text-xs]:leading-snug", "[&[data-kiosk]_.text-sm]:text-[20px]", "[&[data-kiosk]_.text-sm]:leading-snug", "[&[data-kiosk]_[class*='text-[9px]']]:text-[18px]", "[&[data-kiosk]_[class*='text-[10px]']]:text-[18px]", "[&[data-kiosk]_[class*='text-[11px]']]:text-[18px]", "[&[data-kiosk]_.w-56]:w-80", ].join(" "); const PIN_KEY = "drb.wall.pinnedCameras"; const MAX_TAG_CHIPS = 8; const MAX_PINNED = 6; const EVENTS_POLL_MS = 2 * 60 * 1000; /** Incident-doc fields the frontend type doesn't declare yet (backend writes both). */ type IncidentDoc = IncidentRecord & { org_id?: string | null; resolved_at?: string | null }; /** "Resolved 14:32", with the date when it wasn't today. */ function resolvedLabel(inc: IncidentDoc): string { const d = inc.resolved_at ? new Date(inc.resolved_at) : null; if (!d || Number.isNaN(d.getTime())) return "Resolved"; const hm = d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false }); const today = d.toDateString() === new Date().toDateString(); return today ? `Resolved ${hm}` : `Resolved ${d.toLocaleDateString([], { month: "short", day: "numeric" })} ${hm}`; } function timeAgo(date: Date): string { const s = Math.floor((Date.now() - date.getTime()) / 1000); if (s < 60) return `${s}s ago`; if (s < 3600) return `${Math.floor(s / 60)}m ago`; return `${Math.floor(s / 3600)}h ago`; } /** Per-viewer convenience only; the wall works without it. */ function loadPinned(): Camera[] { try { const raw = window.localStorage.getItem(PIN_KEY); const v = raw ? JSON.parse(raw) : []; return Array.isArray(v) ? v.filter((c) => c && typeof c.id === "string").slice(0, MAX_PINNED) : []; } catch { return []; } } function savePinned(cams: Camera[]) { try { window.localStorage.setItem(PIN_KEY, JSON.stringify(cams)); } catch { /* storage blocked */ } } /** 511NY road events around the region (situational context + KPI). */ function useRegionEvents(box: ReturnType) { const [data, setData] = useState({}); const key = box ? [box.south, box.west, box.north, box.east].map((n) => n.toFixed(2)).join(",") : ""; useEffect(() => { if (!box) return; const b = box; let cancelled = false; const load = () => { if (document.hidden) return; c2api.get511(b, "events").then((r) => { if (!cancelled) setData(r); }).catch((e) => console.error("region events:", e)); }; load(); const id = setInterval(load, EVENTS_POLL_MS); return () => { cancelled = true; clearInterval(id); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [key]); return data; } function Kpi({ value, label }: { value: string | number; label: string }) { return ( {value}{label} ); } export function LiveView() { const { nodes, loading: nodesLoading } = useNodes(); const activeCalls = useActiveCalls(); const activeIncidents = useActiveIncidents(); // Wide-ish recent window: feeds incident paths, the call spine, the ticker // and the scrubber's density bars. Real recent calls, not a fixture. const { calls: recentCalls, loading: callsLoading, error: callsError, hasMore: windowFull } = useCalls(RECENT_CALLS); const { orgId } = useAuth(); const { systems } = useSystems(); const { aircraft } = useAircraft(); const { vessels } = useVessels(); const [clock, setClock] = useState(""); // No heartbeat-driven "last updated" any more (server-26#232): a node // check-in kept '● Live' fresh while STT/correlation were dead (#196). The // wall's liveness now comes from usePipelineHealth, keyed to pipeline output. useEffect(() => { const tick = () => setClock(new Date().toLocaleTimeString([], { hour12: false })); tick(); const id = setInterval(tick, 1000); return () => clearInterval(id); }, []); // ── Wall state, mirrored in the URL hash ────────────────────────────────── // `link` = where the wall is (incident/camera/tour/kiosk/filters); // `mapState` = what the map looks like (layers/base/viewport). Both are // written back to the hash by one effect, so the address bar is always a // working "this exact view" link. null until the hash has been read, so // MapView never mounts with the wrong initial view. const [link, setLinkState] = useState({ inc: null, cam: null, tour: false }); const [mapState, setMapState] = useState(null); const mapStateRef = useRef(null); mapStateRef.current = mapState; /** Bumped when a hash edit changes layers/base/view: MapView remounts with them. */ const [mapGen, setMapGen] = useState(0); const [kioskQuery, setKioskQuery] = useState(false); useEffect(() => { const read = (first: boolean) => { const parsed = parseWallHash(window.location.hash); const { layers, base, view, ...nav } = parsed; setLinkState(nav); const cur = mapStateRef.current; const next = resolveWallView({ layers, base, view }, first || !cur ? loadWallPrefs() : cur); if (first || !cur) setMapState(next); else if (!sameView(cur, next)) { setMapState(next); setMapGen((g) => g + 1); } }; setKioskQuery(kioskInQuery()); read(true); const onHash = () => read(false); window.addEventListener("hashchange", onHash); return () => window.removeEventListener("hashchange", onHash); }, []); useEffect(() => { if (!mapState) return; const h = wallHash({ ...link, ...mapState }); if (h !== window.location.hash) { window.history.replaceState(window.history.state, "", window.location.pathname + window.location.search + h); } announceKioskChange(); }, [link, mapState]); const patchLink = useCallback((p: Partial) => setLinkState((cur) => ({ ...cur, ...p })), []); // ── Filter (server-26#235) ──────────────────────────────────────────────── const filterKey = JSON.stringify(link.filter ?? {}); // eslint-disable-next-line react-hooks/exhaustive-deps const filter = useMemo(() => parseFilter(link.filter), [filterKey]); const setFilter = useCallback((f: WallFilter) => patchLink({ filter: serialiseFilter(f) }), [patchLink]); /** A preset switches its map layers on through the saved-view layer state (no MapView edit). */ const onPreset = useCallback((p: PresetKey) => { const cur = mapStateRef.current; if (!cur) return; const have = cur.layers ?? DEFAULT_LAYERS; if (PRESETS[p].layers.every((l) => have.includes(l))) return; const next = { ...cur, layers: normaliseLayers([...have, ...PRESETS[p].layers]) }; saveWallPrefs(next); setMapState(next); setMapGen((g) => g + 1); }, []); const onViewStateChange = useCallback((s: MapViewState) => { setMapState((cur) => { // Keep a known viewport if the map hasn't reported one yet. const next = { ...s, view: s.view ?? cur?.view }; saveWallPrefs(next); return next; }); }, []); const [copied, setCopied] = useState<"ok" | "fail" | null>(null); const copyViewLink = useCallback(() => { const done = (r: "ok" | "fail") => { setCopied(r); setTimeout(() => setCopied(null), 2000); }; try { navigator.clipboard.writeText(window.location.href).then(() => done("ok"), () => done("fail")); } catch { done("fail"); } }, []); // ── Kiosk ───────────────────────────────────────────────────────────────── const kiosk = kioskQuery || !!link.kiosk; const kioskState = useKiosk(kiosk, callsError); const toggleKiosk = useCallback(() => { if (kiosk) { stripKioskQuery(); setKioskQuery(false); patchLink({ kiosk: false }); } else { patchLink({ kiosk: true }); // This click is the user gesture fullscreen needs. if (!kioskState.fullscreen) kioskState.toggleFullscreen(); } }, [kiosk, kioskState, patchLink]); const tourMs = link.tourMs ?? TOUR_MS; const [pinned, setPinned] = useState([]); useEffect(() => { setPinned(loadPinned()); }, []); const pinnedIds = useMemo(() => new Set(pinned.map((c) => c.id)), [pinned]); const togglePin = useCallback((cam: Camera) => { setPinned((prev) => { const next = prev.some((c) => c.id === cam.id) ? prev.filter((c) => c.id !== cam.id) : [...prev, cam].slice(-MAX_PINNED); savePinned(next); return next; }); }, []); // ── Geography ───────────────────────────────────────────────────────────── const callsByIncident = useMemo(() => groupCallsByIncident(recentCalls), [recentCalls]); const filtered = useMemo( () => applyFilter(activeIncidents, recentCalls, filter, callsByIncident), [activeIncidents, recentCalls, filter, callsByIncident], ); const sorted = useMemo(() => worstFirst(filtered.incidents), [filtered.incidents]); const pointsById = useMemo(() => { const m = new Map(); for (const inc of activeIncidents) m.set(inc.incident_id, incidentPoints(inc, callsByIncident.get(inc.incident_id))); return m; }, [activeIncidents, callsByIncident]); // ── Selection (server-26#239) ───────────────────────────────────────────── // Active first; an #inc= that isn't active (resolved, or the active list is // still loading) is looked up by id. Another org's incident never selects: // the rules should deny the read, and the org check here covers a project // where they aren't deployed yet (DEFERRED.md, server-26#13). const activeSelected = link.inc ? activeIncidents.find((i) => i.incident_id === link.inc) ?? null : null; const lookupId = link.inc && !activeSelected ? link.inc : null; const { incident: lookedUp, loading: lookupLoading } = useIncident(lookupId); const fetchedSelected: IncidentDoc | null = lookupId && lookedUp && lookedUp.incident_id === lookupId && !!orgId && (lookedUp as IncidentDoc).org_id === orgId ? lookedUp : null; const selected: IncidentDoc | null = activeSelected ?? fetchedSelected; const lookupPending = !!lookupId && !fetchedSelected && (lookupLoading || !orgId); const notFound = !!lookupId && !fetchedSelected && !lookupPending; const resolvedSelected = fetchedSelected?.status === "resolved" ? fetchedSelected : null; // The selected incident's own calls, by incident id — no limit, so a long // incident keeps every stop the 500-call window has already dropped. The // hook keeps the previous incident's rows until its new snapshot lands, so // rows are re-checked against the current id before merging. const { calls: incidentCalls } = useCallsByIncident(link.inc); const selectedCalls = useMemo(() => { if (!selected) return []; const id = selected.incident_id; const byId = new Map(); for (const c of callsByIncident.get(id) ?? []) byId.set(c.call_id, c); for (const c of incidentCalls) { if (c.incident_ids?.includes(id) || c.incident_id === id) byId.set(c.call_id, c); } return Array.from(byId.values()); }, [selected, callsByIncident, incidentCalls]); const selectedPoints = useMemo(() => (selected ? incidentPoints(selected, selectedCalls) : []), [selected, selectedCalls]); const regionBox = useMemo(() => { const pts = Array.from(pointsById.values()).flat(); // A resolved incident opened by link sits outside the active set; count it // so its nearby cameras load. if (resolvedSelected) pts.push(...selectedPoints); return bboxAround(pts.length ? pts : nodes.map((n) => [n.lat, n.lon] as LatLon), NEAR_CAMERA_KM + 0.5); }, [pointsById, nodes, resolvedSelected, selectedPoints]); const { data: cams, loaded: camsLoaded } = useCamerasInBbox(regionBox); const feeds = useMemo(() => new Map(cams.providers.map((p) => [p.id, p])), [cams.providers]); // Pins live in this browser and carry their image URLs, so a pin must not // outlive its provider: once an admin switches a provider off (licence // gating, server-26#198) its pinned cameras stop rendering until it is back. const servingPinned = useMemo(() => pinned.filter((c) => feeds.has(c.provider)), [pinned, feeds]); const nearById = useMemo(() => { const m = new Map(); pointsById.forEach((pts, id) => m.set(id, nearestCameras(pts, cams.cameras))); return m; }, [pointsById, cams.cameras]); const camCountByIncident = useMemo(() => new Map(Array.from(nearById.entries()).map(([id, n]) => [id, n.length])), [nearById]); const events = useRegionEvents(regionBox); const health = usePipelineHealth({ calls: recentCalls, incidents: activeIncidents, nodes, eventsStatus: events.events_status, cameraProviders: cams.providers, }); const onAir = useMemo(() => onAirIncidentIds(activeIncidents, activeCalls), [activeIncidents, activeCalls]); // A drilled-into incident stays on the map even if the filter excludes it // or it has resolved (a shared #inc= link must still land); it is not added // to the tour. Its full call list rides along so the path is complete. const keepSelected = !!selected && !filtered.ids.has(selected.incident_id); const mapIncidents = useMemo( () => (keepSelected && selected ? [...filtered.incidents, selected] : filtered.incidents), [keepSelected, selected, filtered.incidents], ); const mapCalls = useMemo(() => { if (!selectedCalls.length) return filtered.calls; const have = new Set(filtered.calls.map((c) => c.call_id)); const extra = selectedCalls.filter((c) => !have.has(c.call_id)); return extra.length ? [...filtered.calls, ...extra] : filtered.calls; }, [selectedCalls, filtered.calls]); // Filter choices come from what the wall has loaded (plus system names). const filterOptions = useMemo(() => { const sysNames = new Map(systems.map((s) => [s.system_id, s.name])); const sysIds = new Set(); const tgNames = new Map(); for (const c of recentCalls) { if (c.system_id) sysIds.add(c.system_id); if (c.talkgroup_id != null) { const id = String(c.talkgroup_id); if (c.talkgroup_name || !tgNames.has(id)) tgNames.set(id, c.talkgroup_name || id); } } const tagCount = new Map(); for (const i of activeIncidents) { for (const s of i.system_ids ?? []) sysIds.add(String(s)); for (const t of i.talkgroup_ids ?? []) if (!tgNames.has(String(t))) tgNames.set(String(t), String(t)); for (const t of i.tags ?? []) tagCount.set(t.toLowerCase(), (tagCount.get(t.toLowerCase()) ?? 0) + 1); } const byLabel = (a: FilterOption, b: FilterOption) => a.label.localeCompare(b.label); return { systems: Array.from(sysIds).map((id) => ({ id, label: sysNames.get(id) ?? id })).sort(byLabel), talkgroups: Array.from(tgNames.entries()).map(([id, name]) => ({ id, label: name === id ? id : `${name} (${id})` })).sort(byLabel), tags: Array.from(tagCount.entries()).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, MAX_TAG_CHIPS).map(([t]) => t), }; }, [systems, recentCalls, activeIncidents]); const near = useMemo(() => (selected ? nearestCameras(selectedPoints, cams.cameras) : []), [selected, selectedPoints, cams.cameras]); const drillCam: Camera | null = link.cam ? cams.cameras.find((c) => c.id === link.cam) ?? servingPinned.find((c) => c.id === link.cam) ?? null : null; // Region wall: pinned first, then the nearest camera to each major/moderate incident. const wallCameras = useMemo(() => { const out: { cam: Camera; km?: number }[] = servingPinned.map((cam) => ({ cam })); for (const inc of sorted) { if (severityRank(inc.severity) < 2) continue; const first = nearById.get(inc.incident_id)?.[0]; if (first && !out.some((o) => o.cam.id === first.cam.id)) out.push({ cam: first.cam, km: first.km }); } return out.slice(0, 6); }, [servingPinned, sorted, nearById]); // ── Navigation ──────────────────────────────────────────────────────────── const selectIncident = useCallback((id: string) => patchLink({ inc: id, cam: null, tour: false }), [patchLink]); const openCamera = useCallback((c: Camera) => patchLink({ cam: c.id, tour: false }), [patchLink]); const stepUp = useCallback(() => { if (link.cam) patchLink({ cam: null, tour: false }); else if (link.inc) patchLink({ inc: null, cam: null, tour: false }); else if (link.tour) patchLink({ tour: false }); }, [link, patchLink]); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") stepUp(); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); }, [stepUp]); // ── Auto-tour: worst-first through major/moderate (else all plotted) ────── const tourList = useMemo(() => { const plotted = sorted.filter((i) => (pointsById.get(i.incident_id) ?? []).length); const serious = plotted.filter((i) => severityRank(i.severity) >= 2); return (serious.length ? serious : plotted).map((i) => i.incident_id); }, [sorted, pointsById]); const [tourStart, setTourStart] = useState(0); const [tourNow, setTourNow] = useState(0); useEffect(() => { if (!link.tour || !tourList.length) return; const next = () => { setLinkState((cur) => { const i = cur.inc ? tourList.indexOf(cur.inc) : -1; return { ...cur, inc: tourList[(i + 1) % tourList.length], cam: null, tour: true }; }); setTourStart(Date.now()); }; if (!link.inc || !tourList.includes(link.inc)) next(); else setTourStart(Date.now()); const id = setInterval(next, tourMs); const prog = setInterval(() => setTourNow(Date.now()), 500); return () => { clearInterval(id); clearInterval(prog); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [link.tour, tourList.join(","), tourMs]); // Scrubber honesty (server-26#239): when the window came back full, nothing // older than its oldest call was loaded — that span is unknown, not quiet. const loadedSince = useMemo(() => { if (!windowFull) return null; let oldest: string | null = null; for (const c of recentCalls) if (c.started_at && (!oldest || c.started_at < oldest)) oldest = c.started_at; return oldest; }, [windowFull, recentCalls]); const tourPct = link.tour && tourStart ? Math.min(100, ((tourNow - tourStart) / tourMs) * 100) : 0; // ── KPIs ────────────────────────────────────────────────────────────────── const count = (sev: string) => filtered.incidents.filter((i) => i.severity === sev).length; // "Nodes hearing": a call in the last N min, not merely not-offline — a node // that checks in but hears nothing is not contributing to the picture. const nodesHearing = health.nodesHearing; const eventsOff = !!events.events_status?.disabled; // "Listening" only when every stage is healthy; otherwise the amber bar says why. const configuredButQuiet = !nodesLoading && !callsLoading && nodes.length > 0 && activeIncidents.length === 0 && !health.degraded; const crumb = (label: string, active: boolean, enabled: boolean, onClick: () => void) => ( ); return (
{/* ── Wall bar: drill crumbs · KPIs · auto-tour · clock ── */}
{link.tour && (
{/* Degraded → grey the map and its incident rail (the rail lives inside MapView, so it is wrapped here rather than edited). */}
{nodesLoading || callsLoading || !mapState ? (
Loading map…
) : ( )}
{/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's Activation screen handles the zero-node case). */} {configuredButQuiet && (
● Listening{health.newestCallAt ? ` — last call heard ${timeAgo(health.newestCallAt)}` : ""}
)}
{drillCam && ( km(p, [drillCam.lat, drillCam.lon]))) : null} incidentTitle={selected?.title} pinned={pinnedIds.has(drillCam.id)} onTogglePin={() => togglePin(drillCam)} onClose={() => patchLink({ cam: null })} /> )}
); }