Live ops wall + pluggable camera providers with licence gating (server-26#198)
Backend (c2-core):
- app/internal/cameras/: provider interface, licence model, per-provider
catalog cache; one adapter per source in design-canvas/camera-sources.md.
Implemented: 511NY (legacy shape + tolerant XML inventory parser, 511x
endpoints UNVERIFIED), Caltrans, DelDOT, Ontario 511, DriveBC, Calgary,
TfL, NSW, Fintraffic. Stubs: NYC DOT, Westchester DPW, TRANSCOM, CTroads,
511NJ, Austin, TxDOT, MD CHART, WSDOT, Tallinn, Tarktee, Warendorf,
TrafficLand, Windy, EarthCam.
- Every provider defaults OFF (config/camera_providers). Gated licences
(agreement required / unverified / non-commercial / paid) need an explicit
acknowledgement stored with who/when and written to audit_log; the audit
write is a precondition. Stubs and prohibited sources cannot be enabled.
Ack is bound to the licence status it was given for.
- GET /cameras (signed-in), GET/PUT /admin/camera-providers (admin only).
- /traffic/511 follows the ny511 provider switch and returns the DAA credit
+ NYSDOT as-is disclaimer; 511NY event descriptions no longer truncated
("may not alter the substance of a report").
Frontend:
- LiveView becomes the ops wall: Region > Incident > Camera drill-down,
worst-first rail (MapView), KPIs, transcript ticker, scrubber incident
bands, auto-tour, deep links (#inc=&cam=&tour), Esc steps up.
- Incident panel: nearest cameras (path-weighted), fused situational context
(ADS-B, AIS, 511NY events, NEXRAD tile sample), units, call spine.
- MapView keeps every layer; adds Camera Viewsheds (approx.) overlay and a
nearest-camera layer for the selected incident.
- CameraFeed hygiene: hidden-tab/off-screen gating, 2 concurrent HLS cap,
last good frame + stale badge, frozen-frame signature, HLS stall watchdog,
provider attribution rendered on the imagery.
- Camera/event popups show source credit, update time and the disclaimer.
- Admin > Camera providers tab with licence status, health, gated confirm.
- Camera detection is not built (blocked on #199/#200); seam only.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d57738a8f6
commit
aa5b71d853
@@ -0,0 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { Camera, CameraProviderFeed } from "@/lib/types";
|
||||
import { CameraFeed } from "@/components/CameraFeed";
|
||||
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
|
||||
|
||||
/**
|
||||
* Level 3 of the wall: one camera, large. Esc / Back steps up to the incident.
|
||||
*
|
||||
* Camera detections (the mockup's boxes + per-class counts) are NOT built:
|
||||
* blocked on server-26#199 (NYSDOT's written answer on imagery analytics) and
|
||||
* #200 (CISO ruling on retention). The seam is `CameraSignalSink` in c2-core
|
||||
* (app/internal/cameras/base.py); this panel only states that plainly.
|
||||
*/
|
||||
export function CameraDrill({
|
||||
camera, feed, distanceKm, incidentTitle, pinned, onTogglePin, onClose,
|
||||
}: {
|
||||
camera: Camera;
|
||||
feed?: CameraProviderFeed;
|
||||
distanceKm?: number | null;
|
||||
incidentTitle?: string | null;
|
||||
pinned: boolean;
|
||||
onTogglePin: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const closeRef = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => { closeRef.current?.focus(); }, [camera.id]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[2000] bg-black/70 flex items-center justify-center p-4" role="dialog" aria-modal="true"
|
||||
aria-labelledby="drb-drill-title" onClick={onClose}>
|
||||
<div className="bg-surface border border-line-strong rounded-xl shadow-2xl w-full max-w-5xl max-h-full overflow-y-auto grid md:grid-cols-[minmax(0,1fr)_300px]"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
<CameraFeed camera={camera} priority="high" credit={feed} className="w-full aspect-video bg-black md:rounded-l-xl" />
|
||||
<div className="p-4 space-y-3 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<h2 id="drb-drill-title" className="flex-1 font-semibold text-ink leading-snug">{camera.name || camera.id}</h2>
|
||||
<button ref={closeRef} type="button" onClick={onClose} className="text-xs border border-line rounded px-2 py-1 hover:bg-raised" aria-label="Close camera">Esc</button>
|
||||
</div>
|
||||
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
<dt className="text-ink-muted">Source</dt><dd className="text-ink-2">{feed?.name ?? camera.provider}</dd>
|
||||
{camera.roadway && (<><dt className="text-ink-muted">Road</dt><dd className="text-ink-2">{camera.roadway}</dd></>)}
|
||||
{camera.direction && (<><dt className="text-ink-muted">Direction</dt><dd className="text-ink-2">{camera.direction}{camera.heading_deg != null ? " (approx. view)" : ""}</dd></>)}
|
||||
{distanceKm != null && (<><dt className="text-ink-muted">Distance</dt><dd className="text-ink-2">{distanceKm.toFixed(1)} km from {incidentTitle ?? "the incident"}</dd></>)}
|
||||
<dt className="text-ink-muted">Feed</dt><dd className="text-ink-2">{camera.video_url ? "Live stream (falls back to still)" : "Still image"}</dd>
|
||||
</dl>
|
||||
<SourceCredit credit={feed} fallbackName={feed?.name} updated={feed ? `camera list ${fetchedLabel(feed.fetched_at) ?? "—"}` : null} />
|
||||
<div className="border border-dashed border-line rounded-md p-2.5 text-xs text-ink-muted space-y-1">
|
||||
<p className="font-medium text-ink-2">Camera signals · future</p>
|
||||
<p>Object-class counts from camera stills (vehicle, truck, person, smoke) are not built. Blocked on
|
||||
server-26#199 (NYSDOT written answer on imagery analytics) and #200 (CISO ruling on retention).
|
||||
No plate or face recognition will ever be part of it.</p>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<button type="button" onClick={onTogglePin} className="text-xs rounded px-3 py-1.5 bg-accent text-white">{pinned ? "Unpin from wall" : "Pin to wall"}</button>
|
||||
<button type="button" onClick={onClose} className="text-xs border border-line rounded px-3 py-1.5 hover:bg-raised">Back</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, type CSSProperties } from "react";
|
||||
import type { CallRecord } from "@/lib/types";
|
||||
|
||||
const WINDOW_MS = 60 * 60 * 1000;
|
||||
const MAX_ITEMS = 25;
|
||||
|
||||
function hhmm(iso: string): string {
|
||||
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Live radio transcripts, newest first, as a slow belt along the bottom of
|
||||
* the wall (mockup "Radio" ticker). Clicking an incident id drills into it.
|
||||
* Transcripts are machine output — the same text the call list already shows.
|
||||
*/
|
||||
export function TranscriptTicker({ calls, onSelectIncident }: { calls: CallRecord[]; onSelectIncident: (id: string) => void }) {
|
||||
const items = useMemo(() => {
|
||||
const cutoff = Date.now() - WINDOW_MS;
|
||||
return calls
|
||||
.filter((c) => !c.duplicate_of && (c.transcript_corrected || c.transcript) && new Date(c.started_at).getTime() >= cutoff)
|
||||
.sort((a, b) => b.started_at.localeCompare(a.started_at))
|
||||
.slice(0, MAX_ITEMS);
|
||||
}, [calls]);
|
||||
|
||||
const belt = (copy: number) =>
|
||||
items.map((c) => {
|
||||
const text = (c.transcript_corrected || c.transcript || "").replace(/\s+/g, " ").trim();
|
||||
const inc = c.incident_ids?.[0] ?? c.incident_id ?? null;
|
||||
return (
|
||||
<span key={`${copy}-${c.call_id}`} className="inline-flex items-center gap-1.5 mr-8 whitespace-nowrap" aria-hidden={copy > 0}>
|
||||
<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">{c.talkgroup_name}</span>}
|
||||
{inc && (
|
||||
<button type="button" tabIndex={copy > 0 ? -1 : 0} onClick={() => onSelectIncident(inc)}
|
||||
className="font-mono text-[10px] text-accent hover:underline">{inc.slice(0, 8)}</button>
|
||||
)}
|
||||
<span className="text-ink-2">{text.length > 160 ? `${text.slice(0, 157)}…` : text}</span>
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-9 shrink-0 flex items-center border-t border-line bg-surface/95 text-xs" aria-label="Live radio transcripts">
|
||||
<div className="px-3 shrink-0 font-semibold text-ink-2 uppercase tracking-wide text-[10px] flex items-center gap-1">
|
||||
<span className="text-accent">●</span>Radio
|
||||
</div>
|
||||
<div className="drb-ticker flex-1 min-w-0 overflow-hidden">
|
||||
{items.length === 0 ? (
|
||||
<span className="text-ink-muted">No transcribed calls in the last hour.</span>
|
||||
) : (
|
||||
<div className="drb-ticker-belt inline-flex" style={{ "--drb-ticker-duration": `${Math.max(40, items.length * 8)}s` } as CSSProperties}>
|
||||
{belt(0)}
|
||||
{belt(1)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<span className="px-3 shrink-0 text-[10px] text-ink-muted hidden md:inline">machine transcripts · unverified</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
"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 { CameraFeed } from "@/components/CameraFeed";
|
||||
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
|
||||
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
|
||||
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";
|
||||
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-[9px] 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 }) {
|
||||
return (
|
||||
<p className="text-xs text-ink-muted">
|
||||
{servingCount === 0
|
||||
? <>No camera provider is enabled. An admin turns them on under <Link href="/admin" className="underline">Admin → Camera providers</Link>.</>
|
||||
: `No public camera within ${radiusKm} km.`}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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-[9px] 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>
|
||||
))}
|
||||
<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>
|
||||
<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);
|
||||
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<string, number>();
|
||||
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]);
|
||||
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>
|
||||
{onAir ? <span className="bg-accent text-white rounded px-1 text-[9px] 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>}
|
||||
<span className="font-mono">{elapsed(inc.started_at)} elapsed</span>
|
||||
<span>{inc.call_ids?.length ?? 0} calls</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>
|
||||
{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>
|
||||
<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 === 0 ? "no precipitation echo within ~5 km" : `up to ~${radar.dbz} dBZ within ~5 km (approx., from the radar tile)`}
|
||||
</li>
|
||||
</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}>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}>
|
||||
<h3 className={H3}>Call spine · newest first</h3>
|
||||
{spine.length ? (
|
||||
<ol className="space-y-2">
|
||||
{spine.slice(0, 30).map((c) => {
|
||||
const stop = stopOf.get(c.call_id);
|
||||
const text = c.transcript_corrected || c.transcript;
|
||||
return (
|
||||
<li key={c.call_id} className="flex gap-2 text-xs">
|
||||
<span className="shrink-0 w-5 h-5 rounded-full flex items-center justify-center font-mono text-[10px]"
|
||||
style={stop ? { background: color, color: "var(--page)" } : { background: "var(--raised)", color: "var(--ink-muted)" }}>{stop ?? "·"}</span>
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="text-xs text-ink-muted">No calls for this incident in the loaded window.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user