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
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type L from "leaflet";
|
||||
import { c2api } from "@/lib/c2api";
|
||||
import type { CamerasResponse } from "@/lib/types";
|
||||
|
||||
// Public cameras from every provider an admin has enabled (GET /cameras).
|
||||
// Catalogs are cached server-side (15 min); this only re-asks for the bbox.
|
||||
const POLL_MS = 5 * 60 * 1000;
|
||||
const MOVE_DEBOUNCE_MS = 400;
|
||||
const BBOX_PAD = 0.2;
|
||||
|
||||
export interface BBox { south: number; west: number; north: number; east: number }
|
||||
|
||||
const EMPTY: CamerasResponse = { cameras: [], providers: [], serving_count: 0 };
|
||||
|
||||
function useCameraFetch(getBox: (() => BBox | null) | null, deps: unknown[], subscribe?: (reload: () => void) => () => void) {
|
||||
const [data, setData] = useState<CamerasResponse>(EMPTY);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const seq = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!getBox) { setData(EMPTY); setLoaded(false); return; }
|
||||
const boxFn = getBox;
|
||||
const load = async () => {
|
||||
const box = boxFn();
|
||||
if (!box) return;
|
||||
const mine = ++seq.current;
|
||||
try {
|
||||
const res = await c2api.getCameras(box);
|
||||
if (mine !== seq.current) return;
|
||||
setData(res);
|
||||
setError(null);
|
||||
setLoaded(true);
|
||||
} catch (e) {
|
||||
if (mine !== seq.current) return;
|
||||
console.error("useCameras:", e);
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setLoaded(true);
|
||||
}
|
||||
};
|
||||
load();
|
||||
const unsub = subscribe ? subscribe(load) : undefined;
|
||||
const poll = setInterval(() => { if (!document.hidden) load(); }, POLL_MS);
|
||||
return () => {
|
||||
unsub?.();
|
||||
clearInterval(poll);
|
||||
seq.current++;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps);
|
||||
|
||||
return { data, error, loaded };
|
||||
}
|
||||
|
||||
/** Cameras for the current map view, refetched on pan/zoom while `enabled`. */
|
||||
export function useMapCameras(map: L.Map, enabled: boolean) {
|
||||
return useCameraFetch(
|
||||
enabled
|
||||
? () => {
|
||||
const b = map.getBounds().pad(BBOX_PAD);
|
||||
return { south: b.getSouth(), west: b.getWest(), north: b.getNorth(), east: b.getEast() };
|
||||
}
|
||||
: null,
|
||||
[map, enabled],
|
||||
(reload) => {
|
||||
let t: ReturnType<typeof setTimeout> | undefined;
|
||||
const onMove = () => { clearTimeout(t); t = setTimeout(reload, MOVE_DEBOUNCE_MS); };
|
||||
map.on("moveend", onMove);
|
||||
return () => { map.off("moveend", onMove); clearTimeout(t); };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Cameras inside a fixed bbox (e.g. around the active incidents). `null` = don't fetch. */
|
||||
export function useCamerasInBbox(box: BBox | null) {
|
||||
const key = box ? `${box.south.toFixed(3)},${box.west.toFixed(3)},${box.north.toFixed(3)},${box.east.toFixed(3)}` : "";
|
||||
return useCameraFetch(box ? () => box : null, [key]);
|
||||
}
|
||||
Reference in New Issue
Block a user