Files
server-26/drb-frontend/components/LiveView.tsx
T
Logan CusanoandClaude Opus 5.5 afd2bc463d Live wall: saved views in the URL hash, per-viewer memory, kiosk mode (#234)
- liveWall.ts: WallLink carries layers (short stable keys), z/lat/lon,
  base, tourMs, kiosk and an f.<name> filter pass-through for #235;
  parse/serialise stay pure and round-trip; old inc/cam/tour links parse.
- MapView: overlays/basemap/viewport come from initialLayers/initialBase/
  initialView (defaults unchanged); onViewStateChange reports layer, base
  and region-view changes (debounced). Initially-checked overlays now start
  "shown" (react-leaflet adds a checked overlay before the control can fire
  overlayadd). Closing an incident flies back to the pre-drill view (#137).
- wallPrefs.ts: per-browser last layers/base/view in localStorage, every
  access wrapped; precedence hash > remembered > defaults.
- LiveView: one effect mirrors wall + map state into the hash; hash edits
  remount the map with the new view; Copy view link; configurable tour.
- Kiosk (?kiosk=1 or #kiosk): no nav, fullscreen button (auto on first
  touch), Screen Wake Lock re-acquired on visibility, reconnect banner
  (offline / useCalls error) with backoff reload, 18-20 px type via a
  data-kiosk attribute and Tailwind arbitrary variants.

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

463 lines
23 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.
*
* 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).
*/
import { useCallback, useEffect, useMemo, useRef, 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, sameView, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink,
} from "@/lib/liveWall";
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;
// 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_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, error: callsError } = useCalls(500);
const { aircraft } = useAircraft();
const { vessels } = useVessels();
const [clock, setClock] = useState<string>("");
// 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<WallLink>({ inc: null, cam: null, tour: false });
const [mapState, setMapState] = useState<MapViewState | null>(null);
const mapStateRef = useRef<MapViewState | null>(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<WallLink>) => setLinkState((cur) => ({ ...cur, ...p })), []);
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<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 health = usePipelineHealth({
calls: recentCalls,
incidents: activeIncidents,
nodes,
eventsStatus: events.events_status,
cameraProviders: cams.providers,
});
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) => 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]);
const tourPct = link.tour && tourStart ? Math.min(100, ((tourNow - tourStart) / tourMs) * 100) : 0;
// ── KPIs ──────────────────────────────────────────────────────────────────
const count = (sev: string) => activeIncidents.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) => (
<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 data-kiosk={kiosk ? "" : undefined} className={`flex flex-col h-full ${KIOSK_TYPE}`}>
{/* ── 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, () => patchLink({ 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,
() => patchLink({ 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={`${nodesHearing}/${nodes.length}`} label={`nodes hearing (${health.staleMin}m)`} />
<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">
<TruthStrip health={health} placement="chips" />
<button type="button" aria-pressed={link.tour} onClick={() => patchLink({ 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>
)}
<button type="button" onClick={copyViewLink} title="Copy a link to this exact view: layers, map position, selection"
className="px-2 py-0.5 rounded border border-line text-ink-2 hover:text-ink whitespace-nowrap" aria-live="polite">
{copied === "ok" ? "Link copied" : copied === "fail" ? "Copy failed — use the address bar" : "Copy view link"}
</button>
<KioskControls kiosk={kiosk} state={kioskState} onToggleKiosk={toggleKiosk} />
<span className="font-mono text-ink-2 tabular-nums">{clock}</span>
</div>
</div>
<KioskBanner banner={kioskState.banner} onRetry={kioskState.retryNow} />
<TruthStrip health={health} placement="bar" />
<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">
{/* Degraded → grey the map and its incident rail (the rail lives inside
MapView, so it is wrapped here rather than edited). */}
<div className={`w-full h-full transition-[filter,opacity] ${health.degraded ? "grayscale opacity-60" : ""}`}>
{nodesLoading || callsLoading || !mapState ? (
<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}
selectedIncidentId={selected?.incident_id ?? null}
onSelectIncident={selectIncident}
nearCameras={near}
onOpenCamera={openCamera}
onAir={onAir}
camCountByIncident={camCountByIncident}
key={mapGen}
initialLayers={mapState.layers}
initialBase={mapState.base}
initialView={mapState.view}
onViewStateChange={onViewStateChange}
/>
)}
</div>
{/* 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{health.newestCallAt ? ` — last call heard ${timeAgo(health.newestCallAt)}` : ""}
</span>
</div>
)}
</div>
<aside className={`${kiosk ? "lg:w-[480px]" : "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={() => patchLink({ cam: null })}
/>
)}
</div>
);
}