Files
Logan CusanoandClaude Opus 5.5 aa5b71d853 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>
2026-10-04 17:56:06 -04:00

64 lines
3.9 KiB
TypeScript

"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>
);
}