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
+238 -24
View File
@@ -1,41 +1,217 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState, type RefObject } from "react";
import type HlsType from "hls.js";
import type { Ny511Camera } from "@/lib/types";
import type { Camera, CameraCredit } from "@/lib/types";
// 511NY stills are served with max-age=60, so refreshing faster buys nothing.
// ── Feed hygiene (design-canvas/gods-eye-view-notes.md §2, top-5 #2) ─────────
// A 24/7 wall must not burn bandwidth on feeds nobody can see:
// * hidden tab or off-screen tile → no fetches, live video torn down;
// * at most MAX_LIVE_HLS live decoders page-wide (GEV uses 2); extra tiles
// show their still with a "live cap" badge (the drill-down gets one extra);
// * a failed refresh keeps the last good frame with a "stale Nm" badge;
// * a 64×36 downsample signature detects a frozen camera (same frame for
// FROZEN_AFTER_MS) without repainting it; a stalled HLS stream falls back.
// Video is played straight from the agency's host (no DRB egress).
// Most stills are cached ~60 s upstream, so refreshing faster buys nothing.
const SNAPSHOT_REFRESH_MS = 60_000;
// A failed stream is not given up on for good: 511NY feeds drop out briefly,
// and a permanent fallback left pinned tiles showing a stale still image.
const VIDEO_RETRY_MS = 30_000;
// In-place recoveries hls.js gets per session before we fall back to the snapshot.
const MAX_HLS_RECOVERIES = 3;
const VIDEO_STALL_MS = 20_000;
const FROZEN_AFTER_MS = 20 * 60_000; // > the slowest provider cadence (Fintraffic ~10 min)
export const MAX_LIVE_HLS = 2;
// ── Page-wide live-stream leases ─────────────────────────────────────────────
const holders = new Set<number>();
const leaseListeners = new Set<() => void>();
let nextLeaseId = 1;
function tryAcquire(id: number, high: boolean): boolean {
if (holders.has(id)) return true;
if (holders.size < MAX_LIVE_HLS + (high ? 1 : 0)) {
holders.add(id);
return true;
}
return false;
}
function release(id: number) {
if (holders.delete(id)) leaseListeners.forEach((l) => l());
}
function useLiveLease(want: boolean, high: boolean): boolean {
const idRef = useRef(0);
if (!idRef.current) idRef.current = nextLeaseId++;
const [granted, setGranted] = useState(false);
useEffect(() => {
const id = idRef.current;
if (!want) { release(id); setGranted(false); return; }
const attempt = () => setGranted(tryAcquire(id, high));
attempt();
leaseListeners.add(attempt);
return () => { leaseListeners.delete(attempt); release(id); };
}, [want, high]);
return granted;
}
// ── Visibility: tab shown AND element on screen ──────────────────────────────
function useOnScreen(ref: RefObject<HTMLElement | null>): boolean {
const [tabVisible, setTabVisible] = useState(true);
const [inView, setInView] = useState(true);
useEffect(() => {
const onVis = () => setTabVisible(!document.hidden);
onVis();
document.addEventListener("visibilitychange", onVis);
return () => document.removeEventListener("visibilitychange", onVis);
}, []);
useEffect(() => {
const el = ref.current;
if (!el || typeof IntersectionObserver === "undefined") return;
const io = new IntersectionObserver((entries) => setInView(entries.some((e) => e.isIntersecting)), { rootMargin: "100px" });
io.observe(el);
return () => io.disconnect();
}, [ref]);
return tabVisible && inView;
}
// Origins whose stills refuse CORS: shown without crossOrigin, no signature.
const noCorsOrigins = new Set<string>();
function originOf(url: string): string {
try { return new URL(url).origin; } catch { return url; }
}
function withBust(url: string, t: number): string {
return `${url}${url.includes("?") ? "&" : "?"}t=${t}`;
}
function signature(img: HTMLImageElement): number[] | null {
try {
const c = document.createElement("canvas");
c.width = 64;
c.height = 36;
const ctx = c.getContext("2d", { willReadFrequently: true });
if (!ctx) return null;
ctx.drawImage(img, 0, 0, 64, 36);
const d = ctx.getImageData(0, 0, 64, 36).data;
const out: number[] = [];
for (let i = 0; i < d.length; i += 4) out.push((d[i] + d[i + 1] + d[i + 2]) / 3);
return out;
} catch {
return null; // tainted canvas
}
}
function sameFrame(a: number[] | null, b: number[] | null): boolean {
if (!a || !b || a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i]);
return diff / a.length < 1.5;
}
function ageLabel(ms: number): string {
const m = Math.floor(ms / 60_000);
return m < 1 ? "<1m" : m < 60 ? `${m}m` : `${Math.floor(m / 60)}h${m % 60 ? ` ${m % 60}m` : ""}`;
}
function clock(t: number): string {
return new Date(t).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
}
interface Props {
camera: Camera;
className?: string;
/** "still" never starts video (popups, thumbnails). */
mode?: "auto" | "still";
/** The drill-down camera may take one stream past the page-wide cap. */
priority?: "normal" | "high";
/** Required attribution for this provider — rendered on the imagery itself. */
credit?: CameraCredit | null;
}
/**
* One 511NY camera: the live HLS stream when the camera has one (NYSDOT's
* skyvdn hosts send Access-Control-Allow-Origin: *), otherwise its snapshot on
* a timer. A stream that fails falls back to the snapshot rather than a black box,
* and video is retried every VIDEO_RETRY_MS while it stays down.
* One public camera: live HLS when the camera has a stream and a lease is
* free, otherwise its still on a timer — with honest state badges and the
* provider's attribution on the frame. A stream that fails or stalls falls
* back to the still and is retried every VIDEO_RETRY_MS.
*/
export function CameraFeed({ camera, className }: { camera: Ny511Camera; className?: string }) {
export function CameraFeed({ camera, className, mode = "auto", priority = "normal", credit }: Props) {
const rootRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const onScreen = useOnScreen(rootRef);
const [videoFailed, setVideoFailed] = useState(false);
const [tick, setTick] = useState(() => Date.now());
const useVideo = !!camera.video_url && !videoFailed;
const wantsVideo = mode === "auto" && !!camera.video_url && !videoFailed && onScreen;
const leased = useLiveLease(wantsVideo, priority === "high");
const useVideo = wantsVideo && leased;
const capped = wantsVideo && !leased;
// Still state
const [shownSrc, setShownSrc] = useState<string | null>(null);
const [corsOk, setCorsOk] = useState(true);
const [lastGoodAt, setLastGoodAt] = useState<number | null>(null);
const [frameSince, setFrameSince] = useState<number | null>(null); // when the shown frame first appeared
const [stale, setStale] = useState(false);
const [now, setNow] = useState(() => Date.now());
const sigRef = useRef<number[] | null>(null);
// Reset when the camera changes
useEffect(() => {
setShownSrc(null); setLastGoodAt(null); setFrameSince(null); setStale(false); setVideoFailed(false);
sigRef.current = null;
}, [camera.id]);
useEffect(() => {
if (useVideo) return;
const id = setInterval(() => setTick(Date.now()), SNAPSHOT_REFRESH_MS);
const id = setInterval(() => { if (!document.hidden) setNow(Date.now()); }, 15_000);
return () => clearInterval(id);
}, [useVideo]);
}, []);
// Still polling — only while on screen and not playing video
useEffect(() => {
const url = camera.image_url;
if (useVideo || !onScreen || !url) return;
let cancelled = false;
let probe: HTMLImageElement | null = null;
const load = (allowCors: boolean) => {
const origin = originOf(url);
const cors = allowCors && !noCorsOrigins.has(origin);
const src = withBust(url, Date.now());
probe = new Image();
if (cors) probe.crossOrigin = "anonymous";
probe.onload = () => {
if (cancelled || !probe) return;
const t = Date.now();
const sig = cors ? signature(probe) : null;
const unchanged = sameFrame(sig, sigRef.current);
sigRef.current = sig;
setStale(false);
setLastGoodAt(t);
setCorsOk(cors);
if (!unchanged) {
setShownSrc(src);
setFrameSince(t);
}
};
probe.onerror = () => {
if (cancelled) return;
if (cors) { noCorsOrigins.add(origin); load(false); return; }
setStale(true); // keep the last good frame on screen
};
probe.src = src;
};
load(true);
const id = setInterval(() => load(true), SNAPSHOT_REFRESH_MS);
return () => {
cancelled = true;
clearInterval(id);
if (probe) { probe.onload = null; probe.onerror = null; probe.src = ""; } // abort in-flight
};
}, [camera.image_url, useVideo, onScreen]);
// Retry a failed stream later
useEffect(() => {
if (!videoFailed || !camera.video_url) return;
const id = setTimeout(() => setVideoFailed(false), VIDEO_RETRY_MS);
return () => clearTimeout(id);
}, [videoFailed, camera.video_url]);
// HLS player + stall watchdog
useEffect(() => {
const video = videoRef.current;
const src = camera.video_url;
@@ -65,20 +241,58 @@ export function CameraFeed({ camera, className }: { camera: Ny511Camera; classNa
})
.catch(() => setVideoFailed(true));
}
let lastT = -1;
let lastMove = Date.now();
const watchdog = setInterval(() => {
if (video.currentTime !== lastT) { lastT = video.currentTime; lastMove = Date.now(); return; }
if (Date.now() - lastMove > VIDEO_STALL_MS) setVideoFailed(true); // frozen stream → still
}, 5_000);
return () => {
cancelled = true;
clearInterval(watchdog);
hls?.destroy();
video.removeAttribute("src");
video.load(); // stop the download, not just the playback
};
}, [useVideo, camera.video_url]);
if (useVideo) {
return <video ref={videoRef} muted autoPlay playsInline className={className} onError={() => setVideoFailed(true)} />;
}
if (!camera.image_url) {
return <div className={`${className ?? ""} flex items-center justify-center text-[10px] text-ink-muted`}>No image</div>;
}
// eslint-disable-next-line @next/next/no-img-element
return <img src={`${camera.image_url}?t=${tick}`} alt={`Camera: ${camera.name}`} className={className} />;
// ── Badge ──
let badge: { text: string; tone: "live" | "ok" | "warn" | "err" };
if (useVideo) badge = { text: "LIVE", tone: "live" };
else if (!camera.image_url) badge = { text: capped ? "LIVE CAP · STREAM PAUSED" : camera.video_url ? "STREAM DOWN" : "NO IMAGE", tone: "err" };
else if (!shownSrc) badge = { text: stale ? "FRAME · UNAVAILABLE" : "FRAME · LOADING", tone: stale ? "err" : "warn" };
else if (stale && lastGoodAt) badge = { text: `STALE · ${ageLabel(now - lastGoodAt)}`, tone: "warn" };
else if (frameSince && now - frameSince > FROZEN_AFTER_MS) badge = { text: `FROZEN? · unchanged ${ageLabel(now - frameSince)}`, tone: "warn" };
else badge = { text: `STILL · ${frameSince ? clock(frameSince) : "—"}${capped ? " · LIVE CAP" : ""}`, tone: "ok" };
const toneClass = {
live: "bg-red-600/90 text-white",
ok: "bg-black/65 text-white",
warn: "bg-amber-500/90 text-black",
err: "bg-black/75 text-red-300",
}[badge.tone];
return (
<div ref={rootRef} className={`relative overflow-hidden ${className ?? ""}`}>
{useVideo ? (
<video ref={videoRef} muted autoPlay playsInline className="w-full h-full object-cover" onError={() => setVideoFailed(true)} />
) : shownSrc ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={shownSrc} crossOrigin={corsOk ? "anonymous" : undefined} alt={`Camera: ${camera.name}`} className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center text-[10px] text-ink-muted bg-black/60">
{camera.image_url ? "…" : "No still for this camera"}
</div>
)}
<span className={`absolute top-1 left-1 px-1 rounded-sm font-mono text-[9px] leading-[14px] tracking-wide ${toneClass}`} title="Frame time is when DRB first saw this frame">
{badge.text}
</span>
{credit?.attribution && (
<span className="absolute bottom-0 left-0 right-0 px-1 bg-black/60 text-white text-[9px] leading-[13px] truncate">
{credit.attribution_url ? (
<a href={credit.attribution_url} target="_blank" rel="noopener noreferrer" className="hover:underline">{credit.attribution}</a>
) : credit.attribution}
</span>
)}
</div>
);
}
+312 -36
View File
@@ -2,18 +2,39 @@
/**
* Live — the default landing for every authenticated user (UI_REDESIGN.md
* §3, §5.1). Full-bleed map with the incident rail/legend MapView already
* renders (chunk 5), plus a time-density scrubber strip below it.
* §3, §5.1), now the live ops wall (design-canvas/live-ops-wall-mockup.html):
* Region → Incident → Camera drill-down, worst-first incident rail (MapView),
* KPIs, transcript ticker, scrubber with incident bands, auto-tour and deep
* links (#inc=…&cam=…&tour). Every MapView layer stays — nodes, incidents,
* paths, ADS-B, AIS, cameras, viewsheds, traffic events, NEXRAD.
*/
import { useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { useNodes } from "@/lib/useNodes";
import { useActiveCalls, useCalls } from "@/lib/useCalls";
import { useActiveIncidents } from "@/lib/useIncidents";
import { useAircraft } from "@/lib/useAircraft";
import { useVessels } from "@/lib/useVessels";
import { useCamerasInBbox } from "@/lib/useCameras";
import { c2api } from "@/lib/c2api";
import { severityRank } from "@/lib/severity";
import type { Camera, Ny511Response } from "@/lib/types";
import {
NEAR_CAMERA_KM, bboxAround, groupCallsByIncident, incidentPoints, km, nearestCameras, onAirIncidentIds,
parseWallHash, wallHash, worstFirst, type LatLon, type NearCamera, type WallLink,
} from "@/lib/liveWall";
import { TimeScrubber } from "@/components/TimeScrubber";
import { TranscriptTicker } from "@/components/live/TranscriptTicker";
import { CameraDrill } from "@/components/live/CameraDrill";
import { IncidentPanel, RegionPanel } from "@/components/live/WallPanels";
const MapView = dynamic(() => import("@/components/MapView"), { ssr: false });
const TOUR_MS = 20_000;
const PIN_KEY = "drb.wall.pinnedCameras";
const MAX_PINNED = 6;
const EVENTS_POLL_MS = 2 * 60 * 1000;
function timeAgo(date: Date): string {
const s = Math.floor((Date.now() - date.getTime()) / 1000);
if (s < 60) return `${s}s ago`;
@@ -21,59 +42,314 @@ function timeAgo(date: Date): string {
return `${Math.floor(s / 3600)}h ago`;
}
/** Per-viewer convenience only; the wall works without it. */
function loadPinned(): Camera[] {
try {
const raw = window.localStorage.getItem(PIN_KEY);
const v = raw ? JSON.parse(raw) : [];
return Array.isArray(v) ? v.filter((c) => c && typeof c.id === "string").slice(0, MAX_PINNED) : [];
} catch {
return [];
}
}
function savePinned(cams: Camera[]) {
try { window.localStorage.setItem(PIN_KEY, JSON.stringify(cams)); } catch { /* storage blocked */ }
}
/** 511NY road events around the region (situational context + KPI). */
function useRegionEvents(box: ReturnType<typeof bboxAround>) {
const [data, setData] = useState<Ny511Response>({});
const key = box ? [box.south, box.west, box.north, box.east].map((n) => n.toFixed(2)).join(",") : "";
useEffect(() => {
if (!box) return;
const b = box;
let cancelled = false;
const load = () => {
if (document.hidden) return;
c2api.get511(b, "events").then((r) => { if (!cancelled) setData(r); }).catch((e) => console.error("region events:", e));
};
load();
const id = setInterval(load, EVENTS_POLL_MS);
return () => { cancelled = true; clearInterval(id); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
return data;
}
function Kpi({ value, label }: { value: string | number; label: string }) {
return (
<span className="inline-flex items-baseline gap-1 text-xs text-ink-muted whitespace-nowrap">
<b className="text-ink font-semibold tabular-nums">{value}</b>{label}
</span>
);
}
export function LiveView() {
const { nodes, loading: nodesLoading } = useNodes();
const activeCalls = useActiveCalls();
const activeIncidents = useActiveIncidents();
// Wide-ish recent window: feeds both the incident-path polyline (chunk 5)
// and the scrubber's density bars. Not a fixture — real recent calls.
// Wide-ish recent window: feeds incident paths, the call spine, the ticker
// and the scrubber's density bars. Real recent calls, not a fixture.
const { calls: recentCalls, loading: callsLoading } = useCalls(500);
const { aircraft } = useAircraft();
const { vessels } = useVessels();
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [clock, setClock] = useState<string>("");
useEffect(() => {
if (!nodesLoading) setLastUpdated(new Date());
}, [nodes, activeCalls, activeIncidents, nodesLoading]);
useEffect(() => {
const tick = () => setClock(new Date().toLocaleTimeString([], { hour12: false }));
tick();
const id = setInterval(tick, 1000);
return () => clearInterval(id);
}, []);
// ── Wall state, mirrored in the URL hash ──────────────────────────────────
const [link, setLinkState] = useState<WallLink>({ inc: null, cam: null, tour: false });
useEffect(() => {
const read = () => setLinkState(parseWallHash(window.location.hash));
read();
window.addEventListener("hashchange", read);
return () => window.removeEventListener("hashchange", read);
}, []);
const setLink = useCallback((next: WallLink) => {
setLinkState(next);
const h = wallHash(next);
window.history.replaceState(null, "", h || window.location.pathname + window.location.search);
}, []);
const [pinned, setPinned] = useState<Camera[]>([]);
useEffect(() => { setPinned(loadPinned()); }, []);
const pinnedIds = useMemo(() => new Set(pinned.map((c) => c.id)), [pinned]);
const togglePin = useCallback((cam: Camera) => {
setPinned((prev) => {
const next = prev.some((c) => c.id === cam.id) ? prev.filter((c) => c.id !== cam.id) : [...prev, cam].slice(-MAX_PINNED);
savePinned(next);
return next;
});
}, []);
// ── Geography ─────────────────────────────────────────────────────────────
const callsByIncident = useMemo(() => groupCallsByIncident(recentCalls), [recentCalls]);
const sorted = useMemo(() => worstFirst(activeIncidents), [activeIncidents]);
const pointsById = useMemo(() => {
const m = new Map<string, LatLon[]>();
for (const inc of activeIncidents) m.set(inc.incident_id, incidentPoints(inc, callsByIncident.get(inc.incident_id)));
return m;
}, [activeIncidents, callsByIncident]);
const regionBox = useMemo(() => {
const pts = Array.from(pointsById.values()).flat();
return bboxAround(pts.length ? pts : nodes.map((n) => [n.lat, n.lon] as LatLon), NEAR_CAMERA_KM + 0.5);
}, [pointsById, nodes]);
const { data: cams, loaded: camsLoaded } = useCamerasInBbox(regionBox);
const feeds = useMemo(() => new Map(cams.providers.map((p) => [p.id, p])), [cams.providers]);
const nearById = useMemo(() => {
const m = new Map<string, NearCamera[]>();
pointsById.forEach((pts, id) => m.set(id, nearestCameras(pts, cams.cameras)));
return m;
}, [pointsById, cams.cameras]);
const camCountByIncident = useMemo(() => new Map(Array.from(nearById.entries()).map(([id, n]) => [id, n.length])), [nearById]);
const events = useRegionEvents(regionBox);
const onAir = useMemo(() => onAirIncidentIds(activeIncidents, activeCalls), [activeIncidents, activeCalls]);
const selected = link.inc ? activeIncidents.find((i) => i.incident_id === link.inc) ?? null : null;
const selectedPoints = selected ? pointsById.get(selected.incident_id) ?? [] : [];
const near = selected ? nearById.get(selected.incident_id) ?? [] : [];
const drillCam: Camera | null = link.cam
? cams.cameras.find((c) => c.id === link.cam) ?? pinned.find((c) => c.id === link.cam) ?? null
: null;
// Region wall: pinned first, then the nearest camera to each major/moderate incident.
const wallCameras = useMemo(() => {
const out: { cam: Camera; km?: number }[] = pinned.map((cam) => ({ cam }));
for (const inc of sorted) {
if (severityRank(inc.severity) < 2) continue;
const first = nearById.get(inc.incident_id)?.[0];
if (first && !out.some((o) => o.cam.id === first.cam.id)) out.push({ cam: first.cam, km: first.km });
}
return out.slice(0, 6);
}, [pinned, sorted, nearById]);
// ── Navigation ────────────────────────────────────────────────────────────
const selectIncident = useCallback((id: string) => setLink({ inc: id, cam: null, tour: false }), [setLink]);
const openCamera = useCallback((c: Camera) => setLink({ ...link, cam: c.id, tour: false }), [link, setLink]);
const stepUp = useCallback(() => {
if (link.cam) setLink({ ...link, cam: null, tour: false });
else if (link.inc) setLink({ inc: null, cam: null, tour: false });
else if (link.tour) setLink({ ...link, tour: false });
}, [link, setLink]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") stepUp(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [stepUp]);
// ── Auto-tour: worst-first through major/moderate (else all plotted) ──────
const tourList = useMemo(() => {
const plotted = sorted.filter((i) => (pointsById.get(i.incident_id) ?? []).length);
const serious = plotted.filter((i) => severityRank(i.severity) >= 2);
return (serious.length ? serious : plotted).map((i) => i.incident_id);
}, [sorted, pointsById]);
const [tourStart, setTourStart] = useState(0);
const [tourNow, setTourNow] = useState(0);
useEffect(() => {
if (!link.tour || !tourList.length) return;
const next = () => {
setLinkState((cur) => {
const i = cur.inc ? tourList.indexOf(cur.inc) : -1;
const nxt: WallLink = { inc: tourList[(i + 1) % tourList.length], cam: null, tour: true };
window.history.replaceState(null, "", wallHash(nxt));
return nxt;
});
setTourStart(Date.now());
};
if (!link.inc || !tourList.includes(link.inc)) next();
else setTourStart(Date.now());
const id = setInterval(next, TOUR_MS);
const prog = setInterval(() => setTourNow(Date.now()), 500);
return () => { clearInterval(id); clearInterval(prog); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [link.tour, tourList.join(",")]);
const tourPct = link.tour && tourStart ? Math.min(100, ((tourNow - tourStart) / TOUR_MS) * 100) : 0;
// ── KPIs ──────────────────────────────────────────────────────────────────
const count = (sev: string) => activeIncidents.filter((i) => i.severity === sev).length;
const nodesUp = nodes.filter((n) => n.status !== "offline").length;
const eventsOff = !!events.events_status?.disabled;
const configuredButQuiet = !nodesLoading && nodes.length > 0 && activeIncidents.length === 0;
const mostRecentSeen = configuredButQuiet
? nodes
.map((n) => n.last_seen)
.filter((s): s is string => !!s)
.sort()
.at(-1)
? nodes.map((n) => n.last_seen).filter((s): s is string => !!s).sort().at(-1)
: null;
const crumb = (label: string, active: boolean, enabled: boolean, onClick: () => void) => (
<button type="button" disabled={!enabled} onClick={onClick} aria-current={active ? "true" : undefined}
className={`px-1.5 py-0.5 rounded ${active ? "text-ink font-semibold" : enabled ? "text-ink-2 hover:text-ink" : "text-ink-muted"}`}>
{label}
</button>
);
return (
<div className="flex flex-col h-full">
<div className="relative flex-1 min-h-0">
{nodesLoading || callsLoading ? (
<div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">
Loading map…
</div>
) : (
<MapView
nodes={nodes}
activeCalls={activeCalls}
incidents={activeIncidents}
calls={recentCalls}
lastUpdated={lastUpdated}
/>
)}
{/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's
Activation screen handles the zero-node case). A node that's
online but has heard nothing is NOT the same empty state as an
org with no equipment. */}
{configuredButQuiet && (
<div className="absolute top-12 left-1/2 -translate-x-1/2 z-[1001] pointer-events-none">
<span className="bg-surface/90 border border-line rounded-full px-3 py-1 text-xs text-ink-2 whitespace-nowrap">
● Listening{mostRecentSeen ? ` — last check-in ${timeAgo(new Date(mostRecentSeen))}` : ""}
{/* ── Wall bar: drill crumbs · KPIs · auto-tour · clock ── */}
<div className="shrink-0 flex flex-wrap items-center gap-x-4 gap-y-1 px-3 py-1.5 border-b border-line bg-surface/95 text-xs">
<nav aria-label="Drill level" className="flex items-center gap-0.5">
{crumb("Region", !link.inc, true, () => setLink({ inc: null, cam: null, tour: false }))}
<span className="text-ink-muted">›</span>
{crumb(selected?.title ? `Incident: ${selected.title.slice(0, 32)}` : "Incident", !!link.inc && !link.cam, !!link.inc,
() => setLink({ ...link, cam: null, tour: false }))}
<span className="text-ink-muted">›</span>
{crumb("Camera", !!link.cam, !!link.cam, () => undefined)}
</nav>
<div className="hidden md:flex items-center gap-3 flex-wrap">
<Kpi value={count("major")} label="major" />
<Kpi value={count("moderate")} label="moderate" />
<Kpi value={activeIncidents.length} label="active" />
<Kpi value={`${nodesUp}/${nodes.length}`} label="nodes" />
<Kpi value={cams.cameras.length} label="cams" />
<Kpi value={aircraft.length} label="aircraft" />
<Kpi value={vessels.length} label="vessels" />
<Kpi value={eventsOff ? "—" : events.events?.length ?? 0} label="road events" />
</div>
<div className="ml-auto flex items-center gap-3">
<button type="button" aria-pressed={link.tour} onClick={() => setLink({ ...link, cam: null, tour: !link.tour })}
title="Wall mode: cycle through active incidents, worst first"
className={`px-2 py-0.5 rounded border ${link.tour ? "bg-accent text-white border-accent" : "border-line text-ink-2 hover:text-ink"}`}>
Auto-tour
</button>
{link.tour && (
<span className="w-20 h-1 bg-raised rounded overflow-hidden" aria-hidden="true">
<span className="block h-full bg-accent" style={{ width: `${tourPct}%` }} />
</span>
</div>
)}
)}
<span className="font-mono text-ink-2 tabular-nums">{clock}</span>
</div>
</div>
<TimeScrubber calls={recentCalls} />
<div className="flex-1 min-h-0 flex flex-col lg:flex-row">
<div className="relative h-[55vh] lg:h-auto lg:flex-1 min-w-0 shrink-0">
{nodesLoading || callsLoading ? (
<div className="w-full h-full flex items-center justify-center text-ink-muted text-sm">Loading map…</div>
) : (
<MapView
nodes={nodes}
activeCalls={activeCalls}
incidents={activeIncidents}
calls={recentCalls}
lastUpdated={lastUpdated}
selectedIncidentId={selected?.incident_id ?? null}
onSelectIncident={selectIncident}
nearCameras={near}
onOpenCamera={openCamera}
onAir={onAir}
camCountByIncident={camCountByIncident}
/>
)}
{/* Configured-and-quiet — distinct from "no nodes at all" (chunk 10's
Activation screen handles the zero-node case). */}
{configuredButQuiet && (
<div className="absolute top-12 left-1/2 -translate-x-1/2 z-[1001] pointer-events-none">
<span className="bg-surface/90 border border-line rounded-full px-3 py-1 text-xs text-ink-2 whitespace-nowrap">
● Listening{mostRecentSeen ? ` — last check-in ${timeAgo(new Date(mostRecentSeen))}` : ""}
</span>
</div>
)}
</div>
<aside className="lg:w-[360px] lg:shrink-0 flex-1 lg:flex-none min-h-0 overflow-y-auto border-t lg:border-t-0 lg:border-l border-line bg-surface"
aria-live="polite" aria-label={selected ? "Incident detail" : "Region overview"}>
{selected ? (
<IncidentPanel
inc={selected}
calls={callsByIncident.get(selected.incident_id) ?? []}
points={selectedPoints}
near={near}
feeds={feeds}
servingCount={camsLoaded ? cams.serving_count : 1}
onAir={onAir.has(selected.incident_id)}
aircraft={aircraft}
vessels={vessels}
events={events.events ?? []}
eventsCredit={{ credit: events.credit, fetched_at: events.events_status?.fetched_at ?? null }}
onOpenCamera={openCamera}
/>
) : (
<RegionPanel
incidents={activeIncidents}
calls={recentCalls}
onAirCount={onAir.size}
nodes={nodes}
wallCameras={wallCameras}
feeds={feeds}
servingCount={camsLoaded ? cams.serving_count : 1}
aircraftCount={aircraft.length}
vesselCount={vessels.length}
events={events.events ?? []}
eventsOff={eventsOff}
pinnedIds={pinnedIds}
onOpenCamera={openCamera}
/>
)}
</aside>
</div>
<TranscriptTicker calls={recentCalls} onSelectIncident={selectIncident} />
<TimeScrubber calls={recentCalls} incidents={activeIncidents} selectedIncidentId={selected?.incident_id ?? null} onSelectIncident={selectIncident} />
{drillCam && (
<CameraDrill
camera={drillCam}
feed={feeds.get(drillCam.provider)}
distanceKm={selectedPoints.length ? Math.min(...selectedPoints.map((p) => km(p, [drillCam.lat, drillCam.lon]))) : null}
incidentTitle={selected?.title}
pinned={pinnedIds.has(drillCam.id)}
onTogglePin={() => togglePin(drillCam)}
onClose={() => setLink({ ...link, cam: null })}
/>
)}
</div>
);
}
+205 -53
View File
@@ -6,7 +6,9 @@ import {
FeatureGroup,
LayersControl,
MapContainer,
Circle,
Marker,
Polygon,
Polyline,
Popup,
TileLayer,
@@ -22,8 +24,11 @@ import { useAircraft } from "@/lib/useAircraft";
import { useAircraftTrail } from "@/lib/useAircraftTrail";
import { useVessels } from "@/lib/useVessels";
import { use511 } from "@/lib/use511";
import type { Ny511Camera, Ny511Event, Ny511FeedStatus } from "@/lib/types";
import { useMapCameras } from "@/lib/useCameras";
import type { Camera, CameraCredit, CameraProviderFeed, CamerasResponse, Ny511Event, Ny511FeedStatus } from "@/lib/types";
import { CameraFeed } from "@/components/CameraFeed";
import { SourceCredit, fetchedLabel } from "@/components/SourceCredit";
import { NEAR_CAMERA_KM, groupCallsByIncident, incidentPoints, viewshedWedge, worstFirst } from "@/lib/liveWall";
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
@@ -341,6 +346,7 @@ function RadarLayer() {
// Overlay names double as the event keys for overlayadd/overlayremove.
const OVERLAY_DOT_CAMERAS = "DOT Cameras";
const OVERLAY_TRAFFIC_EVENTS = "Traffic Events";
const OVERLAY_VIEWSHEDS = "Camera Viewsheds (approx.)";
/** True while the named LayersControl overlay is checked. Overlays start unchecked. */
function useOverlayShown(map: L.Map, name: string): boolean {
@@ -450,6 +456,7 @@ function fmtLocal(iso: string | null): string | null {
function FeedProblem({ map, label, status, fetchError }: { map: L.Map; label: string; status?: Ny511FeedStatus; fetchError: string | null }) {
let msg: string | null = null;
if (fetchError) msg = `${label}: could not reach server`;
else if (status?.disabled) msg = `${label}: 511NY feed is off — an admin enables it under Admin → Camera providers`;
else if (status?.error) {
const age = status.fetched_at ? `showing data from ${Math.round((Date.now() / 1000 - status.fetched_at) / 60)} min ago` : "no data yet";
msg = `${label}: 511NY feed error, ${age}`;
@@ -461,19 +468,66 @@ function FeedProblem({ map, label, status, fetchError }: { map: L.Map; label: st
);
}
/** Provider id → what must render with its imagery. */
function creditsByProvider(providers: CameraProviderFeed[]): Map<string, CameraProviderFeed> {
return new Map(providers.map((p) => [p.id, p]));
}
/** Same honesty as FeedProblem, for the multi-provider camera layer. */
function CameraProblem({ map, data, loaded, fetchError }: { map: L.Map; data: CamerasResponse; loaded: boolean; fetchError: string | null }) {
let msg: string | null = null;
if (fetchError) msg = "Cameras: could not reach server";
else if (loaded && data.serving_count === 0) msg = "Cameras: no camera provider is enabled — Admin → Camera providers";
else {
const bad = data.providers.find((p) => p.error);
const cut = data.providers.find((p) => p.truncated);
if (bad) msg = `${bad.name}: feed error, ${bad.fetched_at ? `showing data from ${fetchedLabel(bad.fetched_at)}` : "no data yet"}`;
else if (cut) msg = `Cameras: too many in view — zoom in`;
}
if (!msg) return null;
return createPortal(
<div className="absolute bottom-8 left-3 z-[1001] bg-surface/90 border border-line rounded px-2 py-1 text-xs text-ink-2 pointer-events-none">{msg}</div>,
map.getContainer(),
);
}
/** Popup body for one camera: still, credit, catalog freshness, actions. */
function CameraPopupBody({ c, feed, onPin, onDock, onOpen }: {
c: Camera; feed?: CameraProviderFeed; onPin: () => void; onDock: () => void; onOpen?: () => void;
}) {
return (
<div className="space-y-1">
<div className="font-semibold">{c.name}</div>
{c.roadway && <div className="text-xs text-ink-muted">{c.roadway}{c.direction && c.direction !== "Unknown" ? ` · ${c.direction}` : ""}</div>}
{/* Popup content mounts on open, so the still is fetched per open. */}
<button type="button" onClick={onOpen ?? onPin} title="Open this camera" className="block w-full p-0 border-0 bg-transparent cursor-pointer">
<CameraFeed camera={c} mode="still" credit={feed} className="w-full h-[150px] rounded border border-line bg-black" />
</button>
<div className="flex gap-1.5 pt-0.5">
{onOpen && <button type="button" onClick={onOpen} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Open</button>}
<button type="button" onClick={onPin} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Pin to map</button>
<button type="button" onClick={onDock} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Dock</button>
</div>
<div className="text-[10px] text-ink-muted">{c.video_url ? "Live feed available" : "Snapshot"}</div>
<SourceCredit credit={feed} fallbackName={feed?.name} updated={feed ? `camera list ${fetchedLabel(feed.fetched_at) ?? "—"}` : null} />
</div>
);
}
// Open cameras: each is either pinned (a small tile anchored at the camera, so
// several views along one road read against the map) or docked (a larger tile
// in a strip over the map, which stays put while you pan and survives the
// overlay being switched off). Oldest camera drops past the cap.
const MAX_OPEN_CAMERAS = 6;
type CameraMode = "pin" | "dock";
interface OpenCamera { cam: Ny511Camera; mode: CameraMode }
interface OpenCamera { cam: Camera; mode: CameraMode }
const camBtn = "text-ink-muted hover:text-ink text-xs leading-none px-0.5";
function CameraDock({ map, open, onMode, onClose }: {
function CameraDock({ map, open, credits, onMode, onClose }: {
map: L.Map;
open: Ny511Camera[];
open: Camera[];
credits: Map<string, CameraCredit>;
onMode: (id: string, mode: CameraMode) => void;
onClose: (id: string) => void;
}) {
@@ -497,7 +551,7 @@ function CameraDock({ map, open, onMode, onClose }: {
<button type="button" onClick={() => onMode(c.id, "pin")} title="Pin to the map at the camera" aria-label={`Pin ${c.name} to the map`} className={camBtn}>⌖</button>
<button type="button" onClick={() => onClose(c.id)} aria-label={`Close ${c.name}`} className={camBtn}>×</button>
</div>
<CameraFeed camera={c} className="block w-[240px] h-[135px] object-cover bg-black rounded-b-md" />
<CameraFeed camera={c} credit={credits.get(c.provider)} className="block w-[240px] h-[135px] bg-black rounded-b-md" />
</div>
))}
</div>,
@@ -505,20 +559,21 @@ function CameraDock({ map, open, onMode, onClose }: {
);
}
function DotCameraLayer() {
function DotCameraLayer({ onOpenCamera }: { onOpenCamera?: (c: Camera) => void }) {
const map = useMap();
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
const zoom = useMapZoom(map);
const { data, error } = use511(map, "cameras", shown);
const { data, error, loaded } = useMapCameras(map, shown);
const [open, setOpen] = useState<OpenCamera[]>([]);
const feeds = useMemo(() => creditsByProvider(data.providers), [data.providers]);
const pinnedIds = useMemo(() => new Set(open.filter((o) => o.mode === "pin").map((o) => o.cam.id)), [open]);
const cells = useMemo(
() => lodCells((data.cameras ?? []).filter((c) => !pinnedIds.has(c.id)), map, zoom),
() => lodCells(data.cameras.filter((c) => !pinnedIds.has(c.id)), map, zoom),
[data.cameras, pinnedIds, map, zoom],
);
const show = (cam: Ny511Camera, mode: CameraMode) => {
const show = (cam: Camera, mode: CameraMode) => {
map.closePopup();
setOpen((prev) => [...prev.filter((o) => o.cam.id !== cam.id), { cam, mode }].slice(-MAX_OPEN_CAMERAS));
};
@@ -528,7 +583,7 @@ function DotCameraLayer() {
return (
<>
{shown && <FeedProblem map={map} label="DOT cameras" status={data.cameras_status} fetchError={error} />}
{shown && <CameraProblem map={map} data={data} loaded={loaded} fetchError={error} />}
{cells.map(({ rep: c, count }) => count > 1 ? (
<Marker key={`lod-${c.id}`} position={[c.lat, c.lon]} icon={cameraIcon(count)}
title={`${count} cameras — click to zoom in`}
@@ -536,24 +591,8 @@ function DotCameraLayer() {
) : (
<Marker key={c.id} position={[c.lat, c.lon]} icon={cameraIcon()}>
<Popup minWidth={260} maxWidth={340}>
<div className="space-y-1">
<div className="font-semibold">{c.name}</div>
{c.roadway && <div className="text-xs text-ink-muted">{c.roadway}{c.direction && c.direction !== "Unknown" ? ` · ${c.direction}` : ""}</div>}
{c.image_url && (
// Popup content mounts on open, so the timestamp busts the cache per open.
<button type="button" onClick={() => show(c, "pin")} title="Pin this camera to the map" className="block w-full p-0 border-0 bg-transparent cursor-pointer">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={`${c.image_url}?t=${Date.now()}`} alt={`Camera: ${c.name}`} className="w-full rounded border border-line" loading="lazy" />
</button>
)}
<div className="flex gap-1.5 pt-0.5">
<button type="button" onClick={() => show(c, "pin")} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Pin to map</button>
<button type="button" onClick={() => show(c, "dock")} className="flex-1 text-xs border border-line rounded px-2 py-1 hover:bg-raised">Dock</button>
</div>
<div className="text-[10px] text-ink-muted">
{c.video_url ? "Live feed" : "Snapshot"} · 511NY / NYSDOT
</div>
</div>
<CameraPopupBody c={c} feed={feeds.get(c.provider)} onPin={() => show(c, "pin")} onDock={() => show(c, "dock")}
onOpen={onOpenCamera ? () => { map.closePopup(); onOpenCamera(c); } : undefined} />
</Popup>
</Marker>
))}
@@ -567,12 +606,70 @@ function DotCameraLayer() {
<button type="button" onClick={() => setMode(c.id, "dock")} title="Dock" aria-label={`Dock ${c.name}`} className={camBtn}>⇲</button>
<button type="button" onClick={() => close(c.id)} aria-label={`Close ${c.name}`} className={camBtn}>×</button>
</div>
<CameraFeed camera={c} className="block w-[192px] h-[108px] object-cover bg-black rounded-b" />
<CameraFeed camera={c} credit={feeds.get(c.provider)} className="block w-[192px] h-[108px] bg-black rounded-b" />
</div>
</Tooltip>
</Marker>
))}
<CameraDock map={map} open={open.filter((o) => o.mode === "dock").map((o) => o.cam)} onMode={setMode} onClose={close} />
<CameraDock map={map} open={open.filter((o) => o.mode === "dock").map((o) => o.cam)} credits={feeds} onMode={setMode} onClose={close} />
</>
);
}
// ── Camera viewsheds — GEV idea #4, coarse 2D cones, APPROXIMATE ─────────────
// Built from the published direction of travel, not a surveyed heading/FOV.
function CameraViewshedLayer({ highlightIds }: { highlightIds: Set<string> }) {
const map = useMap();
const shown = useOverlayShown(map, OVERLAY_VIEWSHEDS);
const zoom = useMapZoom(map);
const { data } = useMapCameras(map, shown && zoom >= 11);
if (!shown) return null;
return (
<>
{data.cameras.map((c) => {
const wedge = viewshedWedge(c);
if (!wedge) return null;
const hot = highlightIds.has(c.id);
return (
<Polygon key={`vs-${c.id}`} positions={wedge}
pathOptions={{ color: hot ? "#3987E5" : "#A5B0C2", weight: hot ? 1 : 0.5, opacity: hot ? 0.8 : 0.4, fillOpacity: hot ? 0.18 : 0.06 }}>
<Tooltip sticky>{c.name} — approximate view (from the road&apos;s direction of travel, not a surveyed heading)</Tooltip>
</Polygon>
);
})}
</>
);
}
// ── Nearest cameras to the selected incident (live ops wall) ─────────────────
// Always drawn while an incident is selected, independent of the DOT Cameras
// overlay, so the wall never hides the cameras it is recommending.
function nearCameraIcon(): L.DivIcon {
return L.divIcon({
className: "",
html: `<div style="width:20px;height:16px;border-radius:4px;background:#0b1f3a;border:2px solid #3987E5;display:flex;align-items:center;justify-content:center;box-shadow:0 0 0 3px rgba(57,135,229,.25)"><svg width="12" height="9" viewBox="0 0 16 12"><rect x="0.5" y="1.5" width="11" height="9" rx="2" fill="none" stroke="#8DB9F0" stroke-width="1.5"/><polygon points="12,4 15.5,2 15.5,10 12,8" fill="#8DB9F0"/></svg></div>`,
iconSize: [20, 16],
iconAnchor: [10, 8],
});
}
function NearCameraLayer({ cameras, head, onOpenCamera }: {
cameras: { cam: Camera; km: number }[];
head: [number, number] | null;
onOpenCamera?: (c: Camera) => void;
}) {
return (
<>
{head && (
<Circle center={head} radius={NEAR_CAMERA_KM * 1000} interactive={false}
pathOptions={{ color: "#3987E5", weight: 1, opacity: 0.6, fill: false, dashArray: "2 5" }} />
)}
{cameras.map(({ cam, km: d }) => (
<Marker key={`near-${cam.id}`} position={[cam.lat, cam.lon]} icon={nearCameraIcon()} zIndexOffset={600}
eventHandlers={{ click: () => onOpenCamera?.(cam) }}>
<Tooltip direction="right" offset={[10, 0]}>{cam.name} · {d.toFixed(1)} km</Tooltip>
</Marker>
))}
</>
);
}
@@ -605,8 +702,10 @@ function TrafficEventLayer() {
<div className="font-semibold">{st.label}{e.subtype ? `: ${e.subtype}` : ""}</div>
<div className="text-xs text-ink-muted">{[e.roadway, e.direction, e.county].filter(Boolean).join(" · ")}</div>
{(start || end) && <div className="text-xs">{start ? `From ${start}` : ""}{end ? ` until ${end}` : ""}</div>}
{/* DAA: shown exactly as NYSDOT published it — never summarised or rewritten. */}
<div className="text-xs whitespace-pre-line">{e.description}</div>
<div className="text-[10px] text-ink-muted">511NY{e.updated_local ? ` · updated ${fmtLocal(e.updated_local)}` : ""}</div>
<SourceCredit credit={data.credit} fallbackName="511NY / NYSDOT"
updated={fmtLocal(e.updated_local) ?? fetchedLabel(data.events_status?.fetched_at)} />
</div>
</Popup>
</Marker>
@@ -816,9 +915,11 @@ function FanNodeLayer({
function FanIncidentLayer({
incidents,
onSelect,
selectedId,
}: {
incidents: IncidentRecord[];
onSelect: (inc: IncidentRecord) => void;
selectedId?: string | null;
}) {
const map = useMap();
const [tick, setTick] = useState(0);
@@ -863,6 +964,8 @@ function FanIncidentLayer({
key={repId}
position={[repPlot.lat, repPlot.lng]}
icon={members.length > 1 ? incidentFanIcon(members) : incidentIcon(repPlot.inc.type, repPlot.inc.severity)}
opacity={selectedId && !members.some((m) => m.incident_id === selectedId) ? 0.45 : 1}
zIndexOffset={members.some((m) => m.incident_id === selectedId) ? 1000 : 0}
eventHandlers={{ click: () => onSelect(repPlot.inc) }}
>
<Popup minWidth={180}>
@@ -964,6 +1067,8 @@ function timeAgo(date: Date): string {
return `${Math.floor(s / 3600)}h ago`;
}
const NO_NEAR: { cam: Camera; km: number }[] = [];
// ── Main MapView ──────────────────────────────────────────────────────────────
interface Props {
nodes: NodeRecord[];
@@ -978,9 +1083,24 @@ interface Props {
*/
calls?: CallRecord[];
lastUpdated?: Date | null;
// ── Live ops wall (all optional; without them MapView behaves as before) ──
/** The incident drilled into; the map fits its path/pin and dims the rest. */
selectedIncidentId?: string | null;
/** When set, rail cards and pins select instead of only flying to the pin. */
onSelectIncident?: (id: string) => void;
/** Nearest cameras to the selected incident — always drawn, highlighted. */
nearCameras?: { cam: Camera; km: number }[];
onOpenCamera?: (c: Camera) => void;
/** Incident ids with a live call on one of their talkgroups. */
onAir?: Set<string>;
/** Cameras within range of each incident (rail "N cams"). */
camCountByIncident?: Map<string, number>;
}
export default function MapView({ nodes, activeCalls, incidents = [], calls = [], lastUpdated }: Props) {
export default function MapView({
nodes, activeCalls, incidents = [], calls = [], lastUpdated,
selectedIncidentId = null, onSelectIncident, nearCameras = NO_NEAR, onOpenCamera, onAir, camCountByIncident,
}: Props) {
const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [agoClock, setAgoClock] = useState(0);
@@ -1008,17 +1128,13 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
// eslint-disable-next-line react-hooks/exhaustive-deps
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
const callsByIncident = useMemo(() => {
const map = new Map<string, CallRecord[]>();
for (const c of calls) {
const ids = c.incident_ids?.length ? c.incident_ids : c.incident_id ? [c.incident_id] : [];
for (const id of ids) {
if (!map.has(id)) map.set(id, []);
map.get(id)!.push(c);
}
}
return map;
}, [calls]);
const callsByIncident = useMemo(() => groupCallsByIncident(calls), [calls]);
const railIncidents = useMemo(() => worstFirst(incidents), [incidents]);
const highlightIds = useMemo(() => new Set(nearCameras.map((n) => n.cam.id)), [nearCameras]);
const selectedPoints = useMemo(() => {
const inc = incidents.find((i) => i.incident_id === selectedIncidentId);
return inc ? incidentPoints(inc, callsByIncident.get(inc.incident_id)) : [];
}, [incidents, selectedIncidentId, callsByIncident]);
const allPositions = useMemo(
() => [
@@ -1050,12 +1166,22 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
const handleIncidentSelect = useCallback(
(inc: IncidentRecord) => {
if (onSelectIncident) { onSelectIncident(inc.incident_id); return; } // the fit happens below
if (!mapInstance || !inc.location_coords) return;
mapInstance.flyTo([inc.location_coords.lat, inc.location_coords.lng], 15, { duration: 1.2 });
},
[mapInstance]
[mapInstance, onSelectIncident]
);
// Wall drill-down: fit the selected incident's whole path (or its pin).
const selectedKey = selectedPoints.map((p) => p.join(",")).join(";");
useEffect(() => {
if (!mapInstance || !selectedPoints.length) return;
if (selectedPoints.length === 1) mapInstance.flyTo(selectedPoints[0], 14, { duration: 1.0 });
else mapInstance.flyToBounds(L.latLngBounds(selectedPoints), { padding: [60, 60], maxZoom: 15, duration: 1.0 });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mapInstance, selectedIncidentId, selectedKey]);
const onMapReady = useCallback((m: L.Map) => setMapInstance(m), []);
return (
@@ -1100,7 +1226,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
{/* Overlay: Active Incidents */}
<LayersControl.Overlay checked name="Active Incidents">
<FeatureGroup>
<FanIncidentLayer incidents={incidents} onSelect={handleIncidentSelect} />
<FanIncidentLayer incidents={incidents} onSelect={handleIncidentSelect} selectedId={selectedIncidentId} />
</FeatureGroup>
</LayersControl.Overlay>
@@ -1128,7 +1254,12 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
{/* Overlays: 511NY DOT cameras + traffic events (server-26#183), opt-in */}
<LayersControl.Overlay name={OVERLAY_DOT_CAMERAS}>
<FeatureGroup>
<DotCameraLayer />
<DotCameraLayer onOpenCamera={onOpenCamera} />
</FeatureGroup>
</LayersControl.Overlay>
<LayersControl.Overlay name={OVERLAY_VIEWSHEDS}>
<FeatureGroup>
<CameraViewshedLayer highlightIds={highlightIds} />
</FeatureGroup>
</LayersControl.Overlay>
<LayersControl.Overlay name={OVERLAY_TRAFFIC_EVENTS}>
@@ -1142,6 +1273,10 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
<RadarLayer />
</LayersControl.Overlay>
</LayersControl>
{selectedIncidentId && (
<NearCameraLayer cameras={nearCameras} head={selectedPoints.at(-1) ?? null} onOpenCamera={onOpenCamera} />
)}
</MapContainer>
{/* ── Live timestamp ───────────────────────────────────────────────────── */}
@@ -1236,11 +1371,16 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
<MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start" />
</div>
<div className="flex flex-col gap-1.5 overflow-y-auto min-h-0 pointer-events-auto">
{incidents.map((inc) => {
{railIncidents.map((inc) => {
const color = severityColor(inc.severity);
const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null;
const unitCount = inc.units_active?.length ?? inc.units?.length ?? 0;
const baseClass = "w-full text-left bg-surface/90 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs hover:brightness-110 transition-all";
const units = (inc.units_active?.length ? inc.units_active : inc.units) ?? [];
const selected = inc.incident_id === selectedIncidentId;
const live = onAir?.has(inc.incident_id) ?? false;
const cams = camCountByIncident?.get(inc.incident_id) ?? 0;
const callCount = inc.call_ids?.length ?? 0;
const baseClass = `w-full text-left bg-surface/90 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs hover:brightness-110 transition-all ${selected ? "ring-2 ring-accent" : ""}`;
const cardBody = (
<>
<div className="flex items-center gap-1.5 mb-0.5">
@@ -1248,6 +1388,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
<span className="uppercase tracking-wide font-semibold text-[10px]" style={{ color }}>
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"}
</span>
{live && <span className="ml-auto bg-accent text-white rounded px-1 text-[9px] font-semibold tracking-wide">ON AIR</span>}
</div>
<p className="text-ink font-semibold leading-snug truncate">
{inc.title ?? "Incident"}
@@ -1255,22 +1396,33 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
{inc.location && (
<p className="text-ink-muted truncate mt-0.5">{inc.location}</p>
)}
{onSelectIncident && units.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{units.slice(0, 4).map((u) => (
<span key={u} className="font-mono text-[10px] px-1 rounded bg-raised text-ink-2">{u}</span>
))}
{units.length > 4 && <span className="font-mono text-[10px] px-1 rounded bg-raised text-ink-muted">+{units.length - 4}</span>}
</div>
)}
<div className="flex items-center justify-between mt-0.5">
{age && <span className="text-ink-muted font-mono">{age}</span>}
{unitCount > 0 && (
{onSelectIncident ? (
<span className="text-ink-muted font-mono">{callCount} call{callCount !== 1 ? "s" : ""}{cams ? ` · ${cams} cam${cams !== 1 ? "s" : ""}` : ""}</span>
) : unitCount > 0 && (
<span className="text-ink-muted font-mono">{unitCount} unit{unitCount !== 1 ? "s" : ""}</span>
)}
</div>
{!inc.location_coords && (
{!inc.location_coords && !onSelectIncident && (
<p className="text-[10px] text-accent mt-1">View details →</p>
)}
</>
);
if (inc.location_coords) {
if (inc.location_coords || onSelectIncident) {
return (
<button
key={inc.incident_id}
onClick={() => handleIncidentSelect(inc)}
aria-pressed={selected}
className={baseClass}
style={{ borderColor: color + "55" }}
>
@@ -1305,7 +1457,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
<div className="bg-surface/95 border-t border-line max-h-52 overflow-y-auto px-3 py-2 space-y-1.5">
{/* Gate A / A2 (server-26#46) */}
<MachineOutputNotice variant="inline" className="text-[10px] items-start" />
{incidents.map((inc) => {
{railIncidents.map((inc) => {
const color = severityColor(inc.severity);
const label = (
<>
@@ -1316,7 +1468,7 @@ export default function MapView({ nodes, activeCalls, incidents = [], calls = []
<span className="text-ink">{inc.title ?? "Incident"}</span>
</>
);
if (inc.location_coords) {
if (inc.location_coords || onSelectIncident) {
return (
<button
key={inc.incident_id}
+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>
);
}
+43 -4
View File
@@ -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 =
@@ -0,0 +1,150 @@
"use client";
/**
* Admin → Camera providers. Every source in design-canvas/camera-sources.md,
* each with its licence status shown first. All default OFF. A licence-gated
* source (agreement required / unverified / non-commercial / paid) shows a
* blocking warning and needs an explicit confirmation, which c2-core records
* with who/when in config/camera_providers and audit_log. Stubs and sources
* whose terms forbid the use cannot be switched on at all.
*/
import { useEffect, useState } from "react";
import { c2api } from "@/lib/c2api";
import type { CameraProviderAdmin, LicenceStatus } from "@/lib/types";
const LICENCE_STYLE: Record<LicenceStatus, { label: string; cls: string }> = {
commercial_ok: { label: "Commercial OK", cls: "bg-green-900 text-green-300 border-green-700" },
attribution_required: { label: "Attribution required", cls: "bg-sky-950 text-sky-300 border-sky-700" },
agreement_required: { label: "Requires agreement — not signed", cls: "bg-red-950 text-red-300 border-red-700" },
non_commercial: { label: "Non-commercial only", cls: "bg-red-950 text-red-300 border-red-700" },
unverified: { label: "Licence unverified", cls: "bg-amber-950 text-amber-300 border-amber-700" },
paid: { label: "Paid licence — no contract", cls: "bg-amber-950 text-amber-300 border-amber-700" },
prohibited: { label: "Terms forbid use", cls: "bg-red-950 text-red-300 border-red-700" },
};
function fmtEpoch(s: number | null | undefined): string {
return s ? new Date(s * 1000).toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }) : "—";
}
export function CameraProvidersTab() {
const [providers, setProviders] = useState<CameraProviderAdmin[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const [confirming, setConfirming] = useState<CameraProviderAdmin | null>(null);
const [ackChecked, setAckChecked] = useState(false);
const load = () =>
c2api.listCameraProviders().then((r) => setProviders(r.providers)).catch((e) => setError(String(e)));
useEffect(() => { load(); }, []);
async function toggle(p: CameraProviderAdmin, enabled: boolean, acknowledge = false) {
if (enabled && p.licence.gated && !acknowledge) {
setAckChecked(false);
setConfirming(p);
return;
}
setBusy(p.id);
setError(null);
try {
const updated = await c2api.setCameraProvider(p.id, enabled, acknowledge);
setProviders((cur) => cur?.map((x) => (x.id === p.id ? updated : x)) ?? null);
setConfirming(null);
} catch (e) {
setError(String(e));
} finally {
setBusy(null);
}
}
return (
<section className="space-y-3">
<p className="text-gray-400 text-xs leading-snug">
Public camera sources for the Live wall. Every provider is <b>off</b> until enabled here. Licence status comes from the
camera-sources catalog (2026-10-04); a red or amber licence means DRB does not hold a confirmed right to show that imagery.
Required attributions render on every camera tile.
</p>
{error && <div className="bg-red-950 border border-red-800 rounded-lg p-3 text-red-400 text-sm font-mono break-words">{error}</div>}
{!providers ? (
<p className="text-gray-500 text-sm font-mono">Loading…</p>
) : (
<div className="bg-gray-900 border border-gray-800 rounded-xl divide-y divide-gray-800">
{providers.map((p) => {
const lic = LICENCE_STYLE[p.licence.status];
const lockReason = !p.implemented ? "No working adapter (stub)" : p.licence.status === "prohibited" ? "Source terms forbid this use" : null;
return (
<div key={p.id} className="px-5 py-4 space-y-2">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 space-y-1">
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded border ${lic.cls}`}>{lic.label}</span>
<p className="text-white text-sm font-semibold">{p.name}</p>
<p className="text-gray-400 text-xs">{p.licence.label}</p>
<p className="text-gray-500 text-xs">{p.region} · {p.formats}</p>
</div>
<div className="shrink-0 text-right space-y-1">
<button
type="button"
role="switch"
aria-checked={p.enabled}
aria-label={`${p.enabled ? "Disable" : "Enable"} ${p.name}`}
disabled={busy === p.id || (!!lockReason && !p.enabled)}
title={lockReason ?? undefined}
onClick={() => toggle(p, !p.enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors disabled:opacity-40 ${p.enabled ? "bg-indigo-600" : "bg-gray-700"}`}
>
<span className={`inline-block h-4 w-4 rounded-full bg-white shadow transition-transform ${p.enabled ? "translate-x-6" : "translate-x-1"}`} />
</button>
<p className={`text-[11px] font-mono ${p.serving ? "text-green-400" : "text-gray-500"}`}>
{p.serving ? "serving" : lockReason ?? p.not_serving_reason ?? "off"}
</p>
</div>
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-[11px] font-mono text-gray-400">
<span>cameras: {p.health ? p.health.count : "—"}</span>
<span>last fetch: {fmtEpoch(p.health?.fetched_at)}</span>
<span className={p.health?.error ? "text-red-400" : ""}>health: {p.health ? (p.health.error ? "error" : p.health.fetched_at ? "ok" : "not fetched yet") : "n/a"}</span>
<span className={p.key_missing ? "text-amber-400" : ""}>{p.key_setting ? `key: ${p.key_missing ? "missing" : "set"}` : "no key needed"}</span>
</div>
{p.health?.error && <p className="text-[11px] font-mono text-red-400 break-words">{p.health.error}</p>}
{p.licence.attribution && <p className="text-[11px] text-gray-400">Attribution shown with imagery: “{p.licence.attribution}”</p>}
{p.acknowledgement && (
<p className="text-[11px] text-amber-300">
Licence warning acknowledged by {p.acknowledgement.by_email || p.acknowledgement.by_uid} on {new Date(p.acknowledgement.at).toLocaleString()}
</p>
)}
<details className="text-[11px] text-gray-500">
<summary className="cursor-pointer select-none">Terms & adapter notes</summary>
<p className="mt-1">{p.licence.terms}</p>
<p className="mt-1">{p.notes}</p>
<p className="mt-1 space-x-2">
{p.licence.terms_url && <a className="underline" href={p.licence.terms_url} target="_blank" rel="noopener noreferrer">terms</a>}
{p.homepage && <a className="underline" href={p.homepage} target="_blank" rel="noopener noreferrer">source</a>}
{p.licence.issue && <span>tracking: {p.licence.issue}</span>}
</p>
</details>
</div>
);
})}
</div>
)}
{confirming && (
<div className="fixed inset-0 z-50 bg-black/70 flex items-center justify-center p-4" role="alertdialog" aria-modal="true" aria-labelledby="cam-ack-title">
<div className="bg-gray-900 border border-red-700 rounded-xl max-w-lg w-full p-5 space-y-3">
<h2 id="cam-ack-title" className="text-white font-semibold">Enable {confirming.name}?</h2>
<p className="text-red-300 text-sm font-semibold">{LICENCE_STYLE[confirming.licence.status].label}</p>
<p className="text-gray-300 text-sm leading-snug">{confirming.licence.warning}</p>
<label className="flex items-start gap-2 text-sm text-gray-200">
<input type="checkbox" checked={ackChecked} onChange={(e) => setAckChecked(e.target.checked)} className="mt-1" />
<span>I confirm DRB holds the right to show this source&apos;s imagery. This confirmation is recorded with my account and the time in the audit log.</span>
</label>
<div className="flex justify-end gap-2">
<button type="button" onClick={() => setConfirming(null)} className="text-sm px-3 py-1.5 rounded border border-gray-700 text-gray-300">Cancel</button>
<button type="button" disabled={!ackChecked || busy === confirming.id} onClick={() => toggle(confirming, true, true)}
className="text-sm px-3 py-1.5 rounded bg-red-700 text-white disabled:opacity-40">Enable and record</button>
</div>
</div>
</div>
)}
</section>
);
}
@@ -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>
);
}
+295
View File
@@ -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=…&amp;cam=…&amp;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>
);
}