Power Outages map layer + wires-down cross-match on the live wall (#184)
- 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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f3b21cd432
commit
d10c2f732a
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Incident ↔ power-outage cross-match (server-26#184). Pure — no React, no
|
||||
* fetch — so it can be unit-tested once a JS runner exists.
|
||||
*
|
||||
* Two granularities, two claims:
|
||||
* - point/polygon data: an outage within OUTAGE_POINT_KM of the incident is
|
||||
* "outage ~0.3 km away".
|
||||
* - county data (ODIN): the incident sits inside an affected county's
|
||||
* bounding box, which only supports "<county> has N meters out" — never
|
||||
* "outage here". A county bbox is not the county shape, so a point near a
|
||||
* border can fall in two; both are listed and the wording says "in or near".
|
||||
*
|
||||
* Stale rows (the source still lists them but their own ETR passed >24 h ago)
|
||||
* are never presented as live outages.
|
||||
*/
|
||||
import type { IncidentRecord, OutageArea, OutageProviderFeed } from "@/lib/types";
|
||||
import { km, type LatLon } from "@/lib/liveWall";
|
||||
|
||||
/** Point-level outages closer than this count as "at" the incident. */
|
||||
export const OUTAGE_POINT_KM = 0.5;
|
||||
|
||||
// Wires-down / transformer / manhole / pole / tree-on-wires: the calls an
|
||||
// outage cluster should line up with. Matched against type, tags, title, summary.
|
||||
const UTILITY_HAZARD_RE = new RegExp(
|
||||
[
|
||||
String.raw`wires?[\s_-]*down`,
|
||||
String.raw`downed[\s_-]+(?:power[\s_-]+|electric(?:al)?[\s_-]+)?(?:wires?|lines?)`,
|
||||
String.raw`(?:power|electric(?:al)?|utility|primary|live)[\s_-]*lines?[\s_-]*down`,
|
||||
String.raw`transformer`,
|
||||
String.raw`manhole`,
|
||||
String.raw`(?:utility|telephone|electric|power)[\s_-]*pole`,
|
||||
String.raw`pole[\s_-]*(?:fire|down|damage|struck|hit)`,
|
||||
String.raw`tree[\s_-]+(?:on|in|into|across|against|through)[\s_-]+(?:the[\s_-]+)?(?:wires?|lines?)`,
|
||||
String.raw`arcing`,
|
||||
].join("|"),
|
||||
"i",
|
||||
);
|
||||
|
||||
export function isUtilityHazard(inc: Pick<IncidentRecord, "type" | "tags" | "title" | "summary">): boolean {
|
||||
const text = [inc.type, ...(inc.tags ?? []), inc.title, inc.summary].filter(Boolean).join(" \n ");
|
||||
return UTILITY_HAZARD_RE.test(text);
|
||||
}
|
||||
|
||||
export function isCountyLevel(feed: OutageProviderFeed | undefined, area: OutageArea): boolean {
|
||||
if (feed) return feed.granularity.toLowerCase().startsWith("county");
|
||||
return !!area.fips; // unknown provider: assume the weaker (county) claim
|
||||
}
|
||||
|
||||
export function countyName(a: OutageArea): string {
|
||||
const c = a.county ? (/\b(county|parish|borough)\b/i.test(a.county) ? a.county : `${a.county} County`) : `FIPS ${a.fips}`;
|
||||
return a.state ? `${c}, ${a.state}` : c;
|
||||
}
|
||||
|
||||
function inBbox(p: LatLon, b: [number, number, number, number]): boolean {
|
||||
const [s, w, n, e] = b;
|
||||
return p[0] >= s && p[0] <= n && p[1] >= w && p[1] <= e;
|
||||
}
|
||||
|
||||
export interface OutageMatch {
|
||||
area: OutageArea;
|
||||
feed?: OutageProviderFeed;
|
||||
kind: "county" | "point";
|
||||
/** Distance to the nearest incident point (point data only). */
|
||||
km: number | null;
|
||||
/** More than one county bbox holds the incident — the county is uncertain. */
|
||||
ambiguous: boolean;
|
||||
/** The incident reads as a wires-down/transformer/manhole/pole/tree-on-wires call. */
|
||||
hazard: boolean;
|
||||
/** Honest one-line wording for the panel. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
const n = (v: number) => v.toLocaleString();
|
||||
|
||||
/** The cross-match for one incident. Live (non-stale) outages only, nearest/biggest first. */
|
||||
export function matchOutages(
|
||||
inc: Pick<IncidentRecord, "type" | "tags" | "title" | "summary">,
|
||||
points: LatLon[],
|
||||
areas: OutageArea[],
|
||||
providers: OutageProviderFeed[],
|
||||
): OutageMatch[] {
|
||||
if (!points.length) return [];
|
||||
const feeds = new Map(providers.map((p) => [p.id, p]));
|
||||
const hazard = isUtilityHazard(inc);
|
||||
const county: { area: OutageArea; feed?: OutageProviderFeed }[] = [];
|
||||
const point: OutageMatch[] = [];
|
||||
|
||||
for (const area of areas) {
|
||||
if (area.stale || area.customers_out <= 0) continue;
|
||||
const feed = feeds.get(area.provider);
|
||||
if (isCountyLevel(feed, area)) {
|
||||
const hit = area.bbox
|
||||
? points.some((p) => inBbox(p, area.bbox!))
|
||||
: false; // no county extent → no containment claim
|
||||
if (hit) county.push({ area, feed });
|
||||
} else if (area.lat != null && area.lon != null) {
|
||||
const d = Math.min(...points.map((p) => km(p, [area.lat!, area.lon!])));
|
||||
if (d <= OUTAGE_POINT_KM) {
|
||||
point.push({
|
||||
area, feed, kind: "point", km: d, ambiguous: false, hazard,
|
||||
text: `${n(area.customers_out)} meters/customers out ~${d < 0.1 ? "<0.1" : d.toFixed(1)} km away (utility-reported)`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const ambiguous = county.length > 1;
|
||||
const countyMatches: OutageMatch[] = county
|
||||
.sort((a, b) => b.area.customers_out - a.area.customers_out)
|
||||
.map(({ area, feed }) => ({
|
||||
area, feed, kind: "county", km: null, ambiguous, hazard,
|
||||
text: `${ambiguous ? "In or near " : ""}${countyName(area)}: county has ${n(area.customers_out)} meters/customers out (utility-reported, county-level — not a location)`,
|
||||
}));
|
||||
|
||||
return [...point.sort((a, b) => (a.km ?? 0) - (b.km ?? 0)), ...countyMatches];
|
||||
}
|
||||
|
||||
/** Stale-only counties the incident sits in — said out loud so they aren't mistaken for clear. */
|
||||
export function staleCountiesAt(points: LatLon[], areas: OutageArea[]): OutageArea[] {
|
||||
return areas.filter((a) => a.stale && a.bbox && points.some((p) => inBbox(p, a.bbox!)));
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
"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]);
|
||||
}
|
||||
Reference in New Issue
Block a user