Files
server-26/drb-frontend/components/LiveView.tsx
T
Logan CusanoandClaude Opus 5.5 f479578942 Live wall: filter bar, discipline presets and text search (#235)
lib/liveFilter.ts (pure): WallFilter {types, sevFloor, systems, talkgroups,
ageH, tags, q, preset}, applyFilter over loaded incidents + calls, parse /
serialise to the f.* hash keys #234 reserved. Presets: Fire (fire + EMS
assists heard on a fire channel; Radar on), Storm (weather/wires/tree/flood
tags; Radar on), Traffic (accident / MVA tags; Traffic Events on).

components/live/FilterBar.tsx: one wrapping row under the wall bar; less-used
controls fold behind a toggle below md; read-only summary in kiosk.

LiveView applies the filter once and feeds the filtered lists to MapView
(pins, paths, rail), RegionPanel, TranscriptTicker, TimeScrubber and the
auto-tour; KPIs count the filtered set and say "filtered". Presets turn
layers on through the saved-view layer state, not a MapView edit. Pipeline
health stays unfiltered. liveWall.ts keeps commas literal in f.* values.

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

543 lines
27 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.
*/
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 } 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 {
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;
// 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;
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 { 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]);
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;
// A drilled-into incident stays on the map even if the filter excludes it
// (a shared #inc= link must still land); it is not added to the tour.
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 (!keepSelected || !selected) return filtered.calls;
const have = new Set(filtered.calls.map((c) => c.call_id));
return [...filtered.calls, ...(callsByIncident.get(selected.incident_id) ?? []).filter((c) => !have.has(c.call_id))];
}, [keepSelected, selected, filtered.calls, callsByIncident]);
// 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 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) => 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>
<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" : ""}`}>
{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={mapIncidents}
calls={mapCalls}
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={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} />
{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>
);
}