"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(EMPTY); const [error, setError] = useState(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 | 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]); }