Files
server-26/drb-frontend/components/LiveView.tsx
T
Logan CusanoandClaude Opus 5.5 36fdfb2758 map: mount without waiting on the 500-call window; legend collapsible, hidden under open layers list
The Live map was held behind useCalls(500), measured at ~9.2s to first map
paint on prod; it now mounts as soon as the saved view is known (nodes are
awaited only when there is no view). The legend painted over the expanded
Leaflet layers list (z-1001 vs the map's z-0 stacking context): it is now a
<details> that hides while the list is open and starts folded on short
viewports.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 21:40:57 -05:00

687 lines
36 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).
*
* 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.
*
* Attention cues (server-26#238): lib/attention.ts diffs successive snapshots
* (Major opened, severity up, pursuit path stop, priority keyword) after a
* silent baseline. Cues, the NEW lane and pin pulses only ever cover the
* FILTERED incidents; tone and notifications are opt-in (AttentionControls).
*/
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 { useActiveIncidentsState, 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";
import { DEFAULT_ATTENTION_PREFS, loadAttentionPrefs, saveAttentionPrefs, useAttention, type AttentionPrefs } from "@/lib/attention";
import { NewLane } from "@/components/live/NewLane";
import { AttentionControls, useAttentionCues } from "@/components/live/AttentionControls";
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-[10px]']]: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<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 { incidents: activeIncidents, loaded: incidentsLoaded } = useActiveIncidentsState();
// 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<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 })), []);
// ── 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<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 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<string, LatLon[]>();
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 lookupPending = !!lookupId && !fetchedSelected && (lookupLoading || !orgId);
const notFound = !!lookupId && !fetchedSelected && !lookupPending;
// The incident on screen resolving drops it from the active list before its
// by-id lookup lands. Hold the last doc for that same id meanwhile, so the
// wall doesn't fly back to the region and straight back again.
const lastSelected = useRef<IncidentDoc | null>(null);
const held = lookupPending && lastSelected.current?.incident_id === link.inc ? lastSelected.current : null;
const selected: IncidentDoc | null = activeSelected ?? fetchedSelected ?? held;
lastSelected.current = selected;
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<CallRecord[]>(() => {
if (!selected) return [];
const id = selected.incident_id;
const byId = new Map<string, CallRecord>();
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<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]);
// 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<string>();
const tgNames = new Map<string, string>();
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<string, number>();
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]);
// ── Attention cues (server-26#238) ────────────────────────────────────────
// Diffed over everything loaded (so widening the filter is never "new");
// emitted, laned and pulsed only for what the filter shows.
const [attnPrefs, setAttnPrefs] = useState<AttentionPrefs>(DEFAULT_ATTENTION_PREFS);
useEffect(() => { setAttnPrefs(loadAttentionPrefs()); }, []);
const changeAttnPrefs = useCallback((p: AttentionPrefs) => { setAttnPrefs(p); saveAttentionPrefs(p); }, []);
const cues = useAttentionCues(attnPrefs, selectIncident);
const attentionCalls = useMemo(() => {
if (!incidentCalls.length) return recentCalls;
const have = new Set(recentCalls.map((c) => c.call_id));
const extra = incidentCalls.filter((c) => !have.has(c.call_id));
return extra.length ? [...recentCalls, ...extra] : recentCalls;
}, [recentCalls, incidentCalls]);
const visibleCallIds = useMemo(() => new Set(filtered.calls.map((c) => c.call_id)), [filtered.calls]);
const attention = useAttention({
ready: incidentsLoaded && !!orgId,
resetKey: orgId ?? null,
incidents: activeIncidents,
calls: attentionCalls,
keywords: attnPrefs.keywords,
visible: filtered.incidents,
visibleIds: filtered.ids,
visibleCallIds,
onEvents: cues.fire,
});
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) => (
<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={filtered.active ? `${filtered.incidents.length} of ${activeIncidents.length}` : activeIncidents.length}
label={filtered.active ? "active · filtered" : "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>
<AttentionControls prefs={attnPrefs} onChange={changeAttnPrefs} armed={cues.armed} />
<KioskControls kiosk={kiosk} state={kioskState} onToggleKiosk={toggleKiosk} />
<span className="font-mono text-ink-2 tabular-nums">{clock}</span>
</div>
</div>
<FilterBar
filter={filter}
onChange={setFilter}
onPreset={onPreset}
systems={filterOptions.systems}
talkgroups={filterOptions.talkgroups}
tags={filterOptions.tags}
shown={filtered.incidents.length}
total={activeIncidents.length}
readOnly={kiosk}
/>
<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" : ""}`}>
{/* Mount the map as soon as its starting view is known. The 500-call
window took ~9s on prod and used to hold the whole map back;
calls and paths now stream in once it lands. Nodes are only
awaited when there's no saved view, since they pick the centre. */}
{!mapState || (!mapState.view && nodesLoading) ? (
<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={mapIncidents}
calls={mapCalls}
selectedIncidentId={selected?.incident_id ?? null}
onSelectIncident={selectIncident}
nearCameras={near}
onOpenCamera={openCamera}
onAir={onAir}
camCountByIncident={camCountByIncident}
pulseIncidentIds={attention.pulseIds}
filteredFrom={filtered.active ? activeIncidents.length : undefined}
key={mapGen}
initialLayers={mapState.layers}
initialBase={mapState.base}
initialView={mapState.view}
onViewStateChange={onViewStateChange}
/>
)}
</div>
{!nodesLoading && !callsLoading && mapState && (
<NewLane entries={attention.lane} now={attention.now} onSelect={selectIncident} />
)}
{/* 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={link.inc ? "Incident detail" : "Region overview"}>
{link.inc && !selected ? (
<div className="p-4 text-sm">
{lookupPending ? (
<p className="text-ink-muted">Loading incident…</p>
) : notFound ? (
<>
<p className="font-semibold text-ink">Incident not found</p>
<p className="mt-1 text-xs text-ink-muted">
This link points to an incident that doesn&apos;t exist or isn&apos;t in your organization.
</p>
<button type="button" onClick={() => patchLink({ inc: null, cam: null, tour: false })}
className="mt-3 px-2 py-0.5 rounded border border-line text-xs text-ink-2 hover:text-ink">
Back to region
</button>
</>
) : null}
</div>
) : selected ? (
<>
{resolvedSelected && (
<div role="status" className="px-3 py-1.5 border-b border-line bg-raised text-xs text-ink-2">
<b className="text-ink font-semibold">{resolvedLabel(resolvedSelected)}</b>
{" · no longer active — opened from a link"}
</div>
)}
<IncidentPanel
inc={selected}
calls={selectedCalls}
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={filtered.incidents}
calls={filtered.calls}
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={filtered.calls} onSelectIncident={selectIncident} />
<TimeScrubber calls={filtered.calls} incidents={filtered.incidents} selectedIncidentId={selected?.incident_id ?? null}
onSelectIncident={selectIncident} loadedSince={loadedSince} windowSize={RECENT_CALLS} />
{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>
);
}