- LiveView: the selected incident merges useCallsByIncident (org_id + incident_ids CONTAINS, existing index, no orderBy/limit) into the calls fed to IncidentPanel and MapView, so a long incident keeps its full path and spine past the 500-call window. Stale rows from the previous selection are re-checked against the current id before merging. - #inc= not in the active set is fetched with useIncident: a resolved one is selected, drawn on the map and shown under a "Resolved HH:MM" banner; a missing or other-org id shows "Incident not found". Auto-tour stays on active incidents. - TimeScrubber: new loadedSince prop (oldest call when the window came back full). The axis before it is hatched and labelled "not loaded" instead of reading as a quiet stretch. - useCalls returns hasMore (full page, counted before the duplicate_of filter); useIncident resets loading on a new id so a lookup doesn't flash "not found". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
636 lines
32 KiB
TypeScript
636 lines
32 KiB
TypeScript
"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.
|
||
*/
|
||
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 { useActiveIncidents, 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";
|
||
|
||
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-[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;
|
||
|
||
/** 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 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, 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 selected: IncidentDoc | null = activeSelected ?? fetchedSelected;
|
||
const lookupPending = !!lookupId && !fetchedSelected && (lookupLoading || !orgId);
|
||
const notFound = !!lookupId && !fetchedSelected && !lookupPending;
|
||
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]);
|
||
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>
|
||
<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={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't exist or isn'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>
|
||
);
|
||
}
|