UI_REDESIGN chunk 4 was never finished: 39 app/component files still used raw Tailwind greys, kept readable in light mode only by the !important override sheet. In dark mode text-gray-600 is ~2.3:1 on surface, and the light override maps it to #94a3b8 (~2.6:1 on white) — unreadable in both. Mechanical mapping (886 classes): gray-100/200 -> ink, 300/400 -> ink-2, 500/600/700 -> ink-muted, bg 950/900/800 -> page/surface/raised, border 800 -> line, 700/600 -> line-strong, plus hover/divide/placeholder. Alpha suffixes kept (tokens are color-mix, so /60 works). bg-gray-700, text-white and marketing pages left alone. text-[9px] -> text-[10px]. Frontend unverified: no Node on the authoring box (#207). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
627 lines
34 KiB
TypeScript
627 lines
34 KiB
TypeScript
"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, PRECIP_ECHO_DBZ, alertsAt, bboxAround, compass, contextFor, pathStops,
|
|
sampleRadarDbz, stopNumbers, wantsWind, wxStyle, wxTime,
|
|
type LatLon, type NearCamera,
|
|
} from "@/lib/liveWall";
|
|
import { useWeatherAlertsIn, useWind, type Polled } from "@/lib/useWeather";
|
|
import type { WeatherAlertsResponse } from "@/lib/types";
|
|
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";
|
|
}
|
|
/** Minutes from `iso` to `endIso` (or now), as "42m" / "1h 5m". */
|
|
function elapsed(iso: string, endIso?: string | null): string {
|
|
const end = endIso ? new Date(endIso).getTime() : Date.now();
|
|
const m = Math.max(0, Math.round((end - 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 (
|
|
<button type="button" onClick={onOpen} title={cam.name} className="relative block w-full text-left rounded-md overflow-hidden border border-line bg-black group">
|
|
<CameraFeed camera={cam} credit={feed} className="w-full aspect-video" />
|
|
{km != null && <span className="absolute top-1 right-1 bg-black/65 text-white font-mono text-[10px] px-1 rounded-sm">{km.toFixed(1)} km</span>}
|
|
<span className="block px-1.5 py-0.5 text-[10px] text-ink-2 bg-surface truncate group-hover:text-ink">{cam.name || cam.id}</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<p className="text-xs text-ink-muted">
|
|
{servingCount !== 0
|
|
? `No public camera within ${radiusKm} km.`
|
|
: isAdmin
|
|
? <>No camera provider is enabled. Turn one on under <Link href="/admin#cameras" className="underline">Admin → Camera providers</Link>.</>
|
|
: "No camera provider is enabled. A platform admin can turn them on."}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
// ── 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>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|
|
|
|
|
|
// ── Weather (server-26#237) ──────────────────────────────────────────────────
|
|
const NWS_CREDIT: CameraCredit = { attribution: "National Weather Service", attribution_url: "https://www.weather.gov/", disclaimer: null };
|
|
const METAR_CREDIT: CameraCredit = { attribution: "NOAA Aviation Weather Center (METAR)", attribution_url: "https://aviationweather.gov/", disclaimer: null };
|
|
|
|
/**
|
|
* Why the NWS answer is unusable, in one line (same wording as the map's
|
|
* banner) — or null when it is fine to read.
|
|
*/
|
|
function nwsProblem(state: Polled<WeatherAlertsResponse>): string | null {
|
|
const { data, error, loaded } = state;
|
|
if (!loaded) return "checking…";
|
|
if (!data) return `could not reach server${error ? ` (${error})` : ""}`;
|
|
if (data.error && !data.fetched_at) return "NWS feed error, no data yet";
|
|
return null;
|
|
}
|
|
function nwsFreshness(state: Polled<WeatherAlertsResponse>): string {
|
|
const d = state.data!;
|
|
return `fetched ${fetchedLabel(d.fetched_at) ?? "—"}${d.error ? " · NWS feed error, showing the last good data" : ""}${state.error ? " · could not refresh" : ""}`;
|
|
}
|
|
|
|
/** Incident panel "NWS" lines: the warning polygons the incident's head point sits inside. */
|
|
function WeatherWarningContext({ head }: { head: LatLon | null }) {
|
|
const box = useMemo(() => (head ? bboxAround([head], 1) : null), [head]);
|
|
const state = useWeatherAlertsIn(box);
|
|
const inside = useMemo(() => (head && state.data ? alertsAt(head, state.data.alerts) : []), [head, state.data]);
|
|
const tag = <span className="font-mono text-[10px] text-ink-muted mr-1.5">NWS</span>;
|
|
if (!head) return <li>{tag}—</li>;
|
|
const problem = nwsProblem(state);
|
|
if (problem) return <li>{tag}{problem}</li>;
|
|
const zoneOnly = state.data!.zone_only.length;
|
|
return (
|
|
<li>
|
|
{tag}
|
|
{inside.length ? (
|
|
<span className="inline-flex flex-wrap gap-1 align-middle">
|
|
{inside.map((a) => {
|
|
const st = wxStyle(a.event);
|
|
return (
|
|
<span key={a.id} title={a.headline ?? a.event}
|
|
className="inline-flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded bg-raised text-ink"
|
|
style={{ borderLeft: `3px ${st.dashArray ? "dashed" : "solid"} ${st.color}` }}>
|
|
inside {a.event}{wxTime(a.expires) ? ` · until ${wxTime(a.expires)}` : ""}
|
|
</span>
|
|
);
|
|
})}
|
|
</span>
|
|
) : (
|
|
<>not inside any active NWS alert area</>
|
|
)}
|
|
{zoneOnly > 0 && <span className="text-ink-muted"> · {zoneOnly} zone-based alert{zoneOnly !== 1 ? "s" : ""} in the served area can't be placed (Weather Warnings layer lists them)</span>}
|
|
<SourceCredit credit={NWS_CREDIT} updated={nwsFreshness(state)} />
|
|
</li>
|
|
);
|
|
}
|
|
|
|
/** Arrow pointing where the wind blows TO (METAR gives where it comes FROM). */
|
|
function WindArrow({ fromDeg }: { fromDeg: number }) {
|
|
return (
|
|
<svg width="14" height="14" viewBox="0 0 14 14" className="inline-block align-[-2px] mr-1" aria-hidden>
|
|
<g transform={`rotate(${(fromDeg + 180) % 360} 7 7)`}>
|
|
<line x1="7" y1="12" x2="7" y2="2.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
|
<polyline points="3.8,5.6 7,2.2 10.2,5.6" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
|
</g>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
/** Incident panel "WIND" line for fire/hazmat incidents: nearest METAR station. */
|
|
function WindContext({ head }: { head: LatLon | null }) {
|
|
const state = useWind(head);
|
|
const tag = <span className="font-mono text-[10px] text-ink-muted mr-1.5">WIND</span>;
|
|
if (!head) return <li>{tag}— (no geocoded location)</li>;
|
|
if (!state.loaded) return <li>{tag}checking…</li>;
|
|
const d = state.data;
|
|
if (!d) return <li>{tag}could not reach server{state.error ? ` (${state.error})` : ""}</li>;
|
|
if (!d.available || !d.wind) return <li>{tag}{d.reason ?? "no wind report"}</li>;
|
|
const w = d.wind;
|
|
const calm = w.wind_kt === 0;
|
|
const dir = calm ? "calm" : w.variable || w.wind_dir_deg == null ? "variable" : `from ${compass(w.wind_dir_deg)} (${Math.round(w.wind_dir_deg)}°)`;
|
|
const obs = w.obs_time ? new Date(w.obs_time) : null;
|
|
const obsLabel = obs && !isNaN(obs.getTime()) ? obs.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }) : null;
|
|
return (
|
|
<li>
|
|
{tag}
|
|
{!calm && !w.variable && w.wind_dir_deg != null && <WindArrow fromDeg={w.wind_dir_deg} />}
|
|
<b>{calm ? "Calm" : `${Math.round(w.wind_kt)} kt`}</b>
|
|
{w.gust_kt != null && !calm ? <b> G {Math.round(w.gust_kt)} kt</b> : null}
|
|
{calm ? "" : ` ${dir}`}
|
|
{" · "}{w.station}{w.station_name ? ` (${w.station_name})` : ""}, {w.distance_km.toFixed(1)} km away
|
|
{obsLabel ? ` · observed ${obsLabel}` : ""}
|
|
{w.stale && <span className="ml-1 bg-accent text-white rounded px-1 text-[10px] font-semibold" title="This station's latest report is over 2 h old">STALE</span>}
|
|
<SourceCredit credit={METAR_CREDIT}
|
|
updated={`fetched ${fetchedLabel(d.fetched_at) ?? "—"}${d.error ? " · feed error, showing the last good report" : ""}${state.error ? " · could not refresh" : ""}`} />
|
|
</li>
|
|
);
|
|
}
|
|
|
|
/** Region Feeds "NWS" row: active alerts over the wall's region, with fetch time or the error. */
|
|
function WeatherFeedRow({ 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]);
|
|
const box = useMemo(() => bboxAround(pts, 10), [pts]);
|
|
const state = useWeatherAlertsIn(box);
|
|
const tag = <span className="font-mono text-[10px] text-ink-muted mr-1.5">NWS</span>;
|
|
if (!box) return <li>{tag}no region to check (no nodes or geocoded incidents)</li>;
|
|
const problem = nwsProblem(state);
|
|
if (problem) return <li>{tag}{problem}</li>;
|
|
const d = state.data!;
|
|
const warnings = d.alerts.filter((a) => wxStyle(a.event).kind === "warning").length;
|
|
return (
|
|
<li>{tag}{d.alerts.length
|
|
? `${d.alerts.length} active alert${d.alerts.length !== 1 ? "s" : ""} in region${warnings ? ` (${warnings} warning${warnings !== 1 ? "s" : ""})` : ""}`
|
|
: "no active alert areas in region"}
|
|
{d.zone_only.length ? ` · ${d.zone_only.length} zone-based in served area` : ""} · {nwsFreshness(state)} · Weather Warnings layer (map menu)</li>
|
|
);
|
|
}
|
|
|
|
// ── Level 1: region ──────────────────────────────────────────────────────────
|
|
export function RegionPanel({
|
|
incidents, calls, onAirCount, nodes, wallCameras, feeds, servingCount, aircraftCount, vesselCount,
|
|
events, eventsOff, pinnedIds, onOpenCamera,
|
|
}: {
|
|
incidents: IncidentRecord[];
|
|
calls: CallRecord[];
|
|
onAirCount: number;
|
|
nodes: NodeRecord[];
|
|
wallCameras: { cam: Camera; km?: number }[];
|
|
feeds: Map<string, CameraProviderFeed>;
|
|
servingCount: number;
|
|
aircraftCount: number;
|
|
vesselCount: number;
|
|
events: Ny511Event[];
|
|
eventsOff: boolean;
|
|
pinnedIds: Set<string>;
|
|
onOpenCamera: (c: Camera) => void;
|
|
}) {
|
|
const calls6h = useMemo(() => {
|
|
const cutoff = Date.now() - 6 * 3600_000;
|
|
return calls.filter((c) => new Date(c.started_at).getTime() >= cutoff).length;
|
|
}, [calls]);
|
|
const providers = Array.from(feeds.values());
|
|
return (
|
|
<div>
|
|
<div className={SEC}>
|
|
<h2 className="text-sm font-semibold text-ink mb-2">Region</h2>
|
|
<div className="grid grid-cols-3 gap-2 text-center">
|
|
{[[incidents.length, "active incidents"], [calls6h, "calls · last 6 h"], [onAirCount, "on air now"]].map(([v, l]) => (
|
|
<div key={String(l)} className="bg-raised rounded-md py-2">
|
|
<div className="text-lg font-semibold text-ink tabular-nums">{v}</div>
|
|
<div className="text-[10px] text-ink-muted">{l}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className={SEC}>
|
|
<h3 className={H3}>Camera wall · pinned + nearest to major/moderate incidents</h3>
|
|
{wallCameras.length ? (
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{wallCameras.map(({ cam, km }) => (
|
|
<div key={cam.id} className="relative">
|
|
<CameraTile cam={cam} feed={feeds.get(cam.provider)} km={km} onOpen={() => onOpenCamera(cam)} />
|
|
{pinnedIds.has(cam.id) && <span className="absolute bottom-6 right-1 bg-accent text-white text-[10px] px-1 rounded-sm">PINNED</span>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<NoCameras servingCount={servingCount} radiusKm={NEAR_CAMERA_KM} />
|
|
)}
|
|
<p className="text-[10px] text-ink-muted mt-2">Off-screen and hidden-tab tiles stop fetching; at most two live streams play at once.</p>
|
|
</div>
|
|
<div className={SEC}>
|
|
<h3 className={H3}>Feeds</h3>
|
|
<ul className="space-y-1 text-xs text-ink-2">
|
|
<li><span className="font-mono text-[10px] text-ink-muted mr-1.5">ADS-B</span>{aircraftCount} aircraft heard by your nodes</li>
|
|
<li><span className="font-mono text-[10px] text-ink-muted mr-1.5">AIS</span>{vesselCount} vessels / aids to navigation</li>
|
|
<li><span className="font-mono text-[10px] text-ink-muted mr-1.5">511NY</span>{eventsOff ? "road events off (Admin → Camera providers)" : `${events.length} road events in region`}</li>
|
|
{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} />
|
|
<WeatherFeedRow incidents={incidents} nodes={nodes} />
|
|
<li><span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span>Weather Radar layer (map menu, IEM n0q, with valid time); sampled per incident</li>
|
|
</ul>
|
|
</div>
|
|
<div className={SEC}>
|
|
<h3 className={H3}>Nodes</h3>
|
|
<ul className="space-y-1 text-xs">
|
|
{nodes.map((n) => (
|
|
<li key={n.node_id} className="flex justify-between gap-2">
|
|
<span className="text-ink-2 truncate">{n.name}</span>
|
|
<span className="font-mono text-ink-muted capitalize">{n.status}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
<p className="px-4 py-3 text-[10px] text-ink-muted">
|
|
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 <span className="font-mono">#inc=…&cam=…&tour</span>.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── 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<string, CameraProviderFeed>;
|
|
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);
|
|
// A resolved incident opened from an #inc= link (server-26#275): nothing in
|
|
// the panel may read as still running. Its duration freezes at resolved_at
|
|
// (the correlator writes it; the frontend type doesn't declare it yet), and
|
|
// the live camera / context feeds are labelled as "now", not incident time.
|
|
const resolved = inc.status === "resolved";
|
|
const resolvedAt = (inc as IncidentRecord & { resolved_at?: string | null }).resolved_at ?? null;
|
|
const resolvedAtOk = !!resolvedAt && !Number.isNaN(new Date(resolvedAt).getTime());
|
|
const ctx = useMemo(() => contextFor(points, aircraft, vessels, events), [points, aircraft, vessels, events]);
|
|
const head = points.at(-1) ?? null;
|
|
const radar = useRadarDbz(head);
|
|
// 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 (
|
|
<div>
|
|
<div className={SEC}>
|
|
<div className="flex items-center gap-2 text-[11px] mb-1">
|
|
<span className="font-semibold uppercase tracking-wide" style={{ color }}>{sevLabel(inc.severity)}</span>
|
|
<span className="text-ink-muted">{inc.type ?? "other"}</span>
|
|
{resolved ? <span className="text-ink-muted">resolved</span>
|
|
: onAir ? <span className="bg-accent text-white rounded px-1 text-[10px] font-semibold">ON AIR</span>
|
|
: <span className="text-ink-muted">active</span>}
|
|
<span className="ml-auto font-mono text-ink-muted">{inc.incident_id.slice(0, 8)}</span>
|
|
</div>
|
|
<h2 className="text-base font-semibold text-ink leading-snug">{inc.title ?? "Incident"}</h2>
|
|
<div className="text-xs text-ink-muted mt-1 flex flex-wrap gap-x-3">
|
|
{inc.location && <span>{inc.location}</span>}
|
|
{!resolved ? <span className="font-mono">{elapsed(inc.started_at)} elapsed</span>
|
|
: resolvedAtOk ? <span className="font-mono">lasted {elapsed(inc.started_at, resolvedAt)}</span>
|
|
: null}
|
|
<span>{inc.call_ids?.length ?? 0} calls</span>
|
|
{path.mayBeMerged && (
|
|
<span className="text-[10px] px-1 rounded border border-dashed border-line text-ink-2"
|
|
title={`The calls' locations span ${path.lengthKm.toFixed(0)} km (over ${MAY_BE_MERGED_KM} km) — this may be more than one event`}>
|
|
may be merged · {path.lengthKm.toFixed(0)} km
|
|
</span>
|
|
)}
|
|
</div>
|
|
<Link href={`/incidents/${inc.incident_id}`} className="text-xs text-accent hover:underline mt-1 inline-block">Open incident page →</Link>
|
|
</div>
|
|
|
|
<div className={SEC}>
|
|
<MachineOutputNotice variant="inline" className="text-[10px] mb-1.5 items-start" />
|
|
{inc.summary ? <p className="text-sm text-ink-2 leading-relaxed">{inc.summary}</p> : <p className="text-xs text-ink-muted">No AI summary yet.</p>}
|
|
</div>
|
|
|
|
<div className={SEC}>
|
|
<h3 className={H3}>Nearest cameras · auto-surfaced{points.length > 1 ? " (weighted to the path's head)" : ""}</h3>
|
|
{resolved && <p className="text-[10px] text-ink-muted mb-1.5">Live views as of now, not from when this incident was active.</p>}
|
|
{near.length ? (
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{near.map(({ cam, km }) => (
|
|
<CameraTile key={cam.id} cam={cam} feed={feeds.get(cam.provider)} km={km} onOpen={() => onOpenCamera(cam)} />
|
|
))}
|
|
</div>
|
|
) : points.length ? (
|
|
<NoCameras servingCount={servingCount} radiusKm={NEAR_CAMERA_KM} />
|
|
) : (
|
|
<p className="text-xs text-ink-muted">This incident has no geocoded location, so no cameras can be matched.</p>
|
|
)}
|
|
<div className="mt-2 border border-dashed border-line rounded-md px-2 py-1.5 text-[10px] text-ink-muted">
|
|
Camera signals (detections as incident evidence) · future — blocked on server-26#199 and #200.
|
|
</div>
|
|
</div>
|
|
|
|
<div className={SEC}>
|
|
<h3 className={H3}>Situational context · fused layers</h3>
|
|
{resolved && <p className="text-[10px] text-ink-muted mb-1.5">Current conditions near the location, not at incident time.</p>}
|
|
<ul className="space-y-1 text-xs text-ink-2">
|
|
{ctx.aircraft.map(({ a, km }) => (
|
|
<li key={a.icao}><span className="font-mono text-[10px] text-ink-muted mr-1.5">ADS-B</span>
|
|
<b>{a.callsign || a.icao}</b>{a.altitude_ft != null ? ` · ${Math.round(a.altitude_ft).toLocaleString()} ft` : ""} · {km.toFixed(1)} km</li>
|
|
))}
|
|
{ctx.vessels.map(({ v, km }) => (
|
|
<li key={v.mmsi}><span className="font-mono text-[10px] text-ink-muted mr-1.5">AIS</span>
|
|
<b>{v.name || v.mmsi}</b>{v.speed_kt != null ? ` · ${Math.round(v.speed_kt)} kt` : ""} · {km.toFixed(1)} km</li>
|
|
))}
|
|
{ctx.road.map(({ e, km }) => (
|
|
<li key={e.id}>
|
|
<span className="font-mono text-[10px] text-ink-muted mr-1.5">511NY</span>
|
|
{/* DAA: NYSDOT's text as published, with its update time and source credit. */}
|
|
<span className="whitespace-pre-line">{e.description}</span> · {km.toFixed(1)} km
|
|
<SourceCredit credit={eventsCredit.credit} fallbackName="511NY / NYSDOT"
|
|
updated={e.updated_local ? new Date(e.updated_local).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) : fetchedLabel(eventsCredit.fetched_at)} />
|
|
</li>
|
|
))}
|
|
<li>
|
|
<span className="font-mono text-[10px] text-ink-muted mr-1.5">NEXRAD</span>
|
|
{!points.length ? "—" : !radar.done ? "sampling…" : radar.dbz == null ? "radar sample unavailable"
|
|
: radar.dbz < PRECIP_ECHO_DBZ ? "no precipitation echo within ~5 km"
|
|
: `up to ~${Math.round(radar.dbz)} dBZ within ~5 km (approx., read from the radar layer's n0q tile)`}
|
|
</li>
|
|
<WeatherWarningContext head={head} />
|
|
{wantsWind(inc) && <WindContext head={head} />}
|
|
<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>
|
|
)}
|
|
</div>
|
|
|
|
<div className={`${SEC} grid grid-cols-2 gap-3`}>
|
|
<div>
|
|
<h3 className={H3}>{resolved ? "Last on scene" : "On scene"}</h3>
|
|
<div className="flex flex-wrap gap-1">{unitsOn.length ? unitsOn.map((u) => <span key={u} className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2">{u}</span>) : <span className="text-xs text-ink-muted">—</span>}</div>
|
|
</div>
|
|
<div>
|
|
<h3 className={H3}>Cleared</h3>
|
|
<div className="flex flex-wrap gap-1">{cleared.length ? cleared.map((u) => <span key={u} className="font-mono text-[10px] px-1 rounded bg-raised text-ink-muted">{u}</span>) : <span className="text-xs text-ink-muted">—</span>}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={SEC}>
|
|
<div className="flex items-center gap-2 mb-1.5">
|
|
<h3 className={`${H3} mb-0`}>Call spine · newest first</h3>
|
|
{audioOk && playable.length > 0 && (
|
|
playingHere ? (
|
|
<button type="button" onClick={stopAudio}
|
|
className="ml-auto text-[10px] px-1.5 py-0.5 rounded border border-line text-ink-2 hover:bg-raised">
|
|
■ Stop{queueLen ? ` · ${queueLen} queued` : ""}
|
|
</button>
|
|
) : (
|
|
<button type="button" onClick={() => playIncident(playable)}
|
|
title="Play this incident's loaded calls back to back, oldest first"
|
|
className="ml-auto text-[10px] px-1.5 py-0.5 rounded bg-accent text-white hover:brightness-110">
|
|
▶ Play incident · {playable.length}
|
|
</button>
|
|
)
|
|
)}
|
|
</div>
|
|
{spine.length > SPINE_CAP || outsideWindow > 0 ? (
|
|
<p className="text-[10px] text-ink-muted mb-1.5">
|
|
{showAll || spine.length <= SPINE_CAP
|
|
? `Showing all ${spine.length} loaded of ${totalCalls}`
|
|
: `Showing latest ${SPINE_CAP} of ${totalCalls}`}
|
|
{outsideWindow > 0 && ` · ${outsideWindow} older call${outsideWindow !== 1 ? "s" : ""} outside the loaded window`}
|
|
{spine.length > SPINE_CAP && (
|
|
<button type="button" onClick={() => setShowAll((v) => !v)} className="ml-1.5 text-accent hover:underline">
|
|
{showAll ? "show latest 30" : "show all loaded"}
|
|
</button>
|
|
)}
|
|
</p>
|
|
) : null}
|
|
{spine.length ? (
|
|
<ol className="space-y-2">
|
|
{shown.map((c) => {
|
|
const stop = stopOf.get(c.call_id);
|
|
const ghost = ghostIds.has(c.call_id);
|
|
const text = c.transcript_corrected || c.transcript;
|
|
const isPlaying = c.call_id === playingCallId;
|
|
return (
|
|
<li key={c.call_id}
|
|
className={`flex gap-2 text-xs rounded ${isPlaying ? "bg-raised ring-1 ring-accent -mx-1 px-1 py-0.5" : ""}`}
|
|
aria-current={isPlaying ? "true" : undefined}>
|
|
<span className="shrink-0 w-5 h-5 rounded-full flex items-center justify-center font-mono text-[10px]"
|
|
title={ghost ? "Geocoded too far from the previous stop to reach in time — left off the path" : undefined}
|
|
style={stop ? { background: color, color: "var(--page)" }
|
|
: ghost ? { border: "1.5px dashed var(--ink-muted)", color: "var(--ink-muted)" }
|
|
: { background: "var(--raised)", color: "var(--ink-muted)" }}>{stop ?? (ghost ? "?" : "·")}</span>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex gap-1.5 items-center">
|
|
<span className="font-mono text-ink-muted">{hhmm(c.started_at)}</span>
|
|
{c.talkgroup_name && <span className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2 truncate">{c.talkgroup_name}</span>}
|
|
</div>
|
|
<p className="text-ink-2 leading-snug">{text || <span className="text-ink-muted">no transcript</span>}</p>
|
|
{isPlaying && hasAudio(c) && <div className="mt-1"><InlinePlayer callId={c.call_id} /></div>}
|
|
</div>
|
|
{hasAudio(c) && !isPlaying && <InlinePlayer callId={c.call_id} compact />}
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
) : (
|
|
<p className="text-xs text-ink-muted">No calls for this incident in the loaded window.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|