Files
server-26/drb-frontend/lib/useCameras.ts
T
Logan CusanoandClaude Opus 5.5 aa5b71d853 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>
2026-10-04 17:56:06 -04:00

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]);
}