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:
Logan Cusano
2026-10-04 17:56:06 -04:00
co-authored by Claude Opus 5.5
parent d57738a8f6
commit aa5b71d853
28 changed files with 3342 additions and 132 deletions
+51
View File
@@ -0,0 +1,51 @@
"use client";
import type { CameraCredit } from "@/lib/types";
/** "3 min ago (14:02)" for an epoch-seconds catalog/feed fetch time. */
export function fetchedLabel(epochS: number | null | undefined): string | null {
if (!epochS) return null;
const ms = epochS * 1000;
const m = Math.round((Date.now() - ms) / 60_000);
const t = new Date(ms).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
return m < 1 ? `just now (${t})` : `${m} min ago (${t})`;
}
/**
* Source credit + freshness + pass-through disclaimer for third-party data
* (511NY DAA: credit as a source credit only, "display the time at which the
* data was published or last updated wherever you present it", and "best
* efforts" to pass on NYSDOT's as-is disclaimer). Render it wherever that
* provider's data or imagery is shown.
*/
export function SourceCredit({
credit,
updated,
fallbackName,
className,
}: {
credit?: CameraCredit | null;
/** Human-readable "updated …" text; required by the DAA wherever 511NY data shows. */
updated?: string | null;
fallbackName?: string;
className?: string;
}) {
const name = credit?.attribution ?? fallbackName ?? null;
return (
<div className={`text-[10px] text-ink-muted space-y-0.5 ${className ?? ""}`}>
<div>
{name && (credit?.attribution_url ? (
<a href={credit.attribution_url} target="_blank" rel="noopener noreferrer" className="underline hover:text-ink-2">{name}</a>
) : <span>{name}</span>)}
{name && updated ? " · " : ""}
{updated ? `updated ${updated}` : ""}
</div>
{credit?.disclaimer && (
<details>
<summary className="cursor-pointer select-none">Provided &ldquo;as is&rdquo;, without warranty — details</summary>
<p className="mt-0.5 leading-snug whitespace-normal">{credit.disclaimer}</p>
</details>
)}
</div>
);
}