"use client";
/**
* Live ops wall side panel — Level 1 (region) and Level 2 (incident) of the
* approved mockup (design-canvas/live-ops-wall-mockup.html).
*/
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import type {
AircraftTrack, CallRecord, Camera, CameraCredit, CameraProviderFeed, IncidentRecord, Ny511Event, NodeRecord, VesselTrack,
} from "@/lib/types";
import { isKnownSeverity, SEVERITY_COLORS, SEVERITY_LABEL } from "@/lib/severity";
import { useAuth } from "@/components/AuthProvider";
import { CameraFeed } from "@/components/CameraFeed";
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, MAY_BE_MERGED_KM, NEAR_CAMERA_KM, bboxAround, contextFor, pathStops, sampleRadarDbz, stopNumbers,
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)";
}
function sevLabel(s: string | null | undefined) {
return isKnownSeverity(s) ? SEVERITY_LABEL[s] : "Unknown";
}
function elapsed(iso: string): string {
const m = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 60_000));
return m < 60 ? `${m}m` : `${Math.floor(m / 60)}h ${m % 60}m`;
}
function hhmm(iso: string): string {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
}
const H3 = "text-[11px] uppercase tracking-wide text-ink-muted font-medium mb-1.5";
// Spine rows rendered before "show all loaded" (server-26#239).
const SPINE_CAP = 30;
const SEC = "px-4 py-3 border-b border-line";
export function CameraTile({ cam, feed, km, onOpen }: { cam: Camera; feed?: CameraProviderFeed; km?: number; onOpen: () => void }) {
return (
);
}
function NoCameras({ servingCount, radiusKm }: { servingCount: number; radiusKm: number }) {
// Only admins can open /admin (everyone else is bounced to /), so only they get the link.
const { isAdmin } = useAuth();
return (
{servingCount !== 0
? `No public camera within ${radiusKm} km.`
: isAdmin
? <>No camera provider is enabled. Turn one on under Admin → Camera providers.>
: "No camera provider is enabled. A platform admin can turn them on."}
);
}
// ── 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"}
{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)
Off-screen and hidden-tab tiles stop fetching; at most two live streams play at once.
Feeds
ADS-B{aircraftCount} aircraft heard by your nodes
AIS{vesselCount} vessels / aids to navigation
511NY{eventsOff ? "road events off (Admin → Camera providers)" : `${events.length} road events in region`}
{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
Nodes
{nodes.map((n) => (
{n.name}{n.status}
))}
Region → click an incident for its path, summary, units and nearest cameras → click a camera to open it. Esc steps back up.
Bookmark a view with #inc=…&cam=…&tour.
);
}
// ── Level 2: incident ────────────────────────────────────────────────────────
function useRadarDbz(head: LatLon | null): { dbz: number | null; done: boolean } {
const [state, setState] = useState<{ dbz: number | null; done: boolean }>({ dbz: null, done: false });
const key = head ? `${head[0].toFixed(3)},${head[1].toFixed(3)}` : "";
useEffect(() => {
if (!head) return;
let cancelled = false;
setState({ dbz: null, done: false });
sampleRadarDbz(head[0], head[1]).then((dbz) => { if (!cancelled) setState({ dbz, done: true }); });
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
return state;
}
export function IncidentPanel({
inc, calls, points, near, feeds, servingCount, onAir, aircraft, vessels, events, eventsCredit, onOpenCamera,
}: {
inc: IncidentRecord;
calls: CallRecord[];
points: LatLon[];
near: NearCamera[];
feeds: Map;
servingCount: number;
onAir: boolean;
aircraft: AircraftTrack[];
vessels: VesselTrack[];
events: Ny511Event[];
eventsCredit: { credit?: CameraCredit; fetched_at: number | null };
onOpenCamera: (c: Camera) => void;
}) {
const color = sevColor(inc.severity);
const ctx = useMemo(() => contextFor(points, aircraft, vessels, events), [points, aircraft, vessels, events]);
const radar = useRadarDbz(points.at(-1) ?? null);
// Stop numbers come from the same pathStops() the map draws from, so the
// spine numbers exactly what the map numbers (server-26#236): only movement
// incidents get numbers, and a ghosted (impossible-speed) stop gets none.
const path = useMemo(() => pathStops(inc, calls), [inc, calls]);
const stopOf = useMemo(() => stopNumbers(path), [path]);
const ghostIds = useMemo(() => new Set(path.stops.filter((s) => s.ghost).map((s) => s.call.call_id)), [path]);
const spine = useMemo(() => calls.slice().sort((a, b) => b.started_at.localeCompare(a.started_at)), [calls]);
// "Play incident" (server-26#233): every loaded call with a recording, oldest first.
const playable = useMemo(() => spine.filter(hasAudio).map((c) => c.call_id).reverse(), [spine]);
const auth = useAuth();
const audioOk = canPlayAudio(auth);
const playingCallId = useAudio((s) => s.playingCallId);
const queueLen = useAudio((s) => s.queue.length);
const playingHere = !!playingCallId && spine.some((c) => c.call_id === playingCallId);
// Truncation is said out loud, not silent (server-26#239): the spine shows
// what this page has loaded, which can be less than the incident holds.
const [showAll, setShowAll] = useState(false);
useEffect(() => { setShowAll(false); }, [inc.incident_id]);
const totalCalls = Math.max(inc.call_ids?.length ?? 0, calls.length);
const outsideWindow = Math.max(0, (inc.call_ids?.length ?? 0) - calls.length);
const shown = showAll ? spine : spine.slice(0, SPINE_CAP);
const unitsOn = inc.units_active?.length ? inc.units_active : inc.units ?? [];
const cleared = inc.units_cleared ?? [];
return (
{sevLabel(inc.severity)}{inc.type ?? "other"}
{onAir ? ON AIR : active}
{inc.incident_id.slice(0, 8)}
{inc.title ?? "Incident"}
{inc.location && {inc.location}}
{elapsed(inc.started_at)} elapsed{inc.call_ids?.length ?? 0} calls
{path.mayBeMerged && (
may be merged · {path.lengthKm.toFixed(0)} km
)}
Open incident page →
{inc.summary ?
{inc.summary}
:
No AI summary yet.
}
Nearest cameras · auto-surfaced{points.length > 1 ? " (weighted to the path's head)" : ""}
{near.length ? (
{near.map(({ cam, km }) => (
onOpenCamera(cam)} />
))}
) : points.length ? (
) : (
This incident has no geocoded location, so no cameras can be matched.
)}
Camera signals (detections as incident evidence) · future — blocked on server-26#199 and #200.