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>
82 lines
2.8 KiB
TypeScript
82 lines
2.8 KiB
TypeScript
"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]);
|
|
}
|