- 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>
85 lines
3.1 KiB
TypeScript
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]);
|
|
}
|