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,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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user