"use client"; import { useEffect, useRef, useState } from "react"; import type L from "leaflet"; import { c2api } from "@/lib/c2api"; import type { OutagesResponse } from "@/lib/types"; import type { BBox } from "@/lib/useCameras"; // Power outages from every provider an admin has enabled (GET /outages, // server-26#184). The server caches each provider's feed; this only re-asks // for a bbox. Polls while enabled and the tab is visible, and refetches as // soon as a hidden tab comes back, so a wall left in a background tab never // shows hours-old counts as current. const POLL_MS = 3 * 60 * 1000; const MOVE_DEBOUNCE_MS = 400; const BBOX_PAD = 0.2; export interface OutagesState { /** null until the first response. */ data: OutagesResponse | null; /** Last request failure (network/HTTP), cleared by the next success. */ error: string | null; loaded: boolean; } function useOutageFetch( getBox: (() => BBox | null) | null, deps: unknown[], subscribe?: (reload: () => void) => () => void, ): OutagesState { const [data, setData] = useState(null); const [error, setError] = useState(null); const [loaded, setLoaded] = useState(false); const seq = useRef(0); useEffect(() => { if (!getBox) { setData(null); setError(null); setLoaded(false); return; } const boxFn = getBox; let last = 0; const load = async () => { const box = boxFn(); if (!box) return; const mine = ++seq.current; last = Date.now(); try { const res = await c2api.getOutages(box); if (mine !== seq.current) return; // a newer request's response wins setData(res); setError(null); } catch (e) { if (mine !== seq.current) return; console.error("useOutages:", e); setError(e instanceof Error ? e.message : String(e)); } if (mine === seq.current) setLoaded(true); }; const onVisible = () => { if (!document.hidden && Date.now() - last >= POLL_MS) load(); }; load(); const unsub = subscribe?.(load); const poll = setInterval(() => { if (!document.hidden) load(); }, POLL_MS); document.addEventListener("visibilitychange", onVisible); return () => { unsub?.(); clearInterval(poll); document.removeEventListener("visibilitychange", onVisible); seq.current++; // drop any in-flight response }; // eslint-disable-next-line react-hooks/exhaustive-deps }, deps); return { data, error, loaded }; } const clamp = (v: number, lim: number) => Math.max(-lim, Math.min(lim, v)); /** Outages for the current map view, refetched on pan/zoom while `enabled` (the overlay is on). */ export function useMapOutages(map: L.Map, enabled: boolean): OutagesState { return useOutageFetch( enabled ? () => { // Leaflet bounds run past ±180/±90 zoomed out; GET /outages answers 422 to that. const b = map.getBounds().pad(BBOX_PAD); 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); }; }, ); } /** Outages inside a fixed bbox (an incident, the wall's region). `null` = don't fetch. */ export function useOutagesIn(box: BBox | null): OutagesState { // Key on a rounded box so a re-render with an equal-but-new object doesn't refetch. const key = box ? [box.south, box.west, box.north, box.east].map((v) => v.toFixed(3)).join(",") : ""; const boxRef = useRef(box); boxRef.current = box; return useOutageFetch(key ? () => boxRef.current : null, [key]); }