"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, NEAR_CAMERA_KM, contextFor, sampleRadarDbz, type LatLon, type NearCamera } from "@/lib/liveWall";
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."}
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 share the index with the map path: geocoded calls, oldest first.
const stopOf = useMemo(() => {
const m = new Map();
calls.filter((c) => c.location_coords).slice().sort((a, b) => a.started_at.localeCompare(b.started_at))
.forEach((c, i) => m.set(c.call_id, i + 1));
return m;
}, [calls]);
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)}