Live ops wall + pluggable camera providers with licence gating (server-26#198)

Backend (c2-core):
- app/internal/cameras/: provider interface, licence model, per-provider
  catalog cache; one adapter per source in design-canvas/camera-sources.md.
  Implemented: 511NY (legacy shape + tolerant XML inventory parser, 511x
  endpoints UNVERIFIED), Caltrans, DelDOT, Ontario 511, DriveBC, Calgary,
  TfL, NSW, Fintraffic. Stubs: NYC DOT, Westchester DPW, TRANSCOM, CTroads,
  511NJ, Austin, TxDOT, MD CHART, WSDOT, Tallinn, Tarktee, Warendorf,
  TrafficLand, Windy, EarthCam.
- Every provider defaults OFF (config/camera_providers). Gated licences
  (agreement required / unverified / non-commercial / paid) need an explicit
  acknowledgement stored with who/when and written to audit_log; the audit
  write is a precondition. Stubs and prohibited sources cannot be enabled.
  Ack is bound to the licence status it was given for.
- GET /cameras (signed-in), GET/PUT /admin/camera-providers (admin only).
- /traffic/511 follows the ny511 provider switch and returns the DAA credit
  + NYSDOT as-is disclaimer; 511NY event descriptions no longer truncated
  ("may not alter the substance of a report").

Frontend:
- LiveView becomes the ops wall: Region > Incident > Camera drill-down,
  worst-first rail (MapView), KPIs, transcript ticker, scrubber incident
  bands, auto-tour, deep links (#inc=&cam=&tour), Esc steps up.
- Incident panel: nearest cameras (path-weighted), fused situational context
  (ADS-B, AIS, 511NY events, NEXRAD tile sample), units, call spine.
- MapView keeps every layer; adds Camera Viewsheds (approx.) overlay and a
  nearest-camera layer for the selected incident.
- CameraFeed hygiene: hidden-tab/off-screen gating, 2 concurrent HLS cap,
  last good frame + stale badge, frozen-frame signature, HLS stall watchdog,
  provider attribution rendered on the imagery.
- Camera/event popups show source credit, update time and the disclaimer.
- Admin > Camera providers tab with licence status, health, gated confirm.
- Camera detection is not built (blocked on #199/#200); seam only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Logan Cusano
2026-10-04 17:56:06 -04:00
co-authored by Claude Opus 5.5
parent d57738a8f6
commit aa5b71d853
28 changed files with 3342 additions and 132 deletions
+205 -53
View File
@@ -6,7 +6,9 @@ import {
FeatureGroup,
LayersControl,
MapContainer,
Circle,
Marker,
Polygon,
Polyline,
Popup,
TileLayer,
@@ -22,8 +24,11 @@ import { useAircraft } from "@/lib/useAircraft";
import { useAircraftTrail } from "@/lib/useAircraftTrail";
import { useVessels } from "@/lib/useVessels";
import { use511 } from "@/lib/use511";
import type { Ny511Camera, Ny511Event, Ny511FeedStatus } from "@/lib/types";
import { useMapCameras } from "@/lib/useCameras";
import type { Camera, CameraCredit, CameraProviderFeed, CamerasResponse, Ny511Event, Ny511FeedStatus } from "@/lib/types";
import { CameraFeed } from "@/components/CameraFeed";
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
import { NEAR_CAMERA_KM, groupCallsByIncident, incidentPoints, viewshedWedge, worstFirst } from "@/lib/liveWall";
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
@@ -341,6 +346,7 @@ function RadarLayer() {
// Overlay names double as the event keys for overlayadd/overlayremove.
const OVERLAY_DOT_CAMERAS = "DOT Cameras";
const OVERLAY_TRAFFIC_EVENTS = "Traffic Events";
const OVERLAY_VIEWSHEDS = "Camera Viewsheds (approx.)";
/** True while the named LayersControl overlay is checked. Overlays start unchecked. */
function useOverlayShown(map: L.Map, name: string): boolean {
@@ -450,6 +456,7 @@ function fmtLocal(iso: string | null): string | null {
function FeedProblem({ map, label, status, fetchError }: { map: L.Map; label: string; status?: Ny511FeedStatus; fetchError: string | null }) {
let msg: string | null = null;
if (fetchError) msg = `${label}: could not reach server`;
else if (status?.disabled) msg = `${label}: 511NY feed is off — an admin enables it under Admin → Camera providers`;
else if (status?.error) {
const age = status.fetched_at ? `showing data from ${Math.round((Date.now() / 1000 - status.fetched_at) / 60)} min ago` : "no data yet";
msg = `${label}: 511NY feed error, ${age}`;
@@ -461,19 +468,66 @@ function FeedProblem({ map, label, status, fetchError }: { map: L.Map; label: st
);
}
/** Provider id → what must render with its imagery. */
function creditsByProvider(providers: CameraProviderFeed[]): Map<string, CameraProviderFeed> {
return new Map(providers.map((p) => [p.id, p]));
}
/** Same honesty as FeedProblem, for the multi-provider camera layer. */
function CameraProblem({ map, data, loaded, fetchError }: { map: L.Map; data: CamerasResponse; loaded: boolean; fetchError: string | null }) {
let msg: string | null = null;
if (fetchError) msg = "Cameras: could not reach server";
else if (loaded && data.serving_count === 0) msg = "Cameras: no camera provider is enabled — Admin → Camera providers";
else {
const bad = data.providers.find((p) => p.error);
const cut = data.providers.find((p) => p.truncated);
if (bad) msg = `${bad.name}: feed error, ${bad.fetched_at ? `showing data from ${fetchedLabel(bad.fetched_at)}` : "no data yet"}`;
else if (cut) msg = `Cameras: too many in view — zoom in`;
}
if (!msg) return null;
return createPortal(
<div className="absolute bottom-8 left-3 z-[1001] bg-surface/90 border border-line rounded px-2 py-1 text-xs text-ink-2 pointer-events-none">{msg}</div>,
map.getContainer(),
);
}
/** Popup body for one camera: still, credit, catalog freshness, actions. */
function CameraPopupBody({ c, feed, onPin, onDock, onOpen }: {
c: Camera; feed?: CameraProviderFeed; onPin: () => void; onDock: () => void; onOpen?: () => void;
}) {
return (
<div className="space-y-1">
<div className="font-semibold">{c.name}</div>
{c.roadway && <div className="text-xs text-ink-muted">{c.roadway}{c.direction && c.direction !== "Unknown" ? ` · ${c.direction}` : ""}</div>}
{/* Popup content mounts on open, so the still is fetched per open. */}
<button type="button" onClick={onOpen ?? onPin} title="Open this camera" className="block w-full p-0 border-0 bg-transparent cursor-pointer">
<CameraFeed camera={c} mode="still" credit={feed} className="w-full h-[150px] rounded border border-line bg-black" />
</button>
<div className="flex gap-1.5 pt-0.5">
{onOpen && <button type="button" onClick={onOpen} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Open</button>}
<button type="button" onClick={onPin} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Pin to map</button>
<button type="button" onClick={onDock} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Dock</button>
</div>
<div className="text-[10px] text-ink-muted">{c.video_url ? "Live feed available" : "Snapshot"}</div>
<SourceCredit credit={feed} fallbackName={feed?.name} updated={feed ? `camera list ${fetchedLabel(feed.fetched_at) ?? "—"}` : null} />
</div>
);
}
// Open cameras: each is either pinned (a small tile anchored at the camera, so
// several views along one road read against the map) or docked (a larger tile
// in a strip over the map, which stays put while you pan and survives the
// overlay being switched off). Oldest camera drops past the cap.
const MAX_OPEN_CAMERAS = 6;
type CameraMode = "pin" | "dock";
interface OpenCamera { cam: Ny511Camera; mode: CameraMode }
interface OpenCamera { cam: Camera; mode: CameraMode }
const camBtn = "text-ink-muted hover:text-ink text-xs leading-none px-0.5";
function CameraDock({ map, open, onMode, onClose }: {
function CameraDock({ map, open, credits, onMode, onClose }: {
map: L.Map;
open: Ny511Camera[];
open: Camera[];
credits: Map<string, CameraCredit>;
onMode: (id: string, mode: CameraMode) => void;
onClose: (id: string) => void;
}) {
@@ -497,7 +551,7 @@ function CameraDock({ map, open, onMode, onClose }: {
<button type="button" onClick={() => onMode(c.id, "pin")} title="Pin to the map at the camera" aria-label={`Pin ${c.name} to the map`} className={camBtn}>⌖</button>
<button type="button" onClick={() => onClose(c.id)} aria-label={`Close ${c.name}`} className={camBtn}>×</button>
</div>
<CameraFeed camera={c} className="block w-[240px] h-[135px] object-cover bg-black rounded-b-md" />
<CameraFeed camera={c} credit={credits.get(c.provider)} className="block w-[240px] h-[135px] bg-black rounded-b-md" />
</div>
))}
</div>,
@@ -505,20 +559,21 @@ function CameraDock({ map, open, onMode, onClose }: {
);
}
function DotCameraLayer() {
function DotCameraLayer({ onOpenCamera }: { onOpenCamera?: (c: Camera) => void }) {
const map = useMap();
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
const zoom = useMapZoom(map);
const { data, error } = use511(map, "cameras", shown);
const { data, error, loaded } = useMapCameras(map, shown);
const [open, setOpen] = useState<OpenCamera[]>([]);
const feeds = useMemo(() => creditsByProvider(data.providers), [data.providers]);
const pinnedIds = useMemo(() => new Set(open.filter((o) => o.mode === "pin").map((o) => o.cam.id)), [open]);
const cells = useMemo(
() => lodCells((data.cameras ?? []).filter((c) => !pinnedIds.has(c.id)), map, zoom),
() => lodCells(data.cameras.filter((c) => !pinnedIds.has(c.id)), map, zoom),
[data.cameras, pinnedIds, map, zoom],
);
const show = (cam: Ny511Camera, mode: CameraMode) => {
const show = (cam: Camera, mode: CameraMode) => {
map.closePopup();
setOpen((prev) => [...prev.filter((o) => o.cam.id !== cam.id), { cam, mode }].slice(-MAX_OPEN_CAMERAS));
};
@@ -528,7 +583,7 @@ function DotCameraLayer() {
return (
<>
{shown && <FeedProblem map={map} label="DOT cameras" status={data.cameras_status} fetchError={error} />}
{shown && <CameraProblem map={map} data={data} loaded={loaded} fetchError={error} />}
{cells.map(({ rep: c, count }) => count > 1 ? (
<Marker key={`lod-${c.id}`} position={[c.lat, c.lon]} icon={cameraIcon(count)}
title={`${count} cameras — click to zoom in`}
@@ -536,24 +591,8 @@ function DotCameraLayer() {
) : (
<Marker key={c.id} position={[c.lat, c.lon]} icon={cameraIcon()}>
<Popup minWidth={260} maxWidth={340}>
<div className="space-y-1">
<div className="font-semibold">{c.name}</div>
{c.roadway && <div className="text-xs text-ink-muted">{c.roadway}{c.direction && c.direction !== "Unknown" ? ` · ${c.direction}` : ""}</div>}
{c.image_url && (
// Popup content mounts on open, so the timestamp busts the cache per open.
<button type="button" onClick={() => show(c, "pin")} title="Pin this camera to the map" className="block w-full p-0 border-0 bg-transparent cursor-pointer">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={`${c.image_url}?t=${Date.now()}`} alt={`Camera: ${c.name}`} className="w-full rounded border border-line" loading="lazy" />
</button>
)}
<div className="flex gap-1.5 pt-0.5">
<button type="button" onClick={() => show(c, "pin")} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Pin to map</button>
<button type="button" onClick={() => show(c, "dock")} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Dock</button>
</div>
<div className="text-[10px] text-ink-muted">
{c.video_url ? "Live feed" : "Snapshot"} · 511NY / NYSDOT
</div>
</div>
<CameraPopupBody c={c} feed={feeds.get(c.provider)} onPin={() => show(c, "pin")} onDock={() => show(c, "dock")}
onOpen={onOpenCamera ? () => { map.closePopup(); onOpenCamera(c); } : undefined} />
</Popup>
</Marker>
))}
@@ -567,12 +606,70 @@ function DotCameraLayer() {
<button type="button" onClick={() => setMode(c.id, "dock")} title="Dock" aria-label={`Dock ${c.name}`} className={camBtn}>⇲</button>
<button type="button" onClick={() => close(c.id)} aria-label={`Close ${c.name}`} className={camBtn}>×</button>
</div>
<CameraFeed camera={c} className="block w-[192px] h-[108px] object-cover bg-black rounded-b" />
<CameraFeed camera={c} credit={feeds.get(c.provider)} className="block w-[192px] h-[108px] bg-black rounded-b" />
</div>
</Tooltip>
</Marker>
))}
<CameraDock map={map} open={open.filter((o) => o.mode === "dock").map((o) => o.cam)} onMode={setMode} onClose={close} />
<CameraDock map={map} open={open.filter((o) => o.mode === "dock").map((o) => o.cam)} credits={feeds} onMode={setMode} onClose={close} />
</>
);
}
// ── Camera viewsheds — GEV idea #4, coarse 2D cones, APPROXIMATE ─────────────
// Built from the published direction of travel, not a surveyed heading/FOV.
function CameraViewshedLayer({ highlightIds }: { highlightIds: Set<string> }) {
const map = useMap();
const shown = useOverlayShown(map, OVERLAY_VIEWSHEDS);
const zoom = useMapZoom(map);
const { data } = useMapCameras(map, shown && zoom >= 11);
if (!shown) return null;
return (
<>
{data.cameras.map((c) => {
const wedge = viewshedWedge(c);
if (!wedge) return null;
const hot = highlightIds.has(c.id);
return (
<Polygon key={`vs-${c.id}`} positions={wedge}
pathOptions={{ color: hot ? "#3987E5" : "#A5B0C2", weight: hot ? 1 : 0.5, opacity: hot ? 0.8 : 0.4, fillOpacity: hot ? 0.18 : 0.06 }}>
<Tooltip sticky>{c.name} — approximate view (from the road&apos;s direction of travel, not a surveyed heading)</Tooltip>
</Polygon>
);
})}
</>
);
}
// ── Nearest cameras to the selected incident (live ops wall) ─────────────────
// Always drawn while an incident is selected, independent of the DOT Cameras
// overlay, so the wall never hides the cameras it is recommending.
function nearCameraIcon(): L.DivIcon {
return L.divIcon({
className: "",
html: `<div style="width:20px;height:16px;border-radius:4px;background:#0b1f3a;border:2px solid #3987E5;display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 3px rgba(57,135,229,.25)"><svg width="12" height="9" viewBox="0 0 16 12"><rect x="0.5" y="1.5" width="11" height="9" rx="2" fill="none" stroke="#8DB9F0" stroke-width="1.5"/><polygon points="12,4 15.5,2 15.5,10 12,8" fill="#8DB9F0"/></svg></div>`,
iconSize: [20, 16],
iconAnchor: [10, 8],
});
}
function NearCameraLayer({ cameras, head, onOpenCamera }: {
cameras: { cam: Camera; km: number }[];
head: [number, number] | null;
onOpenCamera?: (c: Camera) => void;
}) {
return (
<>
{head && (
<Circle center={head} radius={NEAR_CAMERA_KM * 1000} interactive={false}
pathOptions={{ color: "#3987E5", weight: 1, opacity: 0.6, fill: false, dashArray: "2 5" }} />
)}
{cameras.map(({ cam, km: d }) => (
<Marker key={`near-${cam.id}`} position={[cam.lat, cam.lon]} icon={nearCameraIcon()} zIndexOffset={600}
eventHandlers={{ click: () => onOpenCamera?.(cam) }}>
<Tooltip direction="right" offset={[10, 0]}>{cam.name} · {d.toFixed(1)} km</Tooltip>
</Marker>
))}
</>
);
}
@@ -605,8 +702,10 @@ function TrafficEventLayer() {
<div className="font-semibold">{st.label}{e.subtype ? `: ${e.subtype}` : ""}</div>
<div className="text-xs text-ink-muted">{[e.roadway, e.direction, e.county].filter(Boolean).join(" · ")}</div>
{(start || end) && <div className="text-xs">{start ? `From ${start}` : ""}{end ? ` until ${end}` : ""}</div>}
{/* DAA: shown exactly as NYSDOT published it — never summarised or rewritten. */}
<div className="text-xs whitespace-pre-line">{e.description}</div>
<div className="text-[10px] text-ink-muted">511NY{e.updated_local ? ` · updated ${fmtLocal(e.updated_local)}` : ""}</div>
<SourceCredit credit={data.credit} fallbackName="511NY / NYSDOT"
updated={fmtLocal(e.updated_local) ?? fetchedLabel(data.events_status?.fetched_at)} />
</div>
</Popup>
</Marker>
@@ -816,9 +915,11 @@ function FanNodeLayer({
function FanIncidentLayer({
incidents,
onSelect,
selectedId,
}: {
incidents: IncidentRecord[];
onSelect: (inc: IncidentRecord) => void;
selectedId?: string | null;
}) {
const map = useMap();
const [tick, setTick] = useState(0);
@@ -863,6 +964,8 @@ function FanIncidentLayer({
key={repId}
position={[repPlot.lat, repPlot.lng]}
icon={members.length > 1 ? incidentFanIcon(members) : incidentIcon(repPlot.inc.type, repPlot.inc.severity)}
opacity={selectedId && !members.some((m) => m.incident_id === selectedId) ? 0.45 : 1}
zIndexOffset={members.some((m) => m.incident_id === selectedId) ? 1000 : 0}
eventHandlers={{ click: () => onSelect(repPlot.inc) }}
>
<Popup minWidth={180}>
@@ -964,6 +1067,8 @@ function timeAgo(date: Date): string {
return `${Math.floor(s / 3600)}h ago`;
}
const NO_NEAR: { cam: Camera; km: number }[] = [];
// ── Main MapView ──────────────────────────────────────────────────────────────
interface Props {
nodes: NodeRecord[];
@@ -978,9 +1083,24 @@ interface Props {
*/
calls?: CallRecord[];
lastUpdated?: Date | null;
// ── Live ops wall (all optional; without them MapView behaves as before) ──
/** The incident drilled into; the map fits its path/pin and dims the rest. */
selectedIncidentId?: string | null;
/** When set, rail cards and pins select instead of only flying to the pin. */
onSelectIncident?: (id: string) => void;
/** Nearest cameras to the selected incident — always drawn, highlighted. */
nearCameras?: { cam: Camera; km: number }[];
onOpenCamera?: (c: Camera) => void;
/** Incident ids with a live call on one of their talkgroups. */
onAir?: Set<string>;
/** Cameras within range of each incident (rail "N cams"). */
camCountByIncident?: Map<string, number>;
}
export default function MapView({ nodes, activeCalls, incidents = [], calls = [], lastUpdated }: Props) {
export default function MapView({
nodes, activeCalls, incidents = [], calls = [], lastUpdated,
selectedIncidentId = null, onSelectIncident, nearCameras = NO_NEAR, onOpenCamera, onAir, camCountByIncident,
}: Props) {
const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [agoClock, setAgoClock] = useState(0);
@@ -1008,17 +1128,13 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
// eslint-disable-next-line react-hooks/exhaustive-deps
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
const callsByIncident = useMemo(() => {
const map = new Map<string, CallRecord[]>();
for (const c of calls) {
const ids = c.incident_ids?.length ? c.incident_ids : c.incident_id ? [c.incident_id] : [];
for (const id of ids) {
if (!map.has(id)) map.set(id, []);
map.get(id)!.push(c);
}
}
return map;
}, [calls]);
const callsByIncident = useMemo(() => groupCallsByIncident(calls), [calls]);
const railIncidents = useMemo(() => worstFirst(incidents), [incidents]);
const highlightIds = useMemo(() => new Set(nearCameras.map((n) => n.cam.id)), [nearCameras]);
const selectedPoints = useMemo(() => {
const inc = incidents.find((i) => i.incident_id === selectedIncidentId);
return inc ? incidentPoints(inc, callsByIncident.get(inc.incident_id)) : [];
}, [incidents, selectedIncidentId, callsByIncident]);
const allPositions = useMemo(
() => [
@@ -1050,12 +1166,22 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
const handleIncidentSelect = useCallback(
(inc: IncidentRecord) => {
if (onSelectIncident) { onSelectIncident(inc.incident_id); return; } // the fit happens below
if (!mapInstance || !inc.location_coords) return;
mapInstance.flyTo([inc.location_coords.lat, inc.location_coords.lng], 15, { duration: 1.2 });
},
[mapInstance]
[mapInstance, onSelectIncident]
);
// Wall drill-down: fit the selected incident's whole path (or its pin).
const selectedKey = selectedPoints.map((p) => p.join(",")).join(";");
useEffect(() => {
if (!mapInstance || !selectedPoints.length) return;
if (selectedPoints.length === 1) mapInstance.flyTo(selectedPoints[0], 14, { duration: 1.0 });
else mapInstance.flyToBounds(L.latLngBounds(selectedPoints), { padding: [60, 60], maxZoom: 15, duration: 1.0 });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mapInstance, selectedIncidentId, selectedKey]);
const onMapReady = useCallback((m: L.Map) => setMapInstance(m), []);
return (
@@ -1100,7 +1226,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
{/* Overlay: Active Incidents */}
<LayersControl.Overlay checked name="Active Incidents">
<FeatureGroup>
<FanIncidentLayer incidents={incidents} onSelect={handleIncidentSelect} />
<FanIncidentLayer incidents={incidents} onSelect={handleIncidentSelect} selectedId={selectedIncidentId} />
</FeatureGroup>
</LayersControl.Overlay>
@@ -1128,7 +1254,12 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
{/* Overlays: 511NY DOT cameras + traffic events (server-26#183), opt-in */}
<LayersControl.Overlay name={OVERLAY_DOT_CAMERAS}>
<FeatureGroup>
<DotCameraLayer />
<DotCameraLayer onOpenCamera={onOpenCamera} />
</FeatureGroup>
</LayersControl.Overlay>
<LayersControl.Overlay name={OVERLAY_VIEWSHEDS}>
<FeatureGroup>
<CameraViewshedLayer highlightIds={highlightIds} />
</FeatureGroup>
</LayersControl.Overlay>
<LayersControl.Overlay name={OVERLAY_TRAFFIC_EVENTS}>
@@ -1142,6 +1273,10 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
<RadarLayer />
</LayersControl.Overlay>
</LayersControl>
{selectedIncidentId && (
<NearCameraLayer cameras={nearCameras} head={selectedPoints.at(-1) ?? null} onOpenCamera={onOpenCamera} />
)}
</MapContainer>
{/* ── Live timestamp ───────────────────────────────────────────────────── */}
@@ -1236,11 +1371,16 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
<MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start" />
</div>
<div className="flex flex-col gap-1.5 overflow-y-auto min-h-0 pointer-events-auto">
{incidents.map((inc) => {
{railIncidents.map((inc) => {
const color = severityColor(inc.severity);
const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null;
const unitCount = inc.units_active?.length ?? inc.units?.length ?? 0;
const baseClass = "w-full text-left bg-surface/90 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs hover:brightness-110 transition-all";
const units = (inc.units_active?.length ? inc.units_active : inc.units) ?? [];
const selected = inc.incident_id === selectedIncidentId;
const live = onAir?.has(inc.incident_id) ?? false;
const cams = camCountByIncident?.get(inc.incident_id) ?? 0;
const callCount = inc.call_ids?.length ?? 0;
const baseClass = `w-full text-left bg-surface/90 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs hover:brightness-110 transition-all ${selected ? "ring-2 ring-accent" : ""}`;
const cardBody = (
<>
<div className="flex items-center gap-1.5 mb-0.5">
@@ -1248,6 +1388,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
<span className="uppercase tracking-wide font-semibold text-[10px]" style={{ color }}>
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"}
</span>
{live && <span className="ml-auto bg-accent text-white rounded px-1 text-[9px] font-semibold tracking-wide">ON AIR</span>}
</div>
<p className="text-ink font-semibold leading-snug truncate">
{inc.title ?? "Incident"}
@@ -1255,22 +1396,33 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
{inc.location && (
<p className="text-ink-muted truncate mt-0.5">{inc.location}</p>
)}
{onSelectIncident && units.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{units.slice(0, 4).map((u) => (
<span key={u} className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2">{u}</span>
))}
{units.length > 4 && <span className="font-mono text-[10px] px-1 rounded bg-raised text-ink-muted">+{units.length - 4}</span>}
</div>
)}
<div className="flex items-center justify-between mt-0.5">
{age && <span className="text-ink-muted font-mono">{age}</span>}
{unitCount > 0 && (
{onSelectIncident ? (
<span className="text-ink-muted font-mono">{callCount} call{callCount !== 1 ? "s" : ""}{cams ? ` · ${cams} cam${cams !== 1 ? "s" : ""}` : ""}</span>
) : unitCount > 0 && (
<span className="text-ink-muted font-mono">{unitCount} unit{unitCount !== 1 ? "s" : ""}</span>
)}
</div>
{!inc.location_coords && (
{!inc.location_coords && !onSelectIncident && (
<p className="text-[10px] text-accent mt-1">View details →</p>
)}
</>
);
if (inc.location_coords) {
if (inc.location_coords || onSelectIncident) {
return (
<button
key={inc.incident_id}
onClick={() => handleIncidentSelect(inc)}
aria-pressed={selected}
className={baseClass}
style={{ borderColor: color + "55" }}
>
@@ -1305,7 +1457,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
<div className="bg-surface/95 border-t border-line max-h-52 overflow-y-auto px-3 py-2 space-y-1.5">
{/* Gate A / A2 (server-26#46) */}
<MachineOutputNotice variant="inline" className="text-[10px] items-start" />
{incidents.map((inc) => {
{railIncidents.map((inc) => {
const color = severityColor(inc.severity);
const label = (
<>
@@ -1316,7 +1468,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
<span className="text-ink">{inc.title ?? "Incident"}</span>
</>
);
if (inc.location_coords) {
if (inc.location_coords || onSelectIncident) {
return (
<button
key={inc.incident_id}