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:
co-authored by
Claude Opus 5.5
parent
d57738a8f6
commit
aa5b71d853
@@ -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'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}
|
||||
|
||||
Reference in New Issue
Block a user