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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user