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:
Logan Cusano
2026-10-05 03:12:32 -04:00
co-authored by Claude Opus 5.5
parent f3b21cd432
commit d10c2f732a
5 changed files with 563 additions and 4 deletions
+36 -3
View File
@@ -33,6 +33,7 @@ import {
incidentPoints, layerKeyForName, normaliseLayers, roundViewport, viewshedWedge, worstFirst,
type BaseKey, type LayerKey, type MapViewport,
} from "@/lib/liveWall";
import { OutageLayer, outageGlyphSvg } from "@/components/map/OutageLayer";
import { PlayButton } from "@/components/InlinePlayer";
import { hasAudio, useAudio } from "@/lib/audioQueue";
@@ -353,6 +354,11 @@ function RadarLayer() {
const OVERLAY_DOT_CAMERAS = LAYER_NAMES.dotcam;
const OVERLAY_TRAFFIC_EVENTS = LAYER_NAMES.traffic;
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).
@@ -1171,13 +1177,20 @@ export default function MapView({
const startOverlayNames = useMemo(() => new Set(Array.from(startLayers).map((k) => LAYER_NAMES[k])), [startLayers]);
const layerOn = (k: LayerKey) => startLayers.has(k);
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
// overlay is actually on (server-26#185).
// overlay is actually on (server-26#185); same for the outage key (#184).
useEffect(() => {
if (!mapInstance) return;
const on = (e: L.LayersControlEvent) => e.name === LAYER_NAMES.air && setAircraftShown(true);
const off = (e: L.LayersControlEvent) => e.name === LAYER_NAMES.air && setAircraftShown(false);
const on = (e: L.LayersControlEvent) => {
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("overlayremove", off);
return () => {
@@ -1393,6 +1406,13 @@ export default function MapView({
</FeatureGroup>
</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 */}
<LayersControl.Overlay checked={layerOn("radar")} name={LAYER_NAMES.radar}>
<RadarLayer />
@@ -1463,6 +1483,19 @@ export default function MapView({
</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">
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Nodes</p>
{([