Files
server-26/drb-frontend/components/LiveView.tsx
T
Logan CusanoandClaude Opus 5.5 e6b1581139 Live wall: pinned/docked cameras stop when their provider is switched off; admin-only empty-state CTA
- LiveView: localStorage pins carry image URLs, so they rendered (without
  credit) after an admin disabled the provider. Pins now show only while
  their provider is serving (server-26#198 licence gating).
- MapView: pinned/docked map players drop out once their provider leaves
  the serving list; credits are kept from the last answer so docked players
  keep attribution while the overlay is hidden.
- WallPanels: the "no camera provider enabled" link to /admin was a dead CTA
  for non-admins (bounced to /). Link shown to admins only, deep-linked to
  /admin#cameras; the admin page now honours a tab hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 17:58:49 -04:00

360 lines
17 KiB
TypeScript
Raw 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.
"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<typeof bboxAround>) {
const [data, setData] = useState<Ny511Response>({});
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 (
<span className="inline-flex items-baseline gap-1 text-xs text-ink-muted whitespace-nowrap">
<b className="text-ink font-semibold tabular-nums">{value}</b>{label}
</span>
);
}
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<Date | null>(null);
const [clock, setClock] = useState<string>("");
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<WallLink>({ 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<Camera[]>([]);
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<string, LatLon[]>();
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]);
// 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<string, NearCamera[]>();
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) ?? 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) => 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) => (
<button type="button" disabled={!enabled} onClick={onClick} aria-current={active ? "true" : undefined}
className={`px-1.5 py-0.5 rounded ${active ? "text-ink font-semibold" : enabled ? "text-ink-2 hover:text-ink" : "text-ink-muted"}`}>
{label}
</button>
);
return (
<div className="flex flex-col h-full">
{/* ── Wall bar: drill crumbs · KPIs · auto-tour · clock ── */}
<div className="shrink-0 flex flex-wrap items-center gap-x-4 gap-y-1 px-3 py-1.5 border-b border-line bg-surface/95 text-xs">
<nav aria-label="Drill level" className="flex items-center gap-0.5">
{crumb("Region", !link.inc, true, () => setLink({ inc: null, cam: null, tour: false }))}
<span className="text-ink-muted">›</span>
{crumb(selected?.title ? `Incident: ${selected.title.slice(0, 32)}` : "Incident", !!link.inc && !link.cam, !!link.inc,
() => setLink({ ...link, cam: null, tour: false }))}
<span className="text-ink-muted">›</span>
{crumb("Camera", !!link.cam, !!link.cam, () => undefined)}
</nav>
<div className="hidden md:flex items-center gap-3 flex-wrap">
<Kpi value={count("major")} label="major" />
<Kpi value={count("moderate")} label="moderate" />
<Kpi value={activeIncidents.length} label="active" />
<Kpi value={`${nodesUp}/${nodes.length}`} label="nodes" />
<Kpi value={cams.cameras.length} label="cams" />
<Kpi value={aircraft.length} label="aircraft" />
<Kpi value={vessels.length} label="vessels" />
<Kpi value={eventsOff ? "—" : events.events?.length ?? 0} label="road events" />
</div>
<div className="ml-auto flex items-center gap-3">
<button type="button" aria-pressed={link.tour} onClick={() => setLink({ ...link, cam: null, tour: !link.tour })}
title="Wall mode: cycle through active incidents, worst first"
className={`px-2 py-0.5 rounded border ${link.tour ? "bg-accent text-white border-accent" : "border-line text-ink-2 hover:text-ink"}`}>
Auto-tour
</button>
{link.tour && (
<span className="w-20 h-1 bg-raised rounded overflow-hidden" aria-hidden="true">
<span className="block h-full bg-accent" style={{ width: `${tourPct}%` }} />
</span>
)}
<span className="font-mono text-ink-2 tabular-nums">{clock}</span>
</div>
</div>
<div className="flex-1 min-h-0 flex flex-col lg:flex-row">
<div className="relative h-[55vh] lg:h-auto lg:flex-1 min-w-0 shrink-0">
{nodesLoading || callsLoading ? (
<div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">Loading map…</div>
) : (
<MapView
nodes={nodes}
activeCalls={activeCalls}
incidents={activeIncidents}
calls={recentCalls}
lastUpdated={lastUpdated}
selectedIncidentId={selected?.incident_id ?? null}
onSelectIncident={selectIncident}
nearCameras={near}
onOpenCamera={openCamera}
onAir={onAir}
camCountByIncident={camCountByIncident}
/>
)}
{/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's
Activation screen handles the zero-node case). */}
{configuredButQuiet && (
<div className="absolute top-12 left-1/2 -translate-x-1/2 z-[1001] pointer-events-none">
<span className="bg-surface/90 border border-line rounded-full px-3 py-1 text-xs text-ink-2 whitespace-nowrap">
● Listening{mostRecentSeen ? ` — last check-in ${timeAgo(new Date(mostRecentSeen))}` : ""}
</span>
</div>
)}
</div>
<aside className="lg:w-[360px] lg:shrink-0 flex-1 lg:flex-none min-h-0 overflow-y-auto border-t lg:border-t-0 lg:border-l border-line bg-surface"
aria-live="polite" aria-label={selected ? "Incident detail" : "Region overview"}>
{selected ? (
<IncidentPanel
inc={selected}
calls={callsByIncident.get(selected.incident_id) ?? []}
points={selectedPoints}
near={near}
feeds={feeds}
servingCount={camsLoaded ? cams.serving_count : 1}
onAir={onAir.has(selected.incident_id)}
aircraft={aircraft}
vessels={vessels}
events={events.events ?? []}
eventsCredit={{ credit: events.credit, fetched_at: events.events_status?.fetched_at ?? null }}
onOpenCamera={openCamera}
/>
) : (
<RegionPanel
incidents={activeIncidents}
calls={recentCalls}
onAirCount={onAir.size}
nodes={nodes}
wallCameras={wallCameras}
feeds={feeds}
servingCount={camsLoaded ? cams.serving_count : 1}
aircraftCount={aircraft.length}
vesselCount={vessels.length}
events={events.events ?? []}
eventsOff={eventsOff}
pinnedIds={pinnedIds}
onOpenCamera={openCamera}
/>
)}
</aside>
</div>
<TranscriptTicker calls={recentCalls} onSelectIncident={selectIncident} />
<TimeScrubber calls={recentCalls} incidents={activeIncidents} selectedIncidentId={selected?.incident_id ?? null} onSelectIncident={selectIncident} />
{drillCam && (
<CameraDrill
camera={drillCam}
feed={feeds.get(drillCam.provider)}
distanceKm={selectedPoints.length ? Math.min(...selectedPoints.map((p) => km(p, [drillCam.lat, drillCam.lon]))) : null}
incidentTitle={selected?.title}
pinned={pinnedIds.has(drillCam.id)}
onTogglePin={() => togglePin(drillCam)}
onClose={() => setLink({ ...link, cam: null })}
/>
)}
</div>
);
}