Files
server-26/drb-frontend/lib/useCameras.ts
Logan CusanoandClaude Opus 5.5 2f21728bce Cameras: redact API keys from feed errors; gate TfL/Calgary; clamp map bbox
- Feed/catalog error text (shown to every signed-in user via GET /cameras and
  /traffic/511 status fields) carried httpx's full request URL, including
  ?key= for Ontario 511 and the 511NY DAA key. Query strings are now dropped.
- TfL and Calgary licences are per GEV only (TfL's terms page 403'd), the same
  footing as Ontario 511, so they are UNVERIFIED (acknowledgement required)
  instead of ungated attribution_required. Attribution still renders.
- The map camera layer clamps its bbox to +/-180/+/-90; zoomed-out bounds
  produced a 422 ("could not reach server").

Refs server-26#198

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 18:06:47 -04:00

85 lines
3.1 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
? () => {
// Clamp: zoomed out (or panned past the antimeridian) Leaflet bounds run
// past ±180/±90, and GET /cameras answers 422 to an out-of-range bbox.
const b = map.getBounds().pad(BBOX_PAD);
const clamp = (v: number, lim: number) => Math.max(-lim, Math.min(lim, v));
return { south: clamp(b.getSouth(), 90), west: clamp(b.getWest(), 180), north: clamp(b.getNorth(), 90), east: clamp(b.getEast(), 180) };
}
: 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]);
}