- lib/useOutages.ts: GET /outages for the map view (pan/zoom debounced) or a fixed bbox; polls every 3 min while visible, refetches when a hidden tab returns. - components/map/OutageLayer.tsx: county markers at the label point sized by meters out, stale-only counties dimmed/dashed; popup with count, % served when known, since/ETR, per-utility counts, SourceCredit + fetch time, and "county centre, not the outage location". An always-on status chip says what the layer shows: loading, N counties, none reported in view (with the source's coverage note), no source enabled (admin-only link to /admin#outages), no licensed source covers this area, or feed error. - MapView: "Power Outages" overlay, off by default, plus a legend entry while it is on. Not hash-persisted yet: LAYER_NAMES lives in lib/liveWall.ts, which #235 owns this batch. - lib/outageMatch.ts (pure): county data matches by county bbox and says "county has N out" ("in or near" when two bboxes hold the point); point data matches within 500 m; stale rows never count as live; flags wires-down/transformer/manhole/pole/tree-on-wires incidents. - WallPanels: OUTAGE lines under the incident's situational context (hazard chip, source credit, fetch time) and an OUTAGE row in the region Feeds. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
104 lines
3.8 KiB
TypeScript
104 lines
3.8 KiB
TypeScript
"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<OutagesResponse | null>(null);
|
|
const [error, setError] = useState<string | null>(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<typeof setTimeout> | 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]);
|
|
}
|