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
@@ -10,8 +10,8 @@
|
||||
* fixture — only the scrub interaction is deferred.
|
||||
*/
|
||||
import { useMemo, useState } from "react";
|
||||
import type { CallRecord } from "@/lib/types";
|
||||
import { SEVERITY_COLORS, severityRank } from "@/lib/severity";
|
||||
import type { CallRecord, IncidentRecord } from "@/lib/types";
|
||||
import { isKnownSeverity, SEVERITY_COLORS, severityRank } from "@/lib/severity";
|
||||
|
||||
const RANGES = [
|
||||
{ label: "1h", ms: 60 * 60 * 1000 },
|
||||
@@ -22,7 +22,17 @@ const RANGES = [
|
||||
|
||||
const BUCKETS = 48;
|
||||
|
||||
export function TimeScrubber({ calls }: { calls: CallRecord[] }) {
|
||||
/**
|
||||
* `incidents` (optional) adds one band per active incident from its start to
|
||||
* its last update, tinted by severity — click a band to drill into it (live
|
||||
* ops wall). The playhead still does not move; see the header.
|
||||
*/
|
||||
export function TimeScrubber({ calls, incidents = [], selectedIncidentId = null, onSelectIncident }: {
|
||||
calls: CallRecord[];
|
||||
incidents?: IncidentRecord[];
|
||||
selectedIncidentId?: string | null;
|
||||
onSelectIncident?: (id: string) => void;
|
||||
}) {
|
||||
const [rangeIdx, setRangeIdx] = useState(2); // default 24h
|
||||
|
||||
const range = RANGES[rangeIdx];
|
||||
@@ -46,9 +56,38 @@ export function TimeScrubber({ calls }: { calls: CallRecord[] }) {
|
||||
|
||||
const maxCount = Math.max(1, ...buckets.map((b) => b.count));
|
||||
|
||||
const bands = useMemo(() => {
|
||||
const now = Date.now();
|
||||
const start = now - range.ms;
|
||||
return incidents
|
||||
.map((inc) => {
|
||||
const a = Math.max(start, new Date(inc.started_at).getTime());
|
||||
const b = Math.min(now, new Date(inc.updated_at || inc.started_at).getTime());
|
||||
if (Number.isNaN(a) || Number.isNaN(b) || b < start) return null;
|
||||
return { inc, left: ((a - start) / range.ms) * 100, width: Math.max(0.6, ((Math.max(a, b) - a) / range.ms) * 100) };
|
||||
})
|
||||
.filter((x): x is { inc: IncidentRecord; left: number; width: number } => x !== null)
|
||||
.sort((x, y) => severityRank(x.inc.severity) - severityRank(y.inc.severity));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [incidents, rangeIdx]);
|
||||
|
||||
return (
|
||||
<div className="h-[84px] bg-surface/90 border-t border-line px-4 flex items-center gap-4">
|
||||
<div className="flex-1 h-12 flex items-end gap-[2px]">
|
||||
<div className="flex-1 h-12 flex items-end gap-[2px] relative">
|
||||
{bands.length > 0 && (
|
||||
<div className="absolute left-0 right-0 -top-3 h-2" aria-label="Incident bands">
|
||||
{bands.map(({ inc, left, width }) => {
|
||||
const color = isKnownSeverity(inc.severity) ? SEVERITY_COLORS[inc.severity] : "var(--ink-muted)";
|
||||
const sel = inc.incident_id === selectedIncidentId;
|
||||
return (
|
||||
<button key={inc.incident_id} type="button" title={inc.title ?? "Incident"}
|
||||
onClick={() => onSelectIncident?.(inc.incident_id)}
|
||||
className={`absolute top-0 h-2 rounded-sm ${sel ? "ring-1 ring-ink" : ""}`}
|
||||
style={{ left: `${left}%`, width: `${width}%`, background: color, opacity: sel ? 1 : 0.55 }} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{buckets.map((b, i) => {
|
||||
const heightPct = b.count === 0 ? 4 : Math.max(10, (b.count / maxCount) * 100);
|
||||
const color =
|
||||
|
||||
Reference in New Issue
Block a user