"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. */ import { useCallback, useEffect, useMemo, useState } from "react"; import dynamic from "next/dynamic"; import { useNodes } from "@/lib/useNodes"; import { useActiveCalls, useCalls } from "@/lib/useCalls"; import { useActiveIncidents } from "@/lib/useIncidents"; 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 { Camera, Ny511Response } from "@/lib/types"; import { NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, onAirIncidentIds, parseWallHash, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink, } from "@/lib/liveWall"; import { TimeScrubber } from "@/components/TimeScrubber"; import { TranscriptTicker } from "@/components/live/TranscriptTicker"; import { CameraDrill } from "@/components/live/CameraDrill"; import { IncidentPanel, RegionPanel } from "@/components/live/WallPanels"; const MapView = dynamic(() => import("@/components/MapView"), { ssr: false }); const TOUR_MS = 20_000; const PIN_KEY = "drb.wall.pinnedCameras"; const MAX_PINNED = 6; const EVENTS_POLL_MS = 2 * 60 * 1000; 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 } = useCalls(500); const { aircraft } = useAircraft(); const { vessels } = useVessels(); const [lastUpdated, setLastUpdated] = useState(null); const [clock, setClock] = useState(""); useEffect(() => { if (!nodesLoading) setLastUpdated(new Date()); }, [nodes, activeCalls, activeIncidents, nodesLoading]); 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 ────────────────────────────────── const [link, setLinkState] = useState({ inc: null, cam: null, tour: false }); useEffect(() => { const read = () => setLinkState(parseWallHash(window.location.hash)); read(); window.addEventListener("hashchange", read); return () => window.removeEventListener("hashchange", read); }, []); const setLink = useCallback((next: WallLink) => { setLinkState(next); const h = wallHash(next); window.history.replaceState(null, "", h || window.location.pathname + window.location.search); }, []); 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 sorted = useMemo(() => worstFirst(activeIncidents), [activeIncidents]); 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]); const regionBox = useMemo(() => { const pts = Array.from(pointsById.values()).flat(); return bboxAround(pts.length ? pts : nodes.map((n) => [n.lat, n.lon] as LatLon), NEAR_CAMERA_KM + 0.5); }, [pointsById, nodes]); const { data: cams, loaded: camsLoaded } = useCamerasInBbox(regionBox); const feeds = useMemo(() => new Map(cams.providers.map((p) => [p.id, p])), [cams.providers]); 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 onAir = useMemo(() => onAirIncidentIds(activeIncidents, activeCalls), [activeIncidents, activeCalls]); const selected = link.inc ? activeIncidents.find((i) => i.incident_id === link.inc) ?? null : null; const selectedPoints = selected ? pointsById.get(selected.incident_id) ?? [] : []; const near = selected ? nearById.get(selected.incident_id) ?? [] : []; const drillCam: Camera | null = link.cam ? cams.cameras.find((c) => c.id === link.cam) ?? pinned.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 }[] = pinned.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); }, [pinned, sorted, nearById]); // ── Navigation ──────────────────────────────────────────────────────────── const selectIncident = useCallback((id: string) => setLink({ inc: id, cam: null, tour: false }), [setLink]); const openCamera = useCallback((c: Camera) => setLink({ ...link, cam: c.id, tour: false }), [link, setLink]); const stepUp = useCallback(() => { if (link.cam) setLink({ ...link, cam: null, tour: false }); else if (link.inc) setLink({ inc: null, cam: null, tour: false }); else if (link.tour) setLink({ ...link, tour: false }); }, [link, setLink]); 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; const nxt: WallLink = { inc: tourList[(i + 1) % tourList.length], cam: null, tour: true }; window.history.replaceState(null, "", wallHash(nxt)); return nxt; }); setTourStart(Date.now()); }; if (!link.inc || !tourList.includes(link.inc)) next(); else setTourStart(Date.now()); const id = setInterval(next, TOUR_MS); const prog = setInterval(() => setTourNow(Date.now()), 500); return () => { clearInterval(id); clearInterval(prog); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [link.tour, tourList.join(",")]); const tourPct = link.tour && tourStart ? Math.min(100, ((tourNow - tourStart) / TOUR_MS) * 100) : 0; // ── KPIs ────────────────────────────────────────────────────────────────── const count = (sev: string) => activeIncidents.filter((i) => i.severity === sev).length; const nodesUp = nodes.filter((n) => n.status !== "offline").length; const eventsOff = !!events.events_status?.disabled; const configuredButQuiet = !nodesLoading && nodes.length > 0 && activeIncidents.length === 0; const mostRecentSeen = configuredButQuiet ? nodes.map((n) => n.last_seen).filter((s): s is string => !!s).sort().at(-1) : null; const crumb = (label: string, active: boolean, enabled: boolean, onClick: () => void) => ( ); return (
{/* ── Wall bar: drill crumbs · KPIs · auto-tour · clock ── */}
{link.tour && (
{nodesLoading || callsLoading ? (
Loading map…
) : ( )} {/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's Activation screen handles the zero-node case). */} {configuredButQuiet && (
● Listening{mostRecentSeen ? ` — last check-in ${timeAgo(new Date(mostRecentSeen))}` : ""}
)}
{drillCam && ( km(p, [drillCam.lat, drillCam.lon]))) : null} incidentTitle={selected?.title} pinned={pinnedIds.has(drillCam.id)} onTogglePin={() => togglePin(drillCam)} onClose={() => setLink({ ...link, cam: null })} /> )}
); }