From d10c2f732a8f91e9ca55418a44b13204a4a1dd3e Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Mon, 5 Oct 2026 03:12:32 -0400 Subject: [PATCH 1/2] 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 --- drb-frontend/components/MapView.tsx | 39 ++++- drb-frontend/components/live/WallPanels.tsx | 121 ++++++++++++- drb-frontend/components/map/OutageLayer.tsx | 183 ++++++++++++++++++++ drb-frontend/lib/outageMatch.ts | 121 +++++++++++++ drb-frontend/lib/useOutages.ts | 103 +++++++++++ 5 files changed, 563 insertions(+), 4 deletions(-) create mode 100644 drb-frontend/components/map/OutageLayer.tsx create mode 100644 drb-frontend/lib/outageMatch.ts create mode 100644 drb-frontend/lib/useOutages.ts diff --git a/drb-frontend/components/MapView.tsx b/drb-frontend/components/MapView.tsx index e951065..cbb49c7 100644 --- a/drb-frontend/components/MapView.tsx +++ b/drb-frontend/components/MapView.tsx @@ -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({ + {/* Overlay: Power outages (server-26#184) — licence-gated providers, opt-in */} + + + + + + {/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet */} @@ -1463,6 +1483,19 @@ export default function MapView({ )} + {outagesShown && ( +
+

Power outages

+
+ + County with outages · size = meters out +
+
+ + Stale upstream rows only +
+
+ )}

Nodes

{([ diff --git a/drb-frontend/components/live/WallPanels.tsx b/drb-frontend/components/live/WallPanels.tsx index 5715e01..4535560 100644 --- a/drb-frontend/components/live/WallPanels.tsx +++ b/drb-frontend/components/live/WallPanels.tsx @@ -16,7 +16,9 @@ import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice"; import { SourceCredit, fetchedLabel } from "@/components/SourceCredit"; import { InlinePlayer } from "@/components/InlinePlayer"; 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) { 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 (Admin → Outage providers) + : <>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 = OUTAGE; + if (!points.length) return
  • {tag}—
  • ; + if (!outageIsUsable(state)) return
  • {tag}
  • ; + 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 && ( +
  • + + {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"} + +
  • + )} + {matches.map((m) => ( +
  • + {tag}{m.text} + {m.area.etr ? ` · est. restoration ${new Date(m.area.etr).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}` : ""} + +
  • + ))} + {!matches.length && ( +
  • + {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) => ( + + ))} +
  • + )} + + ); +} + +/** 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 = OUTAGE; + if (!box) return
  • {tag}no region to check (no nodes or geocoded incidents)
  • ; + if (!outageIsUsable(state)) return
  • {tag}
  • ; + 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 ( +
  • {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)
  • + ); + })} + + ); +} + // ── Level 1: region ────────────────────────────────────────────────────────── export function RegionPanel({ incidents, calls, onAirCount, nodes, wallCameras, feeds, servingCount, aircraftCount, vesselCount, @@ -123,6 +240,7 @@ export function RegionPanel({ {providers.map((p) => (
  • CAM{p.name}: {p.total_in_bbox} in region{p.error ? " · feed error" : ""}{p.fetched_at ? ` · list ${fetchedLabel(p.fetched_at)}` : ""}
  • ))} +
  • NEXRADWeather Radar layer (map menu); sampled per incident
  • @@ -270,6 +388,7 @@ export function IncidentPanel({ {!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)`} + {!ctx.aircraft.length && !ctx.vessels.length && !ctx.road.length && (

    No aircraft within {CONTEXT_KM.aircraft} km, vessels within {CONTEXT_KM.vessel} km, or road events within {CONTEXT_KM.road} km.

    diff --git a/drb-frontend/components/map/OutageLayer.tsx b/drb-frontend/components/map/OutageLayer.tsx new file mode 100644 index 0000000..63dd7a5 --- /dev/null +++ b/drb-frontend/components/map/OutageLayer.tsx @@ -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 + ? `` + : ``; + const bolt = ``; + return `${ring}${bolt}`; +} + +/** 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: `
    ${outageGlyphSvg(s, stale)}` + + `${label}
    `, + 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 ( +
    +
    {countyName(a)}
    + {a.stale ? ( +
    + 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. +
    + ) : ( +
    + {a.customers_out.toLocaleString()} 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 && · plus {a.stale_customers_out.toLocaleString()} on stale rows} +
    + )} + {(since || etr) && ( +
    {since ? `Out since ${since}` : ""}{since && etr ? " · " : ""}{etr ? `Est. restoration ${etr}` : ""}
    + )} + {!etr && !a.stale &&
    No restoration estimate given.
    } + {a.utilities.length > 0 && ( +
      + {a.utilities.map((u, 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)` : ""} +
    • + ))} +
    + )} +
    County-level count — the marker is the county's centre, not where the outage is.
    + +
    + ); +} + +/** 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(null); + useEffect(() => { + if (ref.current) L.DomEvent.disableClickPropagation(ref.current); + }); + const s = statusFor(state, isAdmin, liveCount, staleCount); + return createPortal( +
    + {s.text} + {s.adminLink && <> Admin → Outage providers} +
    , + 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 ( + <> + + {drawable.map((a) => ( + + + + + + ))} + + ); +} diff --git a/drb-frontend/lib/outageMatch.ts b/drb-frontend/lib/outageMatch.ts new file mode 100644 index 0000000..6189714 --- /dev/null +++ b/drb-frontend/lib/outageMatch.ts @@ -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 " 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): 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, + 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!))); +} diff --git a/drb-frontend/lib/useOutages.ts b/drb-frontend/lib/useOutages.ts new file mode 100644 index 0000000..1580762 --- /dev/null +++ b/drb-frontend/lib/useOutages.ts @@ -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(null); + const [error, setError] = useState(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 | 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]); +} -- 2.54.0 From e7070a0afa8f8e47f26c201b435d89c751c5d64b Mon Sep 17 00:00:00 2001 From: Logan Cusano Date: Mon, 5 Oct 2026 03:16:58 -0400 Subject: [PATCH 2/2] Outages review fixes: reset on new target, memoize marker icons, coverage note in popup (#184) - useOutagesIn kept the previous incident's answer while fetching the next one (IncidentPanel isn't keyed), so a newly selected incident briefly read 'none reported here'. Drop the old answer when the target changes. - OutageLayer built a fresh divIcon per marker every render; MapView re-renders on live updates, so Leaflet rebuilt every marker's DOM. Memoize. - Popup now carries the provider's coverage note (ODIN: non-reporting utilities, e.g. Con Ed/O&R, show nothing even during an outage). Co-Authored-By: Claude Opus 5.5 --- drb-frontend/components/map/OutageLayer.tsx | 8 ++++++-- drb-frontend/lib/useOutages.ts | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/drb-frontend/components/map/OutageLayer.tsx b/drb-frontend/components/map/OutageLayer.tsx index 63dd7a5..31a6fed 100644 --- a/drb-frontend/components/map/OutageLayer.tsx +++ b/drb-frontend/components/map/OutageLayer.tsx @@ -91,6 +91,7 @@ function OutagePopupBody({ a, feed }: { a: OutageArea; feed?: OutageProviderFeed )}
    County-level count — the marker is the county's centre, not where the outage is.
    + {feed?.coverage_note &&
    {feed.coverage_note}
    } @@ -163,7 +164,10 @@ export function OutageLayer({ shown }: { shown: boolean }) { 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 drawable = useMemo(() => areas.filter((a) => a.lat != null && a.lon != null), [areas]); + // One icon per area per response: a fresh divIcon each render would make + // Leaflet rebuild every marker's DOM whenever MapView re-renders. + const icons = useMemo(() => new Map(drawable.map((a) => [a.id, outageIcon(a)])), [drawable]); const liveCount = areas.filter((a) => !a.stale && a.customers_out > 0).length; const staleCount = areas.filter((a) => a.stale).length; if (!shown) return null; @@ -171,7 +175,7 @@ export function OutageLayer({ shown }: { shown: boolean }) { <> {drawable.map((a) => ( - diff --git a/drb-frontend/lib/useOutages.ts b/drb-frontend/lib/useOutages.ts index 1580762..b44d1e4 100644 --- a/drb-frontend/lib/useOutages.ts +++ b/drb-frontend/lib/useOutages.ts @@ -34,7 +34,10 @@ function useOutageFetch( const seq = useRef(0); useEffect(() => { - if (!getBox) { setData(null); setError(null); setLoaded(false); return; } + // A new target (another incident, overlay re-enabled) must not be judged + // against the previous target's answer: drop it and show "checking…". + setData(null); setError(null); setLoaded(false); + if (!getBox) return; const boxFn = getBox; let last = 0; const load = async () => { -- 2.54.0