Files
Logan CusanoandClaude Opus 5.5 d10c2f732a 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>
2026-10-05 03:12:32 -04:00

122 lines
5.1 KiB
TypeScript

/**
* 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!)));
}