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
@@ -33,6 +33,7 @@ import {
|
|||||||
incidentPoints, layerKeyForName, normaliseLayers, roundViewport, viewshedWedge, worstFirst,
|
incidentPoints, layerKeyForName, normaliseLayers, roundViewport, viewshedWedge, worstFirst,
|
||||||
type BaseKey, type LayerKey, type MapViewport,
|
type BaseKey, type LayerKey, type MapViewport,
|
||||||
} from "@/lib/liveWall";
|
} from "@/lib/liveWall";
|
||||||
|
import { OutageLayer, outageGlyphSvg } from "@/components/map/OutageLayer";
|
||||||
import { PlayButton } from "@/components/InlinePlayer";
|
import { PlayButton } from "@/components/InlinePlayer";
|
||||||
import { hasAudio, useAudio } from "@/lib/audioQueue";
|
import { hasAudio, useAudio } from "@/lib/audioQueue";
|
||||||
|
|
||||||
@@ -353,6 +354,11 @@ function RadarLayer() {
|
|||||||
const OVERLAY_DOT_CAMERAS = LAYER_NAMES.dotcam;
|
const OVERLAY_DOT_CAMERAS = LAYER_NAMES.dotcam;
|
||||||
const OVERLAY_TRAFFIC_EVENTS = LAYER_NAMES.traffic;
|
const OVERLAY_TRAFFIC_EVENTS = LAYER_NAMES.traffic;
|
||||||
const OVERLAY_VIEWSHEDS = LAYER_NAMES.vs;
|
const OVERLAY_VIEWSHEDS = LAYER_NAMES.vs;
|
||||||
|
// Power outages (server-26#184). Not in LAYER_NAMES yet — lib/liveWall.ts was
|
||||||
|
// owned by #235 when this landed — so it isn't saved in the URL hash and always
|
||||||
|
// starts off. Adding `outage: "Power Outages"` to LAYER_NAMES and `checked=
|
||||||
|
// {layerOn("outage")}` below makes it persist like the rest.
|
||||||
|
const OVERLAY_OUTAGES = "Power Outages";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Overlay names checked when the map mounts (server-26#234 saved views).
|
* Overlay names checked when the map mounts (server-26#234 saved views).
|
||||||
@@ -1171,13 +1177,20 @@ export default function MapView({
|
|||||||
const startOverlayNames = useMemo(() => new Set(Array.from(startLayers).map((k) => LAYER_NAMES[k])), [startLayers]);
|
const startOverlayNames = useMemo(() => new Set(Array.from(startLayers).map((k) => LAYER_NAMES[k])), [startLayers]);
|
||||||
const layerOn = (k: LayerKey) => startLayers.has(k);
|
const layerOn = (k: LayerKey) => startLayers.has(k);
|
||||||
const [aircraftShown, setAircraftShown] = useState(() => startLayers.has("air"));
|
const [aircraftShown, setAircraftShown] = useState(() => startLayers.has("air"));
|
||||||
|
const [outagesShown, setOutagesShown] = useState(false);
|
||||||
|
|
||||||
// The altitude key only belongs in the legend while the opt-in Aircraft
|
// The altitude key only belongs in the legend while the opt-in Aircraft
|
||||||
// overlay is actually on (server-26#185).
|
// overlay is actually on (server-26#185); same for the outage key (#184).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!mapInstance) return;
|
if (!mapInstance) return;
|
||||||
const on = (e: L.LayersControlEvent) => e.name === LAYER_NAMES.air && setAircraftShown(true);
|
const on = (e: L.LayersControlEvent) => {
|
||||||
const off = (e: L.LayersControlEvent) => e.name === LAYER_NAMES.air && setAircraftShown(false);
|
if (e.name === LAYER_NAMES.air) setAircraftShown(true);
|
||||||
|
if (e.name === OVERLAY_OUTAGES) setOutagesShown(true);
|
||||||
|
};
|
||||||
|
const off = (e: L.LayersControlEvent) => {
|
||||||
|
if (e.name === LAYER_NAMES.air) setAircraftShown(false);
|
||||||
|
if (e.name === OVERLAY_OUTAGES) setOutagesShown(false);
|
||||||
|
};
|
||||||
mapInstance.on("overlayadd", on);
|
mapInstance.on("overlayadd", on);
|
||||||
mapInstance.on("overlayremove", off);
|
mapInstance.on("overlayremove", off);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -1393,6 +1406,13 @@ export default function MapView({
|
|||||||
</FeatureGroup>
|
</FeatureGroup>
|
||||||
</LayersControl.Overlay>
|
</LayersControl.Overlay>
|
||||||
|
|
||||||
|
{/* Overlay: Power outages (server-26#184) — licence-gated providers, opt-in */}
|
||||||
|
<LayersControl.Overlay checked={false} name={OVERLAY_OUTAGES}>
|
||||||
|
<FeatureGroup>
|
||||||
|
<OutageLayer shown={outagesShown} />
|
||||||
|
</FeatureGroup>
|
||||||
|
</LayersControl.Overlay>
|
||||||
|
|
||||||
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet */}
|
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet */}
|
||||||
<LayersControl.Overlay checked={layerOn("radar")} name={LAYER_NAMES.radar}>
|
<LayersControl.Overlay checked={layerOn("radar")} name={LAYER_NAMES.radar}>
|
||||||
<RadarLayer />
|
<RadarLayer />
|
||||||
@@ -1463,6 +1483,19 @@ export default function MapView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{outagesShown && (
|
||||||
|
<div className="border-t border-line pt-1.5 space-y-1">
|
||||||
|
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Power outages</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span style={{ width: 16, display: "inline-flex", justifyContent: "center" }} dangerouslySetInnerHTML={{ __html: outageGlyphSvg(14) }} />
|
||||||
|
<span className="text-ink-2">County with outages · size = meters out</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span style={{ width: 16, display: "inline-flex", justifyContent: "center", opacity: 0.6 }} dangerouslySetInnerHTML={{ __html: outageGlyphSvg(14, true) }} />
|
||||||
|
<span className="text-ink-2">Stale upstream rows only</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="border-t border-line pt-1.5 space-y-1">
|
<div className="border-t border-line pt-1.5 space-y-1">
|
||||||
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Nodes</p>
|
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Nodes</p>
|
||||||
{([
|
{([
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
|||||||
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
|
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
|
||||||
import { InlinePlayer } from "@/components/InlinePlayer";
|
import { InlinePlayer } from "@/components/InlinePlayer";
|
||||||
import { canPlayAudio, hasAudio, playIncident, stop as stopAudio, useAudio } from "@/lib/audioQueue";
|
import { canPlayAudio, hasAudio, playIncident, stop as stopAudio, useAudio } from "@/lib/audioQueue";
|
||||||
import { CONTEXT_KM, NEAR_CAMERA_KM, contextFor, sampleRadarDbz, type LatLon, type NearCamera } from "@/lib/liveWall";
|
import { CONTEXT_KM, NEAR_CAMERA_KM, bboxAround, contextFor, sampleRadarDbz, type LatLon, type NearCamera } from "@/lib/liveWall";
|
||||||
|
import { useOutagesIn, type OutagesState } from "@/lib/useOutages";
|
||||||
|
import { OUTAGE_POINT_KM, countyName, isUtilityHazard, matchOutages, staleCountiesAt } from "@/lib/outageMatch";
|
||||||
|
|
||||||
function sevColor(s: string | null | undefined) {
|
function sevColor(s: string | null | undefined) {
|
||||||
return isKnownSeverity(s) ? SEVERITY_COLORS[s] : "var(--ink-muted)";
|
return isKnownSeverity(s) ? SEVERITY_COLORS[s] : "var(--ink-muted)";
|
||||||
@@ -61,6 +63,121 @@ function NoCameras({ servingCount, radiusKm }: { servingCount: number; radiusKm:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Power outages (server-26#184) ────────────────────────────────────────────
|
||||||
|
/**
|
||||||
|
* Why the outage answer is empty or unusable, in one line — or null when the
|
||||||
|
* data is fine to read. Shared by the region Feeds row and the incident panel.
|
||||||
|
*/
|
||||||
|
function OutageProblem({ state, where }: { state: OutagesState; where: string }) {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const { data, error, loaded } = state;
|
||||||
|
if (!loaded) return <>checking…</>;
|
||||||
|
if (!data) return <>could not reach server{error ? ` (${error})` : ""}</>;
|
||||||
|
if (data.status === "none_enabled") {
|
||||||
|
return isAdmin
|
||||||
|
? <>no outage source enabled (<Link href="/admin#outages" className="underline">Admin → Outage providers</Link>)</>
|
||||||
|
: <>no outage source enabled — a platform admin can turn one on</>;
|
||||||
|
}
|
||||||
|
if (data.status === "no_source") return <>no licensed outage source covers {where}</>;
|
||||||
|
if (data.status === "error") {
|
||||||
|
const bad = data.providers.filter((p) => p.covers_bbox && p.error);
|
||||||
|
return <>feed error{bad.length ? `: ${bad.map((p) => `${p.name} (${p.error})`).join("; ")}` : ""}</>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function outageIsUsable(state: OutagesState): boolean {
|
||||||
|
return !!state.data && state.data.status === "ok";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Incident panel "OUTAGE" lines: the cross-match, with source credit and fetch time. */
|
||||||
|
function OutageContext({ inc, points }: { inc: IncidentRecord; points: LatLon[] }) {
|
||||||
|
// 1 km pad: county data is matched by the county bbox containing a point, so
|
||||||
|
// any county the incident touches intersects this box.
|
||||||
|
const box = useMemo(() => bboxAround(points, 1), [points]);
|
||||||
|
const state = useOutagesIn(box);
|
||||||
|
const hazard = isUtilityHazard(inc);
|
||||||
|
const matches = useMemo(
|
||||||
|
() => (state.data ? matchOutages(inc, points, state.data.areas, state.data.providers) : []),
|
||||||
|
[inc, points, state.data],
|
||||||
|
);
|
||||||
|
const staleHere = useMemo(() => (state.data ? staleCountiesAt(points, state.data.areas) : []), [points, state.data]);
|
||||||
|
const tag = <span className="font-mono text-[10px] text-ink-muted mr-1.5">OUTAGE</span>;
|
||||||
|
if (!points.length) return <li>{tag}—</li>;
|
||||||
|
if (!outageIsUsable(state)) return <li>{tag}<OutageProblem state={state} where="this location" /></li>;
|
||||||
|
const covering = state.data!.providers.filter((p) => p.covers_bbox);
|
||||||
|
const refreshNote = state.error ? " · could not refresh, showing the last answer" : "";
|
||||||
|
const feedErr = covering.find((p) => p.error);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{hazard && (
|
||||||
|
<li>
|
||||||
|
<span className={`inline-block text-[10px] font-semibold px-1.5 py-0.5 rounded ${matches.length ? "bg-accent text-white" : "bg-raised text-ink-2"}`}>
|
||||||
|
{matches.length
|
||||||
|
? matches[0].kind === "point"
|
||||||
|
? `⚡ Utility-hazard call within ${OUTAGE_POINT_KM} km of a reported outage`
|
||||||
|
: "⚡ Utility-hazard call in a county with reported outages"
|
||||||
|
: "⚡ Utility-hazard call · no reported outage matched"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{matches.map((m) => (
|
||||||
|
<li key={m.area.id}>
|
||||||
|
{tag}{m.text}
|
||||||
|
{m.area.etr ? ` · est. restoration ${new Date(m.area.etr).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}` : ""}
|
||||||
|
<SourceCredit credit={m.feed} fallbackName={m.feed?.name ?? m.area.provider}
|
||||||
|
updated={m.feed ? `fetched ${fetchedLabel(m.feed.fetched_at) ?? "—"}${refreshNote}` : null} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{!matches.length && (
|
||||||
|
<li>
|
||||||
|
{tag}
|
||||||
|
{feedErr
|
||||||
|
? `${feedErr.name} feed error${feedErr.fetched_at ? `, last good data ${fetchedLabel(feedErr.fetched_at)}` : ", no data yet"} — none matched`
|
||||||
|
: `none reported here by ${covering.map((p) => p.name).join(", ") || "the enabled source"}`}
|
||||||
|
{staleHere.length > 0 && ` (${staleHere.map(countyName).join(", ")}: stale upstream rows only, not shown as live)`}
|
||||||
|
{covering.map((p) => (
|
||||||
|
<SourceCredit key={p.id} credit={p} fallbackName={p.name}
|
||||||
|
updated={`fetched ${fetchedLabel(p.fetched_at) ?? "—"}${refreshNote}${p.coverage_note ? ` · ${p.coverage_note}` : ""}`} />
|
||||||
|
))}
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Region Feeds "OUTAGE" row: provider status for the wall's region. */
|
||||||
|
function OutageFeedRow({ incidents, nodes }: { incidents: IncidentRecord[]; nodes: NodeRecord[] }) {
|
||||||
|
const pts = useMemo(() => {
|
||||||
|
const inc = incidents.filter((i) => i.location_coords).map((i) => [i.location_coords!.lat, i.location_coords!.lng] as LatLon);
|
||||||
|
return inc.length ? inc : nodes.map((n) => [n.lat, n.lon] as LatLon);
|
||||||
|
}, [incidents, nodes]);
|
||||||
|
// Counties are large; pad the region so a county just outside the pins still counts.
|
||||||
|
const box = useMemo(() => bboxAround(pts, 10), [pts]);
|
||||||
|
const state = useOutagesIn(box);
|
||||||
|
const tag = <span className="font-mono text-[10px] text-ink-muted mr-1.5">OUTAGE</span>;
|
||||||
|
if (!box) return <li>{tag}no region to check (no nodes or geocoded incidents)</li>;
|
||||||
|
if (!outageIsUsable(state)) return <li>{tag}<OutageProblem state={state} where="this region" /></li>;
|
||||||
|
const { areas, providers } = state.data!;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{providers.filter((p) => p.covers_bbox).map((p) => {
|
||||||
|
const mine = areas.filter((a) => a.provider === p.id);
|
||||||
|
const live = mine.filter((a) => !a.stale && a.customers_out > 0);
|
||||||
|
const out = live.reduce((t, a) => t + a.customers_out, 0);
|
||||||
|
const stale = mine.filter((a) => a.stale).length;
|
||||||
|
return (
|
||||||
|
<li key={p.id}>{tag}{p.name}: {live.length
|
||||||
|
? `${live.length} count${live.length !== 1 ? "ies" : "y"} with outages, ${out.toLocaleString()} meters/customers out`
|
||||||
|
: "no outages reported in region"}
|
||||||
|
{stale ? ` · ${stale} stale` : ""}{p.error ? " · feed error" : ""}{p.fetched_at ? ` · fetched ${fetchedLabel(p.fetched_at)}` : ""}
|
||||||
|
{state.error ? " · could not refresh" : ""} · Power Outages layer (map menu)</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Level 1: region ──────────────────────────────────────────────────────────
|
// ── Level 1: region ──────────────────────────────────────────────────────────
|
||||||
export function RegionPanel({
|
export function RegionPanel({
|
||||||
incidents, calls, onAirCount, nodes, wallCameras, feeds, servingCount, aircraftCount, vesselCount,
|
incidents, calls, onAirCount, nodes, wallCameras, feeds, servingCount, aircraftCount, vesselCount,
|
||||||
@@ -123,6 +240,7 @@ export function RegionPanel({
|
|||||||
{providers.map((p) => (
|
{providers.map((p) => (
|
||||||
<li key={p.id}><span className="font-mono text-[10px] text-ink-muted mr-1.5">CAM</span>{p.name}: {p.total_in_bbox} in region{p.error ? " · feed error" : ""}{p.fetched_at ? ` · list ${fetchedLabel(p.fetched_at)}` : ""}</li>
|
<li key={p.id}><span className="font-mono text-[10px] text-ink-muted mr-1.5">CAM</span>{p.name}: {p.total_in_bbox} in region{p.error ? " · feed error" : ""}{p.fetched_at ? ` · list ${fetchedLabel(p.fetched_at)}` : ""}</li>
|
||||||
))}
|
))}
|
||||||
|
<OutageFeedRow incidents={incidents} nodes={nodes} />
|
||||||
<li><span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span>Weather Radar layer (map menu); sampled per incident</li>
|
<li><span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span>Weather Radar layer (map menu); sampled per incident</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -270,6 +388,7 @@ export function IncidentPanel({
|
|||||||
{!points.length ? "—" : !radar.done ? "sampling…" : radar.dbz == null ? "radar sample unavailable"
|
{!points.length ? "—" : !radar.done ? "sampling…" : radar.dbz == null ? "radar sample unavailable"
|
||||||
: radar.dbz === 0 ? "no precipitation echo within ~5 km" : `up to ~${radar.dbz} dBZ within ~5 km (approx., from the radar tile)`}
|
: radar.dbz === 0 ? "no precipitation echo within ~5 km" : `up to ~${radar.dbz} dBZ within ~5 km (approx., from the radar tile)`}
|
||||||
</li>
|
</li>
|
||||||
|
<OutageContext inc={inc} points={points} />
|
||||||
</ul>
|
</ul>
|
||||||
{!ctx.aircraft.length && !ctx.vessels.length && !ctx.road.length && (
|
{!ctx.aircraft.length && !ctx.vessels.length && !ctx.road.length && (
|
||||||
<p className="text-[10px] text-ink-muted mt-1">No aircraft within {CONTEXT_KM.aircraft} km, vessels within {CONTEXT_KM.vessel} km, or road events within {CONTEXT_KM.road} km.</p>
|
<p className="text-[10px] text-ink-muted mt-1">No aircraft within {CONTEXT_KM.aircraft} km, vessels within {CONTEXT_KM.vessel} km, or road events within {CONTEXT_KM.road} km.</p>
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Power Outages" map overlay (server-26#184). County-level areas from the
|
||||||
|
* admin-enabled, licence-gated outage providers, drawn at the county's label
|
||||||
|
* point and sized by meters out. The marker is a county, not an outage
|
||||||
|
* location, and every popup says so.
|
||||||
|
*
|
||||||
|
* Never a silently empty layer: while the overlay is on, a status chip always
|
||||||
|
* says what the layer is showing — loading, N counties, nothing reported in
|
||||||
|
* view (with the source's coverage caveat), no provider enabled, no licensed
|
||||||
|
* source covering this area, or a feed error.
|
||||||
|
*/
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useMemo, useRef } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { Marker, Popup, useMap } from "react-leaflet";
|
||||||
|
import L from "leaflet";
|
||||||
|
import type { OutageArea, OutageProviderFeed } from "@/lib/types";
|
||||||
|
import { useAuth } from "@/components/AuthProvider";
|
||||||
|
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
|
||||||
|
import { useMapOutages, type OutagesState } from "@/lib/useOutages";
|
||||||
|
import { countyName } from "@/lib/outageMatch";
|
||||||
|
|
||||||
|
/** The outage glyph (lightning bolt in a ring), shared with the map legend. */
|
||||||
|
export function outageGlyphSvg(size: number, stale = false): string {
|
||||||
|
const ring = stale
|
||||||
|
? `<circle cx="12" cy="12" r="10.5" fill="#1f2937" fill-opacity="0.55" stroke="#9ca3af" stroke-width="1.5" stroke-dasharray="3 2"/>`
|
||||||
|
: `<circle cx="12" cy="12" r="10.5" fill="#1f2937" stroke="#facc15" stroke-width="1.75"/>`;
|
||||||
|
const bolt = `<path d="M13.2 4.5 7.5 13.2h4l-1 6.3 5.9-9h-4.1z" fill="${stale ? "#9ca3af" : "#facc15"}"/>`;
|
||||||
|
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24">${ring}${bolt}</svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marker diameter in px: grows with log10(meters out), 16–34. */
|
||||||
|
function sizeFor(out: number): number {
|
||||||
|
return Math.round(Math.min(34, 16 + 4 * Math.log10(Math.max(1, out))));
|
||||||
|
}
|
||||||
|
|
||||||
|
function outageIcon(a: OutageArea): L.DivIcon {
|
||||||
|
const stale = a.stale;
|
||||||
|
const out = stale ? a.stale_customers_out : a.customers_out;
|
||||||
|
const s = sizeFor(out);
|
||||||
|
const label = out >= 1000 ? `${(out / 1000).toFixed(out >= 10_000 ? 0 : 1)}k` : String(out);
|
||||||
|
return L.divIcon({
|
||||||
|
className: "",
|
||||||
|
html: `<div style="position:relative;width:${s}px;height:${s}px;opacity:${stale ? 0.6 : 1}">${outageGlyphSvg(s, stale)}`
|
||||||
|
+ `<span style="position:absolute;top:-7px;left:${s - 6}px;padding:0 3px;border-radius:7px;background:#0f172a;color:#fff;border:1px solid ${stale ? "#9ca3af" : "#facc15"};font:700 9px/12px sans-serif;white-space:nowrap">${label}</span></div>`,
|
||||||
|
iconSize: [s, s],
|
||||||
|
iconAnchor: [s / 2, s / 2],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmt(iso: string | null): string | null {
|
||||||
|
if (!iso) return null;
|
||||||
|
const d = new Date(iso);
|
||||||
|
return isNaN(d.getTime()) ? null : d.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function OutagePopupBody({ a, feed }: { a: OutageArea; feed?: OutageProviderFeed }) {
|
||||||
|
const since = fmt(a.since);
|
||||||
|
const etr = fmt(a.etr);
|
||||||
|
const updated = fmt(a.updated_at);
|
||||||
|
const pct = a.customers_served ? (100 * a.customers_out) / a.customers_served : null;
|
||||||
|
return (
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
<div className="font-semibold text-sm">{countyName(a)}</div>
|
||||||
|
{a.stale ? (
|
||||||
|
<div className="text-ink-muted">
|
||||||
|
Stale upstream rows only: {a.stale_customers_out.toLocaleString()} meters on events whose own restoration
|
||||||
|
estimate passed more than 24 h ago. The source still lists them; they are not shown as live outages.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<b>{a.customers_out.toLocaleString()}</b> meters/customers out (utility-reported)
|
||||||
|
{pct != null ? ` · ${pct < 0.1 ? "<0.1" : pct.toFixed(1)}% of ${a.customers_served!.toLocaleString()} served` : " · % served not known"}
|
||||||
|
{a.stale_customers_out > 0 && <span className="text-ink-muted"> · plus {a.stale_customers_out.toLocaleString()} on stale rows</span>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(since || etr) && (
|
||||||
|
<div>{since ? `Out since ${since}` : ""}{since && etr ? " · " : ""}{etr ? `Est. restoration ${etr}` : ""}</div>
|
||||||
|
)}
|
||||||
|
{!etr && !a.stale && <div className="text-ink-muted">No restoration estimate given.</div>}
|
||||||
|
{a.utilities.length > 0 && (
|
||||||
|
<ul className="text-ink-2">
|
||||||
|
{a.utilities.map((u, i) => (
|
||||||
|
<li key={u.eia_id ?? i}>
|
||||||
|
{u.name ?? (u.eia_id ? `EIA ${u.eia_id}` : "Utility")}: {u.customers_out.toLocaleString()} out
|
||||||
|
{u.stale_customers_out ? ` (+${u.stale_customers_out.toLocaleString()} stale)` : ""}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
<div className="text-[10px] text-ink-muted">County-level count — the marker is the county's centre, not where the outage is.</div>
|
||||||
|
<SourceCredit credit={feed} fallbackName={feed?.name ?? a.provider}
|
||||||
|
updated={[updated && `utility data ${updated}`, feed && `fetched ${fetchedLabel(feed.fetched_at) ?? "—"}`].filter(Boolean).join(" · ") || null} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The layer's always-on status chip. Returns the message, or null while there's nothing to say yet. */
|
||||||
|
function statusFor(state: OutagesState, isAdmin: boolean, liveCount: number, staleCount: number): { text: string; adminLink?: boolean; problem: boolean } {
|
||||||
|
const { data, error, loaded } = state;
|
||||||
|
if (!loaded) return { text: "Power outages: loading…", problem: false };
|
||||||
|
if (error && !data) return { text: "Power outages: could not reach server", problem: true };
|
||||||
|
if (!data) return { text: "Power outages: no data", problem: true };
|
||||||
|
const stale = error ? " (could not refresh — showing the last answer)" : "";
|
||||||
|
switch (data.status) {
|
||||||
|
case "none_enabled":
|
||||||
|
return isAdmin
|
||||||
|
? { text: "Power outages: no outage source is enabled —", adminLink: true, problem: true }
|
||||||
|
: { text: "Power outages: no outage source is enabled. A platform admin can turn one on.", problem: true };
|
||||||
|
case "no_source":
|
||||||
|
return { text: `Power outages: ${data.message ?? "No licensed outage source covers this area."}`, problem: true };
|
||||||
|
case "error": {
|
||||||
|
const bad = data.providers.filter((p) => p.covers_bbox && p.error);
|
||||||
|
return { text: `Power outages: ${bad.map((p) => `${p.name} feed error (${p.error})`).join("; ") || data.message || "feed error"}`, problem: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const covering = data.providers.filter((p) => p.covers_bbox);
|
||||||
|
const names = covering.map((p) => p.name).join(", ");
|
||||||
|
const bad = covering.find((p) => p.error);
|
||||||
|
if (bad) {
|
||||||
|
return {
|
||||||
|
text: `Power outages: ${bad.name} feed error, ${bad.fetched_at ? `showing data fetched ${fetchedLabel(bad.fetched_at)}` : "no data yet"}${stale}`,
|
||||||
|
problem: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (covering.some((p) => p.truncated)) return { text: "Power outages: too many areas in view — zoom in", problem: true };
|
||||||
|
const fetched = covering.map((p) => fetchedLabel(p.fetched_at)).find(Boolean);
|
||||||
|
const when = fetched ? ` · fetched ${fetched}` : "";
|
||||||
|
if (!liveCount) {
|
||||||
|
const note = covering.map((p) => p.coverage_note).find(Boolean);
|
||||||
|
return {
|
||||||
|
text: `Power outages: none reported in view by ${names || "the enabled source"}${staleCount ? ` (${staleCount} stale count${staleCount !== 1 ? "ies" : "y"} dimmed)` : ""}${when}.${note ? ` ${note}` : ""}${stale}`,
|
||||||
|
problem: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
text: `Power outages: ${liveCount} count${liveCount !== 1 ? "ies" : "y"} with outages · ${names}${when}${staleCount ? ` · ${staleCount} stale dimmed` : ""}${stale}`,
|
||||||
|
problem: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function OutageStatus({ map, state, liveCount, staleCount }: { map: L.Map; state: OutagesState; liveCount: number; staleCount: number }) {
|
||||||
|
const { isAdmin } = useAuth();
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) L.DomEvent.disableClickPropagation(ref.current);
|
||||||
|
});
|
||||||
|
const s = statusFor(state, isAdmin, liveCount, staleCount);
|
||||||
|
return createPortal(
|
||||||
|
<div ref={ref} role="status"
|
||||||
|
className={`absolute bottom-[3.75rem] left-3 z-[1001] max-w-[min(28rem,calc(100%-1.5rem))] bg-surface/90 border rounded px-2 py-1 text-xs text-ink-2 ${s.problem ? "border-accent" : "border-line"}`}>
|
||||||
|
{s.text}
|
||||||
|
{s.adminLink && <> <Link href="/admin#outages" className="underline">Admin → Outage providers</Link></>}
|
||||||
|
</div>,
|
||||||
|
map.getContainer(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OutageLayer({ shown }: { shown: boolean }) {
|
||||||
|
const map = useMap();
|
||||||
|
const state = useMapOutages(map, shown);
|
||||||
|
const areas = useMemo(() => state.data?.areas ?? [], [state.data]);
|
||||||
|
const feeds = useMemo(() => new Map((state.data?.providers ?? []).map((p) => [p.id, p])), [state.data]);
|
||||||
|
const drawable = areas.filter((a) => a.lat != null && a.lon != null);
|
||||||
|
const liveCount = areas.filter((a) => !a.stale && a.customers_out > 0).length;
|
||||||
|
const staleCount = areas.filter((a) => a.stale).length;
|
||||||
|
if (!shown) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<OutageStatus map={map} state={state} liveCount={liveCount} staleCount={staleCount} />
|
||||||
|
{drawable.map((a) => (
|
||||||
|
<Marker key={a.id} position={[a.lat!, a.lon!]} icon={outageIcon(a)} zIndexOffset={a.stale ? -100 : 100}
|
||||||
|
title={`${countyName(a)}: ${a.stale ? "stale rows only" : `${a.customers_out.toLocaleString()} out`}`}>
|
||||||
|
<Popup minWidth={240} maxWidth={320}>
|
||||||
|
<OutagePopupBody a={a} feed={feeds.get(a.provider)} />
|
||||||
|
</Popup>
|
||||||
|
</Marker>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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