Merge pull request 'Power Outages map layer + wires-down cross-match on the live wall (#184)' (#270) from feat/184-outage-map into main
This commit was merged in pull request #270.
This commit is contained in:
@@ -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 (<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 ──────────────────────────────────────────────────────────
|
||||
export function RegionPanel({
|
||||
incidents, calls, onAirCount, nodes, wallCameras, feeds, servingCount, aircraftCount, vesselCount,
|
||||
@@ -123,6 +240,7 @@ export function RegionPanel({
|
||||
{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>
|
||||
))}
|
||||
<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>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -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)`}
|
||||
</li>
|
||||
<OutageContext inc={inc} points={points} />
|
||||
</ul>
|
||||
{!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>
|
||||
|
||||
Reference in New Issue
Block a user