Files
server-26/drb-frontend/components/MapView.tsx
T
Logan CusanoandClaude Opus 5.5 c836986b89 Click-to-listen on the live wall + HTTP Range on /media (server-26#233)
Backend: /media/calls/{id}/audio now honours a single HTTP Range
(bytes=a-b, a-, -n) with 206 + Content-Range, 416 for unsatisfiable,
and a 200 full body (Accept-Ranges: bytes) otherwise. The body is
streamed from GCS in 512 KiB span reads (storage.audio_object_size +
download_audio_range) instead of downloading the whole object first,
so a 600 s FLAC starts after one span and seeks fetch only what they
need. HMAC check and Firestore lookup unchanged and still first.

Frontend: InlinePlayer moved to components/InlinePlayer.tsx and now
drives lib/audioQueue.ts, a useSyncExternalStore store over ONE audio
element (one call plays at a time). Wall spine rows get play buttons
and a "Play incident" queue (oldest first) with the playing row
highlighted; map path-stop popups get a play button and the playing
stop gets an accent ring; rail cards get "play latest". Playback is
gated by canPlayAudio() (BUSINESS_MODEL section 3 / Gate A hook).
Nothing plays until clicked. Spine truncation is now stated
("showing latest 30 of M", show-all toggle, count outside the loaded
window) for server-26#239.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 02:33:25 -04:00

1544 lines
72 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
FeatureGroup,
LayersControl,
MapContainer,
Circle,
Marker,
Polygon,
Polyline,
Popup,
TileLayer,
Tooltip,
useMap,
useMapEvents,
} from "react-leaflet";
import L from "leaflet";
import type { AircraftTrack, CallRecord, IncidentRecord, NodeRecord, NodeStatus } from "@/lib/types";
import { isKnownSeverity, SEVERITY_COLORS, SEVERITY_LABEL, type Severity } from "@/lib/severity";
import { MachineOutputNotice } from "@/components/ui/MachineOutputNotice";
import { useAircraft } from "@/lib/useAircraft";
import { useAircraftTrail } from "@/lib/useAircraftTrail";
import { useVessels } from "@/lib/useVessels";
import { use511 } from "@/lib/use511";
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";
import { PlayButton } from "@/components/InlinePlayer";
import { hasAudio, useAudio } from "@/lib/audioQueue";
// ── Leaflet icon fix ──────────────────────────────────────────────────────────
delete (L.Icon.Default.prototype as unknown as Record<string, unknown>)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
});
// ── Basemap tiles ─────────────────────────────────────────────────────────────
// Prod sets NEXT_PUBLIC_MAP_TILE_URL to a keyed style (a CARTO account style,
// MapTiler, Mapbox, …). The in-code fallback is plain OpenStreetMap so the map
// still renders if that var is missing — CARTO's keyless CDN has proven flaky.
// Whatever is supplied must use Leaflet's {s}/{z}/{x}/{y}{r} placeholder scheme;
// the {z}/{x}/{y} tokens below are substituted by Leaflet at runtime.
const MAP_TILE_URL =
process.env.NEXT_PUBLIC_MAP_TILE_URL ||
"https://tile.openstreetmap.org/{z}/{x}/{y}.png";
const MAP_TILE_ATTRIBUTION = "&copy; OpenStreetMap contributors";
// ── Colour ────────────────────────────────────────────────────────────────────
// Severity is the only hue on this map — see UI_REDESIGN.md §2.3. Incident
// type is carried by the glyph knocked out of the pin, never by colour, and
// node state is carried by the diamond's weight (filled/hollow/dashed), not
// by colour either. No green appears anywhere here any more.
function severityColor(severity: string | null | undefined): string {
return isKnownSeverity(severity) ? SEVERITY_COLORS[severity] : "var(--ink-muted)";
}
// Inline SVG paths matching components/marks/TypeGlyph.tsx, duplicated here
// (rather than rendered through the React component) because Leaflet marker
// icons are raw HTML strings, not React nodes.
const TYPE_GLYPH_PATHS: Record<string, string> = {
fire: '<path d="M12 2c1 3-2 4-2 7a3 3 0 0 0 6 0c1.5 1.5 2 3.5 2 5a6 6 0 0 1-12 0c0-3 1.5-4.5 3-6.5C10 5.5 11 4 12 2Z"/>',
police: '<path d="M12 2 4 5v6c0 5 3.4 8.7 8 9 4.6-.3 8-4 8-9V5l-8-3Z"/><path d="M9 12l2 2 4-4"/>',
ems: '<rect x="3" y="3" width="18" height="18" rx="3"/><path d="M12 7v10M7 12h10"/>',
collision: '<path d="M3 16l3-7 4 2 2-5 4 3 3-2 2 6"/><path d="M3 16h18M6 16v3M18 16v3"/>',
other:
'<circle cx="12" cy="12" r="9"/><path d="M9.5 9a2.5 2.5 0 0 1 4.7-1.2c.5.9.2 1.6-.7 2.3-.9.7-1.5 1.2-1.5 2.4"/><circle cx="12" cy="16.5" r="0.6" fill="currentColor" stroke="none"/>',
};
function typeGlyphSvg(type: string | null | undefined, color: string, size = 13): string {
const key = type === "accident" ? "collision" : type && TYPE_GLYPH_PATHS[type] ? type : "other";
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${TYPE_GLYPH_PATHS[key]}</svg>`;
}
// ── Node diamond icon — matches components/marks/NodeMark.tsx ────────────────
function nodeDiamondSvg(status: NodeStatus, size: number): string {
const half = size / 2;
const pts = `${half},1 ${size - 1},${half} ${half},${size - 1} 1,${half}`;
if (status === "recording") {
return `<polygon points="${pts}" fill="var(--ink)" stroke="var(--accent)" stroke-width="1.5"/>`;
}
if (status === "online") {
return `<polygon points="${pts}" fill="var(--ink)"/>`;
}
if (status === "unconfigured") {
return `<polygon points="${pts}" fill="none" stroke="var(--ink-muted)" stroke-width="1.5" stroke-dasharray="2.5 2"/>`;
}
return `<polygon points="${pts}" fill="none" stroke="var(--ink-muted)" stroke-width="1.5"/>`;
}
function nodeIcon(status: NodeStatus): L.DivIcon {
const size = 14;
const isRec = status === "recording";
const ring = isRec
? `<div class="node-pulse-ring" style="position:absolute;width:${size * 2}px;height:${size * 2}px;border-radius:50%;border:2px solid var(--accent);top:-${size / 2}px;left:-${size / 2}px;pointer-events:none;"></div>`
: "";
return L.divIcon({
className: "",
html: `<div style="position:relative;width:${size}px;height:${size}px">${ring}<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">${nodeDiamondSvg(status, size)}</svg></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
}
// ── Aircraft — node-26#9 second-SDR ADS-B overlay ─────────────────────────────
// Styled after ADS-B Exchange / tar1090: a sized airliner silhouette with a
// dark outline, filled by altitude on tar1090's hue ramp, so height reads at a
// glance and the icon stands out from OSM's own (purple) airport symbols.
const ALT_HUE_STOPS: [number, number][] = [
[0, 20], [2000, 32.5], [4000, 43], [6000, 54], [8000, 72], [9000, 85], [11000, 140], [40000, 300],
];
function altitudeColor(altFt: number | null): string {
if (altFt == null) return "hsl(0, 0%, 55%)";
if (altFt <= 0) return "hsl(0, 0%, 45%)"; // on the ground
let hue = ALT_HUE_STOPS[ALT_HUE_STOPS.length - 1][1];
for (let i = 1; i < ALT_HUE_STOPS.length; i++) {
const [a1, h1] = ALT_HUE_STOPS[i];
if (altFt <= a1) {
const [a0, h0] = ALT_HUE_STOPS[i - 1];
hue = h0 + ((h1 - h0) * (altFt - a0)) / (a1 - a0);
break;
}
}
return `hsl(${hue.toFixed(0)}, 88%, 48%)`;
}
// Legend ticks for the altitude ramp, evenly spaced (the ramp itself isn't linear).
const ALTITUDE_LEGEND_TICKS: [number, string][] = [
[1000, "1k"], [4000, "4k"], [10000, "10k"], [20000, "20k"], [40000, "40k"],
];
const AIRLINER_PATH =
"M32 2 C34.2 2 35.2 5 35.2 8 L35.2 23 L61 37.5 L61 42 L35.2 35 L34.2 51 L42.5 57.5 L42.5 61 L32 58.5 " +
"L21.5 61 L21.5 57.5 L29.8 51 L28.8 35 L3 42 L3 37.5 L28.8 23 L28.8 8 C28.8 5 29.8 2 32 2 Z";
function aircraftIcon(trackDeg: number | null, altFt: number | null, selected: boolean): L.DivIcon {
const size = selected ? 36 : 30;
const outline = selected ? "#ffffff" : "#000000";
const shadow = selected ? "drop-shadow(0 0 3px #000)" : "drop-shadow(0 1px 1px rgba(0,0,0,.45))";
return L.divIcon({
className: "",
html:
`<div style="width:${size}px;height:${size}px;transform:rotate(${trackDeg ?? 0}deg);filter:${shadow}">` +
`<svg width="${size}" height="${size}" viewBox="0 0 64 64"><path d="${AIRLINER_PATH}" ` +
`fill="${altitudeColor(altFt)}" stroke="${outline}" stroke-width="${selected ? 3 : 2}" stroke-linejoin="round"/></svg></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
}
function AircraftTrail({ icao, current }: { icao: string; current: AircraftTrack }) {
const trail = useAircraftTrail(icao);
// Extend to the live position so the path always meets the icon.
const points = [...trail];
if (current.lat != null && current.lon != null) {
points.push({ lat: current.lat, lon: current.lon, altitude_ft: current.altitude_ft, t: current.last_seen });
}
// One segment per leg, colored by altitude like tar1090's track.
return (
<>
{points.slice(1).map((p, i) => (
<Polyline
key={`${icao}-${i}`}
positions={[[points[i].lat, points[i].lon], [p.lat, p.lon]]}
pathOptions={{ color: altitudeColor(p.altitude_ft), weight: 3, opacity: 0.9, lineCap: "round" }}
interactive={false}
/>
))}
</>
);
}
function Stat({ label, value }: { label: string; value: string }) {
return (
<div>
<div className="text-[10px] uppercase tracking-wide text-ink-muted">{label}</div>
<div className="text-ink font-medium tabular-nums">{value}</div>
</div>
);
}
// Details dock at the right edge (bottom sheet on phones, above the incident
// drawer) instead of a popup over the plane, so the trail stays visible and
// the map can be panned to follow it.
function AircraftPanel({ a, onClose }: { a: AircraftTrack; onClose: () => void }) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
// The panel is portaled into the Leaflet container, whose native listeners
// would otherwise treat clicks/scrolls here as map clicks (deselect) or zoom.
if (!ref.current) return;
L.DomEvent.disableClickPropagation(ref.current);
L.DomEvent.disableScrollPropagation(ref.current);
}, []);
const fmt = (n: number | null, unit: string) => (n == null ? "—" : `${Math.round(n).toLocaleString()} ${unit}`);
return (
<div
ref={ref}
className="absolute left-3 right-3 bottom-12 md:left-auto md:bottom-auto md:top-[5.5rem] md:right-3 md:w-60 z-[1002] bg-surface/95 backdrop-blur-sm border border-line rounded-lg shadow-lg text-xs"
>
<div className="flex items-start justify-between gap-2 px-3 pt-2.5 pb-2 border-b border-line">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<span className="inline-block w-2.5 h-2.5 rounded-full shrink-0" style={{ background: altitudeColor(a.altitude_ft) }} />
<span className="text-ink font-semibold text-sm truncate">{a.callsign || a.icao}</span>
</div>
<div className="text-ink-muted mt-0.5">ICAO {a.icao}</div>
</div>
<button onClick={onClose} aria-label="Close aircraft details" className="text-ink-muted hover:text-ink px-1 leading-none text-base">
×
</button>
</div>
<div className="grid grid-cols-2 gap-x-3 gap-y-2 px-3 py-2.5">
<Stat label="Altitude" value={fmt(a.altitude_ft, "ft")} />
<Stat label="Speed" value={fmt(a.ground_speed_kt, "kt")} />
<Stat label="Heading" value={a.track_deg == null ? "—" : `${Math.round(a.track_deg)}°`} />
<Stat label="Last heard" value={timeAgo(new Date(a.last_seen))} />
</div>
</div>
);
}
function AircraftLayer() {
const map = useMap();
const { aircraft } = useAircraft();
const [selected, setSelected] = useState<string | null>(null);
const positioned = aircraft.filter((a) => a.lat != null && a.lon != null);
const selectedTrack = positioned.find((a) => a.icao === selected);
// Clicking empty map deselects; marker clicks don't reach the map.
useMapEvents({ click: () => setSelected(null) });
return (
<>
{selectedTrack && <AircraftTrail icao={selectedTrack.icao} current={selectedTrack} />}
{selectedTrack &&
createPortal(<AircraftPanel a={selectedTrack} onClose={() => setSelected(null)} />, map.getContainer())}
{positioned.map((a) => (
<Marker
key={a.icao}
position={[a.lat as number, a.lon as number]}
icon={aircraftIcon(a.track_deg, a.altitude_ft, a.icao === selected)}
zIndexOffset={a.icao === selected ? 1000 : 0}
eventHandlers={{ click: () => setSelected((cur) => (cur === a.icao ? null : a.icao)) }}
>
<Tooltip direction="top" offset={[0, -14]}>
{a.callsign || a.icao}
{a.altitude_ft != null && ` · ${Math.round(a.altitude_ft).toLocaleString()} ft`}
</Tooltip>
</Marker>
))}
</>
);
}
// ── Vessel icon — node-26#9 second-SDR AIS overlay ─────────────────────────────
// MMSI 99xxxxxxx is an aid to navigation (buoy, beacon, light), not a vessel —
// on the Hudson most of what a node hears is buoys (server-26#186).
function isAidToNavigation(mmsi: string): boolean {
return /^99\d{7}$/.test(mmsi);
}
// AIS reports heading 511 (and course 360) for "not available".
function aisHeading(deg: number | null): number | null {
return deg == null || deg >= 360 ? null : deg;
}
function vesselIcon(headingDeg: number | null): L.DivIcon {
const size = 22;
return L.divIcon({
className: "",
html:
`<div style="width:${size}px;height:${size}px;transform:rotate(${aisHeading(headingDeg) ?? 0}deg);filter:drop-shadow(0 1px 1px rgba(0,0,0,.45))">` +
`<svg width="${size}" height="${size}" viewBox="0 0 24 24"><path d="M12 2 L18 12 L18 21 L6 21 L6 12 Z" fill="hsl(190, 80%, 42%)" stroke="#000" stroke-width="1.25" stroke-linejoin="round"/></svg></div>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
}
function aidToNavigationIcon(): L.DivIcon {
const size = 12;
return L.divIcon({
className: "",
html: `<svg width="${size}" height="${size}" viewBox="0 0 12 12"><rect x="2" y="2" width="8" height="8" transform="rotate(45 6 6)" fill="hsl(50, 95%, 55%)" stroke="#000" stroke-width="1"/></svg>`,
iconSize: [size, size],
iconAnchor: [size / 2, size / 2],
});
}
function VesselLayer() {
const { vessels } = useVessels();
return (
<>
{vessels
.filter((v) => v.lat != null && v.lon != null)
.map((v) => {
const aton = isAidToNavigation(v.mmsi);
return (
<Marker
key={v.mmsi}
position={[v.lat as number, v.lon as number]}
icon={aton ? aidToNavigationIcon() : vesselIcon(v.heading_deg)}
zIndexOffset={aton ? -100 : 0}
>
<Popup minWidth={160}>
<div className="space-y-1">
<div className="font-semibold">{aton ? `Aid to navigation${v.name ? ` ${v.name}` : ""}` : v.name || v.mmsi}</div>
<div className="text-xs text-ink-muted">MMSI {v.mmsi}</div>
{!aton && v.speed_kt != null && <div className="text-xs">Speed: {Math.round(v.speed_kt)} kt</div>}
</div>
</Popup>
</Marker>
);
})}
</>
);
}
// ── Weather radar ────────────────────────────────────────────────────────────
// IEM tiles are static once loaded, so refresh them in place with a
// cache-buster. Never remount the layer (e.g. via `key`) to refresh it: a
// remounted child of an unchecked LayersControl.Overlay is re-registered with
// the control but not re-added to the map, which switched radar off every 5 min.
const RADAR_URL = "https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0r-900913/{z}/{x}/{y}.png";
const RADAR_REFRESH_MS = 5 * 60 * 1000;
function RadarLayer() {
const ref = useRef<L.TileLayer>(null);
useEffect(() => {
const id = setInterval(() => ref.current?.setUrl(`${RADAR_URL}?_=${Date.now()}`), RADAR_REFRESH_MS);
return () => clearInterval(id);
}, []);
return (
<TileLayer
ref={ref}
url={RADAR_URL}
attribution='Radar &copy; <a href="https://mesonet.agron.iastate.edu/">IEM/NWS</a>'
opacity={0.65}
/>
);
}
// ── 511NY traffic layers (server-26#183) ─────────────────────────────────────
// 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 {
const [shown, setShown] = useState(false);
useEffect(() => {
const on = (e: L.LayersControlEvent) => e.name === name && setShown(true);
const off = (e: L.LayersControlEvent) => e.name === name && setShown(false);
map.on("overlayadd", on);
map.on("overlayremove", off);
return () => { map.off("overlayadd", on); map.off("overlayremove", off); };
}, [map, name]);
return shown;
}
/** Small count badge for an LOD cluster icon; empty when the marker stands alone. */
function countBadge(count: number): string {
if (count < 2) return "";
const label = count > 99 ? "99+" : String(count);
return `<span style="position:absolute;top:-7px;right:-9px;min-width:14px;height:14px;padding:0 3px;border-radius:7px;background:#0f172a;color:#fff;border:1px solid #fff;font:700 9px/12px sans-serif;text-align:center;box-sizing:border-box">${label}</span>`;
}
function cameraIcon(count = 1): L.DivIcon {
return L.divIcon({
className: "",
html: `<div style="position:relative;width:16px;height:12px"><svg width="16" height="12" viewBox="0 0 16 12"><rect x="0.5" y="1.5" width="11" height="9" rx="2" fill="#1e3a5f" stroke="#93c5fd" stroke-width="1"/><circle cx="6" cy="6" r="2.5" fill="none" stroke="#93c5fd" stroke-width="1.25"/><polygon points="12,4 15.5,2 15.5,10 12,8" fill="#93c5fd"/></svg>${countBadge(count)}</div>`,
iconSize: [16, 12],
iconAnchor: [8, 6],
});
}
// Shape + glyph per 511 event type, so the layer reads without colour alone.
// Order is LOD priority: when events share a cell, the earliest type represents it.
const EVENT_STYLE: Record<string, { glyph: string; fill: string; label: string }> = {
accidentsAndIncidents: { glyph: "!", fill: "#dc2626", label: "Accident / incident" },
closures: { glyph: "×", fill: "#ea580c", label: "Closure" },
roadwork: { glyph: "W", fill: "#ca8a04", label: "Roadwork" },
specialEvents: { glyph: "E", fill: "#7c3aed", label: "Special event" },
transitOperations: { glyph: "T", fill: "#0891b2", label: "Transit" },
};
const EVENT_STYLE_OTHER = { glyph: "i", fill: "#6b7280", label: "Other" };
const EVENT_RANK = Object.keys(EVENT_STYLE);
const eventRank = (e: Ny511Event) => {
const r = EVENT_RANK.indexOf(e.type);
return r < 0 ? EVENT_RANK.length : r;
};
function trafficEventIcon(type: string, count = 1): L.DivIcon {
const st = EVENT_STYLE[type] ?? EVENT_STYLE_OTHER;
return L.divIcon({
className: "",
html: `<div style="position:relative;width:16px;height:16px"><svg width="16" height="16" viewBox="0 0 16 16"><rect x="1" y="1" width="14" height="14" rx="3" fill="${st.fill}" stroke="#000" stroke-width="1"/><text x="8" y="12" text-anchor="middle" font-size="11" font-weight="700" font-family="sans-serif" fill="#fff">${st.glyph}</text></svg>${countBadge(count)}</div>`,
iconSize: [16, 16],
iconAnchor: [8, 8],
});
}
// ── 511 level of detail ──────────────────────────────────────────────────────
// Zoomed out, a county's worth of cameras/events buries the map. Below
// LOD_FULL_ZOOM, markers are bucketed into a grid of LOD_CELL_PX screen pixels
// and each cell draws one representative with a count badge; clicking a
// cluster zooms in on it. The grid is in world pixels (map.project), so it
// only changes on zoom — panning never reshuffles which marker represents a cell.
const LOD_CELL_PX = 40;
const LOD_FULL_ZOOM = 14;
interface LodCell<T> { rep: T; count: number }
function useMapZoom(map: L.Map): number {
const [zoom, setZoom] = useState(() => map.getZoom());
useEffect(() => {
const h = () => setZoom(map.getZoom());
map.on("zoomend", h);
return () => { map.off("zoomend", h); };
}, [map]);
return zoom;
}
function lodCells<T extends { lat: number; lon: number }>(
items: T[], map: L.Map, zoom: number, rank: (t: T) => number = () => 0,
): LodCell<T>[] {
if (zoom >= LOD_FULL_ZOOM) return items.map((rep) => ({ rep, count: 1 }));
const cells = new Map<string, LodCell<T>>();
for (const it of items) {
const p = map.project([it.lat, it.lon], zoom);
const key = `${Math.floor(p.x / LOD_CELL_PX)}:${Math.floor(p.y / LOD_CELL_PX)}`;
const cell = cells.get(key);
if (!cell) cells.set(key, { rep: it, count: 1 });
else {
cell.count++;
if (rank(it) < rank(cell.rep)) cell.rep = it;
}
}
return Array.from(cells.values());
}
function zoomInto(map: L.Map, lat: number, lon: number) {
map.setView([lat, lon], Math.min(map.getZoom() + 2, LOD_FULL_ZOOM));
}
function fmtLocal(iso: string | null): string | null {
if (!iso) return null;
const d = new Date(iso); // naive ISO parses as browser-local; 511NY stamps are NY local
return isNaN(d.getTime()) ? null : d.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
}
/** Surfaces a dead or stale 511 feed on the map instead of an unexplained empty layer. */
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}`;
} else if (status?.truncated) msg = `${label}: showing ${1500} of ${status.total_in_bbox} — 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(),
);
}
/** 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: Camera; mode: CameraMode }
const camBtn = "text-ink-muted hover:text-ink text-xs leading-none px-0.5";
function CameraDock({ map, open, credits, onMode, onClose }: {
map: L.Map;
open: Camera[];
credits: Map<string, CameraCredit>;
onMode: (id: string, mode: CameraMode) => void;
onClose: (id: string) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
// Portaled into the Leaflet container — keep clicks/scrolls off the map.
if (!ref.current) return;
L.DomEvent.disableClickPropagation(ref.current);
L.DomEvent.disableScrollPropagation(ref.current);
}, [open.length > 0]); // eslint-disable-line react-hooks/exhaustive-deps
if (!open.length) return null;
return createPortal(
<div
ref={ref}
className="absolute z-[1002] left-3 right-3 top-12 md:top-auto md:bottom-8 md:left-[16rem] md:right-[12rem] flex flex-wrap-reverse justify-center gap-2 max-h-[calc(100%-8rem)] overflow-y-auto pointer-events-none"
>
{open.map((c) => (
<div key={c.id} className="pointer-events-auto w-[240px] bg-surface border border-line-strong rounded-md shadow-lg">
<div className="flex items-center gap-1 px-1.5 h-5">
<span className="flex-1 truncate text-[11px] leading-5 text-ink-2" title={c.name}>{c.name}</span>
<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} credit={credits.get(c.provider)} className="block w-[240px] h-[135px] bg-black rounded-b-md" />
</div>
))}
</div>,
map.getContainer(),
);
}
function DotCameraLayer({ onOpenCamera }: { onOpenCamera?: (c: Camera) => void }) {
const map = useMap();
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
const zoom = useMapZoom(map);
const { data, error, loaded } = useMapCameras(map, shown);
const [openAll, setOpen] = useState<OpenCamera[]>([]);
// Credits from the last answer received while the overlay was on. Hiding the
// overlay empties `data`, but docked players stay up and still need their
// attribution. A pinned/docked camera whose provider an admin has since
// switched off (licence gating, server-26#198) drops out on the next answer.
const [feeds, setFeeds] = useState<Map<string, CameraProviderFeed>>(() => new Map());
useEffect(() => {
if (shown && loaded) setFeeds(creditsByProvider(data.providers));
}, [shown, loaded, data.providers]);
const open = useMemo(() => openAll.filter((o) => feeds.has(o.cam.provider)), [openAll, feeds]);
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),
[data.cameras, pinnedIds, map, zoom],
);
const show = (cam: Camera, mode: CameraMode) => {
map.closePopup();
setOpen((prev) => [...prev.filter((o) => o.cam.id !== cam.id), { cam, mode }].slice(-MAX_OPEN_CAMERAS));
};
const setMode = (id: string, mode: CameraMode) =>
setOpen((prev) => prev.map((o) => (o.cam.id === id ? { ...o, mode } : o)));
const close = (id: string) => setOpen((prev) => prev.filter((o) => o.cam.id !== id));
return (
<>
{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`}
eventHandlers={{ click: () => zoomInto(map, c.lat, c.lon) }} />
) : (
<Marker key={c.id} position={[c.lat, c.lon]} icon={cameraIcon()}>
<Popup minWidth={260} maxWidth={340}>
<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>
))}
{/* Pinned players only exist while the overlay is on, so hiding it stops their streams. */}
{shown && open.filter((o) => o.mode === "pin").map(({ cam: c }) => (
<Marker key={`pin-${c.id}`} position={[c.lat, c.lon]} icon={cameraIcon()} zIndexOffset={300}>
<Tooltip permanent interactive direction="top" offset={[0, -8]} opacity={1} className="drb-cam-pin">
<div className="w-[192px]">
<div className="flex items-center gap-1 px-1.5 h-4">
<span className="flex-1 truncate text-[10px] leading-4 text-ink-2" title={c.name}>{c.name}</span>
<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} 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)} 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>
))}
</>
);
}
function TrafficEventLayer() {
const map = useMap();
const shown = useOverlayShown(map, OVERLAY_TRAFFIC_EVENTS);
const zoom = useMapZoom(map);
const { data, error } = use511(map, "events", shown);
const cells = useMemo(() => lodCells(data.events ?? [], map, zoom, eventRank), [data.events, map, zoom]);
return (
<>
{shown && <FeedProblem map={map} label="Traffic events" status={data.events_status} fetchError={error} />}
{cells.map(({ rep: e, count }) => {
const zIndexOffset = e.type === "accidentsAndIncidents" ? 200 : 0;
if (count > 1) {
return (
<Marker key={`lod-${e.id}`} position={[e.lat, e.lon]} icon={trafficEventIcon(e.type, count)} zIndexOffset={zIndexOffset}
title={`${count} traffic events — click to zoom in`}
eventHandlers={{ click: () => zoomInto(map, e.lat, e.lon) }} />
);
}
const st = EVENT_STYLE[e.type] ?? EVENT_STYLE_OTHER;
const start = fmtLocal(e.start_local);
const end = fmtLocal(e.planned_end_local);
return (
<Marker key={e.id} position={[e.lat, e.lon]} icon={trafficEventIcon(e.type)} zIndexOffset={zIndexOffset}>
<Popup minWidth={220} maxWidth={320}>
<div className="space-y-1">
<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>
<SourceCredit credit={data.credit} fallbackName="511NY / NYSDOT"
updated={fmtLocal(e.updated_local) ?? fetchedLabel(data.events_status?.fetched_at)} />
</div>
</Popup>
</Marker>
);
})}
</>
);
}
function nodeFanIcon(members: NodeRecord[]): L.DivIcon {
const n = members.length;
const CARD = 13;
const STEP = 5;
const totalW = CARD + (n - 1) * STEP;
const maxRot = Math.min(28, n * 7);
const cards = members
.map((m, i) => {
const ratio = n === 1 ? 0 : i / (n - 1) - 0.5;
const rot = ratio * maxRot;
const left = i * STEP;
return `<div style="position:absolute;width:${CARD}px;height:${CARD}px;left:${left}px;top:0;transform:rotate(${rot}deg);transform-origin:bottom center;"><svg width="${CARD}" height="${CARD}" viewBox="0 0 ${CARD} ${CARD}">${nodeDiamondSvg(m.status, CARD)}</svg></div>`;
})
.join("");
return L.divIcon({
className: "",
html: `<div style="position:relative;width:${totalW}px;height:${CARD + 6}px">${cards}</div>`,
iconSize: [totalW, CARD + 6],
iconAnchor: [totalW / 2, CARD + 6],
});
}
// ── Incident pin — teardrop filled by severity, type glyph knocked out ───────
// Minor/routine (no alarm colour) render hollow with an ink stroke so the map
// doesn't imply urgency that isn't there.
function incidentIcon(type: string | null, severity: string | null | undefined): L.DivIcon {
const color = severityColor(severity);
const hollow = !isKnownSeverity(severity) || severity === "minor" || severity === "routine";
const glyphColor = hollow ? color : "var(--page)";
const fill = hollow ? "var(--surface)" : color;
const stroke = hollow ? color : "var(--page)";
const svg = `
<svg width="28" height="36" viewBox="0 0 28 36">
<path d="M14 1C6.8 1 1 6.8 1 14c0 9.5 13 21 13 21s13-11.5 13-21C27 6.8 21.2 1 14 1Z"
fill="${fill}" stroke="${stroke}" stroke-width="2"/>
<g transform="translate(7.5,7.5)">${typeGlyphSvg(type, glyphColor, 13)}</g>
</svg>`;
return L.divIcon({ className: "", html: svg, iconSize: [28, 36], iconAnchor: [14, 34] });
}
function incidentFanIcon(members: IncidentRecord[]): L.DivIcon {
const n = members.length;
const CARD = 16;
const STEP = 9;
const totalW = CARD + (n - 1) * STEP;
const maxRot = Math.min(28, n * 7);
const cards = members
.map((m, i) => {
const ratio = n === 1 ? 0 : i / (n - 1) - 0.5;
const rot = ratio * maxRot;
const left = i * STEP;
const color = severityColor(m.severity);
const hollow = !isKnownSeverity(m.severity) || m.severity === "minor" || m.severity === "routine";
return `<div style="position:absolute;width:${CARD}px;height:${CARD}px;border-radius:3px;background:${hollow ? "var(--surface)" : color};border:1.5px solid ${color};left:${left}px;top:0;transform:rotate(${rot}deg);transform-origin:bottom center;box-shadow:0 1px 3px rgba(0,0,0,0.35);display:flex;align-items:center;justify-content:center;">${typeGlyphSvg(m.type, hollow ? color : "var(--page)", 10)}</div>`;
})
.join("");
return L.divIcon({
className: "",
html: `<div style="position:relative;width:${totalW}px;height:${CARD + 6}px">${cards}</div>`,
iconSize: [totalW, CARD + 6],
iconAnchor: [totalW / 2, CARD + 6],
});
}
// ── Incident path stop marker — numbered, shared index with the call spine ───
// `playing` (server-26#233): the stop whose call is on the speaker gets a
// thick accent ring outside the severity halo — "where was he when he said
// that", answered while it is being said.
function pathStopIcon(index: number, isFirst: boolean, isLast: boolean, color: string, playing = false): L.DivIcon {
const size = playing ? 30 : 22;
const c = size / 2;
const fill = isFirst ? "var(--surface)" : color;
const textColor = isFirst ? color : "var(--page)";
const halo = isLast ? `<circle cx="${c}" cy="${c}" r="10.5" fill="none" stroke="${color}" stroke-width="2" opacity="0.5"/>` : "";
const ring = playing ? `<circle cx="${c}" cy="${c}" r="13.5" fill="none" stroke="var(--accent)" stroke-width="3"/>` : "";
return L.divIcon({
className: "",
html: `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
${ring}
${halo}
<circle cx="${c}" cy="${c}" r="8" fill="${fill}" stroke="${color}" stroke-width="2"/>
<text x="${c}" y="${c + 3.5}" text-anchor="middle" font-size="10" font-weight="600" fill="${textColor}" font-family="var(--font-sans)">${index}</text>
</svg>`,
iconSize: [size, size],
iconAnchor: [c, c],
});
}
// ── Fan cluster grouping ──────────────────────────────────────────────────────
const CLUSTER_PX = 32;
function computeGroups<T extends { id: string; lat: number; lng: number }>(
items: T[],
map: L.Map
): Map<string, T[]> {
if (!items.length) return new Map();
const withPx = items.map((item) => ({
item,
px: map.latLngToContainerPoint([item.lat, item.lng]),
}));
const parent: number[] = items.map((_, i) => i);
function find(x: number): number {
if (parent[x] !== x) parent[x] = find(parent[x]);
return parent[x];
}
for (let i = 0; i < withPx.length; i++) {
for (let j = i + 1; j < withPx.length; j++) {
const dx = withPx[i].px.x - withPx[j].px.x;
const dy = withPx[i].px.y - withPx[j].px.y;
if (Math.sqrt(dx * dx + dy * dy) < CLUSTER_PX) {
const ri = find(i), rj = find(j);
if (ri !== rj) parent[ri] = rj;
}
}
}
const groups = new Map<number, T[]>();
withPx.forEach(({ item }, i) => {
const root = find(i);
if (!groups.has(root)) groups.set(root, []);
groups.get(root)!.push(item);
});
const result = new Map<string, T[]>();
Array.from(groups.values()).forEach((members) => result.set(members[0].id, members));
return result;
}
// ── MapRefCapture — exposes L.Map instance to parent ─────────────────────────
function MapRefCapture({ onReady }: { onReady: (m: L.Map) => void }) {
const map = useMap();
useEffect(() => {
onReady(map);
}, [map, onReady]);
return null;
}
// ── FanNodeLayer ──────────────────────────────────────────────────────────────
function FanNodeLayer({
nodes,
activeCalls,
}: {
nodes: NodeRecord[];
activeCalls: CallRecord[];
}) {
const map = useMap();
const [tick, setTick] = useState(0);
useEffect(() => {
const h = () => setTick((t: number) => t + 1);
map.on("zoomend moveend", h);
return () => { map.off("zoomend moveend", h); };
}, [map]);
const activeByNode = useMemo(
() => Object.fromEntries(activeCalls.map((c) => [c.node_id, c])),
[activeCalls]
);
const nodeById = useMemo(() => new Map(nodes.map((n) => [n.node_id, n])), [nodes]);
const groups = useMemo(() => {
const items = nodes.map((n) => ({ id: n.node_id, lat: n.lat, lng: n.lon }));
return computeGroups(items, map);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodes, map, tick]);
return (
<>
{(Array.from(groups.entries()) as Array<[string, { id: string; lat: number; lng: number }[]]>).map(([repId, raw]) => {
const members = raw.map((r) => nodeById.get(r.id)!).filter(Boolean);
const rep = nodeById.get(repId);
if (!rep) return null;
return (
<Marker
key={repId}
position={[rep.lat, rep.lon]}
icon={members.length > 1 ? nodeFanIcon(members) : nodeIcon(rep.status)}
>
<Popup minWidth={160}>
<div className="space-y-2">
{members.map((node, idx) => (
<div
key={node.node_id}
className={idx < members.length - 1 ? "border-b border-gray-200 pb-2" : ""}
>
<p className="font-semibold text-sm text-gray-900">{node.name}</p>
<p className="text-xs text-gray-500 font-mono">{node.node_id}</p>
<p className="text-xs capitalize text-gray-700">{node.status}</p>
{activeByNode[node.node_id] && (
<p className="text-xs text-orange-600 mt-0.5">
● TG {activeByNode[node.node_id].talkgroup_id ?? "—"}{" "}
{activeByNode[node.node_id].talkgroup_name}
</p>
)}
</div>
))}
</div>
</Popup>
</Marker>
);
})}
</>
);
}
// ── FanIncidentLayer ──────────────────────────────────────────────────────────
function FanIncidentLayer({
incidents,
onSelect,
selectedId,
}: {
incidents: IncidentRecord[];
onSelect: (inc: IncidentRecord) => void;
selectedId?: string | null;
}) {
const map = useMap();
const [tick, setTick] = useState(0);
useEffect(() => {
const h = () => setTick((t: number) => t + 1);
map.on("zoomend moveend", h);
return () => { map.off("zoomend moveend", h); };
}, [map]);
const plotted = useMemo(
() =>
incidents
.filter((i) => i.location_coords)
.map((i) => ({
id: i.incident_id,
lat: i.location_coords!.lat,
lng: i.location_coords!.lng,
inc: i,
})),
[incidents]
);
const incById = useMemo(
() => new Map(plotted.map((p: { id: string; lat: number; lng: number; inc: IncidentRecord }) => [p.id, p.inc])),
[plotted]
);
const groups = useMemo(
() => computeGroups(plotted, map),
// eslint-disable-next-line react-hooks/exhaustive-deps
[plotted, map, tick]
);
return (
<>
{(Array.from(groups.entries()) as Array<[string, { id: string; lat: number; lng: number }[]]>).map(([repId, raw]) => {
const members = raw.map((r) => incById.get(r.id)!).filter(Boolean);
const repPlot = plotted.find((p: { id: string }) => p.id === repId);
if (!repPlot) return null;
return (
<Marker
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}>
<div className="space-y-2">
{members.map((inc, idx) => {
const color = severityColor(inc.severity);
return (
<div
key={inc.incident_id}
className={idx < members.length - 1 ? "border-b border-gray-200 pb-2" : ""}
>
<p className="font-semibold text-sm text-gray-900">{inc.title ?? "Incident"}</p>
<p className="text-xs capitalize font-medium" style={{ color }}>
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"} · {inc.type ?? "other"}
</p>
{inc.location && <p className="text-xs text-gray-600">{inc.location}</p>}
<a
href={`/incidents/${inc.incident_id}`}
onClick={(e) => { e.stopPropagation(); window.location.href = `/incidents/${inc.incident_id}`; e.preventDefault(); }}
className="text-xs text-blue-600 hover:underline block mt-0.5"
>
View incident →
</a>
</div>
);
})}
{/* Gate A / A2 (server-26#46) — same screen as the output. */}
<MachineOutputNotice variant="popup" />
</div>
</Popup>
</Marker>
);
})}
</>
);
}
// ── Incident path layer — the flagship feature: a pursuit as a polyline ──────
// Per UI_REDESIGN.md §2.4/§5.1: an incident's map stops use the SAME index as
// its call timeline, so "where was he when he said that" is answered by
// looking, not cross-referencing timestamps. Needs no backend — per-call
// location_coords are already written by intelligence.py.
function IncidentPathLayer({
incidents,
callsByIncident,
}: {
incidents: IncidentRecord[];
callsByIncident: Map<string, CallRecord[]>;
}) {
// Only the playing id, so a timeupdate never re-renders the map.
const playingCallId = useAudio((s) => s.playingCallId);
return (
<>
{incidents.map((inc) => {
const calls = (callsByIncident.get(inc.incident_id) ?? [])
.filter((c) => c.location_coords)
.slice()
.sort((a, b) => a.started_at.localeCompare(b.started_at));
if (calls.length < 2) return null;
const color = severityColor(inc.severity);
const positions = calls.map((c) => [c.location_coords!.lat, c.location_coords!.lng] as [number, number]);
return (
<FeatureGroup key={inc.incident_id}>
<Polyline
positions={positions}
pathOptions={{ color, weight: 3, opacity: 0.85, lineJoin: "round" }}
/>
{calls.map((c, i) => (
<Marker
key={c.call_id}
position={[c.location_coords!.lat, c.location_coords!.lng]}
icon={pathStopIcon(i + 1, i === 0, i === calls.length - 1, color, c.call_id === playingCallId)}
zIndexOffset={c.call_id === playingCallId ? 1000 : 0}
>
<Popup minWidth={160}>
<div className="text-gray-900">
<p className="text-xs text-gray-500">Stop {i + 1} of {calls.length}</p>
<p className="font-semibold text-sm mt-0.5">{inc.title ?? "Incident"}</p>
{c.location && <p className="text-xs text-gray-600 mt-0.5">{c.location}</p>}
{hasAudio(c) && (
<div className="flex items-center gap-1.5 mt-1">
<PlayButton callId={c.call_id} label={`Play stop ${i + 1}`} />
<span className="text-xs text-gray-600">Play this call</span>
</div>
)}
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1">
View incident →
</a>
{/* Gate A / A2 (server-26#46) — the stop location and its
ordering come from the transcript, not from GPS. */}
<MachineOutputNotice variant="popup" />
</div>
</Popup>
</Marker>
))}
</FeatureGroup>
);
})}
</>
);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function timeAgo(date: Date): string {
const s = Math.floor((Date.now() - date.getTime()) / 1000);
if (s < 60) return `${s}s ago`;
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
return `${Math.floor(s / 3600)}h ago`;
}
const NO_NEAR: { cam: Camera; km: number }[] = [];
// ── Main MapView ──────────────────────────────────────────────────────────────
interface Props {
nodes: NodeRecord[];
activeCalls: CallRecord[];
incidents?: IncidentRecord[];
/**
* Calls to draw incident paths from — needs `location_coords` and
* `incident_ids`/`incident_id`. Grouped internally by incident. Pass the
* broadest set of recently-loaded calls the caller has (e.g. from
* useCalls); an incident with fewer than 2 geocoded calls in this set
* simply draws no path.
*/
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,
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);
const [aircraftShown, setAircraftShown] = useState(false);
// The altitude key only belongs in the legend while the opt-in Aircraft
// overlay is actually on (server-26#185).
useEffect(() => {
if (!mapInstance) return;
const on = (e: L.LayersControlEvent) => e.name === "Aircraft" && setAircraftShown(true);
const off = (e: L.LayersControlEvent) => e.name === "Aircraft" && setAircraftShown(false);
mapInstance.on("overlayadd", on);
mapInstance.on("overlayremove", off);
return () => {
mapInstance.off("overlayadd", on);
mapInstance.off("overlayremove", off);
};
}, [mapInstance]);
useEffect(() => {
const id = setInterval(() => setAgoClock((t: number) => t + 1), 10_000);
return () => clearInterval(id);
}, []);
// eslint-disable-next-line react-hooks/exhaustive-deps
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
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(
() => [
...nodes.map((n) => [n.lat, n.lon] as [number, number]),
...incidents
.filter((i) => i.location_coords)
.map((i) => [i.location_coords!.lat, i.location_coords!.lng] as [number, number]),
],
[nodes, incidents]
);
const center: [number, number] =
nodes.length > 0
? [nodes[0].lat, nodes[0].lon]
: allPositions.length > 0
? allPositions[0]
: [39.5, -98.35];
const zoom = nodes.length > 0 ? 10 : allPositions.length > 0 ? 14 : 4;
const handleFitAll = useCallback(() => {
if (!mapInstance || allPositions.length === 0) return;
if (allPositions.length === 1) {
mapInstance.setView(allPositions[0], 14);
} else {
mapInstance.fitBounds(L.latLngBounds(allPositions), { padding: [40, 40] });
}
}, [mapInstance, allPositions]);
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, 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 (
<div className="relative w-full h-full">
{/* ── Map container ───────────────────────────────────────────────────── */}
<MapContainer
center={center}
zoom={zoom}
className="w-full h-full"
style={{ background: "var(--map-bg)" }}
>
<MapRefCapture onReady={onMapReady} />
<LayersControl position="topright">
{/* Base layers */}
<LayersControl.BaseLayer checked name="Dark">
<TileLayer
url={MAP_TILE_URL}
attribution={MAP_TILE_ATTRIBUTION}
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name="Light">
<TileLayer
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
attribution={MAP_TILE_ATTRIBUTION}
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer name="Streets">
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
/>
</LayersControl.BaseLayer>
{/* Overlay: Nodes */}
<LayersControl.Overlay checked name="Nodes">
<FeatureGroup>
<FanNodeLayer nodes={nodes} activeCalls={activeCalls} />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlay: Active Incidents */}
<LayersControl.Overlay checked name="Active Incidents">
<FeatureGroup>
<FanIncidentLayer incidents={incidents} onSelect={handleIncidentSelect} selectedId={selectedIncidentId} />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlay: Incident paths — the flagship feature */}
<LayersControl.Overlay checked name="Incident Paths">
<FeatureGroup>
<IncidentPathLayer incidents={incidents} callsByIncident={callsByIncident} />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlay: Aircraft — node-26#9 second-SDR ADS-B live snapshot, opt-in */}
<LayersControl.Overlay name="Aircraft">
<FeatureGroup>
<AircraftLayer />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlay: Vessels — node-26#9 second-SDR AIS live snapshot, opt-in */}
<LayersControl.Overlay name="Vessels">
<FeatureGroup>
<VesselLayer />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlays: 511NY DOT cameras + traffic events (server-26#183), opt-in */}
<LayersControl.Overlay name={OVERLAY_DOT_CAMERAS}>
<FeatureGroup>
<DotCameraLayer onOpenCamera={onOpenCamera} />
</FeatureGroup>
</LayersControl.Overlay>
<LayersControl.Overlay name={OVERLAY_VIEWSHEDS}>
<FeatureGroup>
<CameraViewshedLayer highlightIds={highlightIds} />
</FeatureGroup>
</LayersControl.Overlay>
<LayersControl.Overlay name={OVERLAY_TRAFFIC_EVENTS}>
<FeatureGroup>
<TrafficEventLayer />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet */}
<LayersControl.Overlay name="Weather Radar">
<RadarLayer />
</LayersControl.Overlay>
</LayersControl>
{selectedIncidentId && (
<NearCameraLayer cameras={nearCameras} head={selectedPoints.at(-1) ?? null} onOpenCamera={onOpenCamera} />
)}
</MapContainer>
{/* ── Live timestamp ───────────────────────────────────────────────────── */}
{ago && (
<div className="absolute top-3 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-accent whitespace-nowrap">
● Live · {ago}
</span>
</div>
)}
{/* ── Map action buttons — top-left, below zoom controls ──────────────── */}
<div className="absolute top-[4.5rem] left-3 z-[1002] flex flex-col gap-1">
{mapInstance && allPositions.length > 0 && (
<button
onClick={handleFitAll}
title="Fit all markers in view"
className="w-8 h-8 bg-surface/90 border border-line rounded text-ink text-base leading-none hover:bg-raised transition-colors flex items-center justify-center select-none"
>
⤢
</button>
)}
</div>
{/* ── Legend — shape-first, both themes. Never a bare colour swatch. ──── */}
<div className="absolute bottom-8 right-3 z-[1001] bg-surface/90 border border-line rounded-lg px-3 py-2.5 text-xs pointer-events-none space-y-2 max-h-[calc(100%-4rem)] overflow-y-auto">
<div className="space-y-1">
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Severity</p>
{(["major", "moderate", "minor", "routine"] as Severity[]).map((sev) => (
<div key={sev} className="flex items-center gap-2">
<span style={{ width: 16, display: "inline-flex", justifyContent: "center" }}>
{sev === "major" && (
<svg width="12" height="12" viewBox="0 0 16 16"><polygon points="8,1.5 14.5,14.5 1.5,14.5" fill={SEVERITY_COLORS.major} /></svg>
)}
{sev === "moderate" && (
<svg width="12" height="12" viewBox="0 0 16 16"><polygon points="8,1.5 14.5,14.5 1.5,14.5" fill="none" stroke={SEVERITY_COLORS.moderate} strokeWidth={1.75} /></svg>
)}
{sev === "minor" && (
<svg width="12" height="12" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6" fill="none" stroke={SEVERITY_COLORS.minor} strokeWidth={1.75} /></svg>
)}
{sev === "routine" && (
<svg width="12" height="12" viewBox="0 0 16 16" style={{ opacity: 0.6 }}><circle cx="8" cy="8" r="6" fill="none" stroke={SEVERITY_COLORS.routine} strokeWidth={1.25} /></svg>
)}
</span>
<span className="text-ink-2">{SEVERITY_LABEL[sev]}</span>
</div>
))}
</div>
{aircraftShown && (
<div className="border-t border-line pt-1.5 space-y-1">
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Aircraft altitude</p>
<div
className="h-2 w-32 rounded-sm border border-line"
style={{ background: `linear-gradient(to right, ${ALTITUDE_LEGEND_TICKS.map(([ft], i) => `${altitudeColor(ft)} ${(i / (ALTITUDE_LEGEND_TICKS.length - 1)) * 100}%`).join(", ")})` }}
/>
<div className="flex justify-between w-32 text-[10px] text-ink-2 tabular-nums">
{ALTITUDE_LEGEND_TICKS.map(([ft, label]) => <span key={ft}>{label}</span>)}
</div>
</div>
)}
<div className="border-t border-line pt-1.5 space-y-1">
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Nodes</p>
{([
["recording", "Recording"],
["online", "Online"],
["offline", "Offline"],
["unconfigured", "Unconfigured"],
] as [NodeStatus, string][]).map(([status, label]) => (
<div key={status} className="flex items-center gap-2">
<span style={{ width: 16, display: "inline-flex", justifyContent: "center" }}>
<svg width="11" height="11" viewBox="0 0 11 11" dangerouslySetInnerHTML={{ __html: nodeDiamondSvg(status, 11) }} />
</span>
<span className="text-ink-2">{label}</span>
</div>
))}
</div>
</div>
{/* ── Incident overlay panel ───────────────────────────────────────────── */}
{incidents.length > 0 && (
<>
{/* Desktop: left sidebar — offset below the zoom stack + fit-all button
so it never overlaps the Leaflet +/- controls (#118). Height is
capped and the list scrolls on its own, so the rail never reaches
the bottom-right legend. pointer-events are off on the wrapper and
back on for the cards, so the map still pans in the gaps. */}
<div className="absolute top-[9.5rem] left-3 z-[1001] hidden md:flex flex-col w-56 gap-1.5 max-h-[calc(100%-12rem)] pointer-events-none">
{/* Gate A / A2 (server-26#46) — the rail's titles, locations and
unit counts are pipeline output. Pinned above the scroll area
so it cannot be scrolled off the screen it qualifies. */}
<div className="bg-surface/90 backdrop-blur-sm border border-line rounded-lg px-2 py-1.5 shrink-0 pointer-events-auto">
<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">
{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 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;
// "Play latest" (server-26#233): newest call of this incident in
// the loaded calls that has a recording.
const latest = (callsByIncident.get(inc.incident_id) ?? [])
.filter(hasAudio)
.reduce<CallRecord | null>((best, c) => (!best || c.started_at > best.started_at ? c : best), null);
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">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke={color} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" dangerouslySetInnerHTML={{ __html: TYPE_GLYPH_PATHS[inc.type === "accident" ? "collision" : (inc.type && TYPE_GLYPH_PATHS[inc.type] ? inc.type : "other")] }} />
<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"}
</p>
{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>}
{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 && !onSelectIncident && (
<p className="text-[10px] text-accent mt-1">View details →</p>
)}
</>
);
// The play control is a sibling of the card, not inside it — a
// <button> can't nest in the card's <button>/<a>.
const playLatest = latest && (
<PlayButton callId={latest.call_id} label="Play latest call" className="absolute bottom-2 right-2 shadow" />
);
if (inc.location_coords || onSelectIncident) {
return (
<div key={inc.incident_id} className="relative shrink-0">
<button
onClick={() => handleIncidentSelect(inc)}
aria-pressed={selected}
className={`${baseClass} ${latest ? "pr-10" : ""}`}
style={{ borderColor: color + "55" }}
>
{cardBody}
</button>
{playLatest}
</div>
);
}
return (
<div key={inc.incident_id} className="relative shrink-0">
<a
href={`/incidents/${inc.incident_id}`}
className={`block ${baseClass} ${latest ? "pr-10" : ""}`}
style={{ borderColor: color + "55" }}
>
{cardBody}
</a>
{playLatest}
</div>
);
})}
</div>
</div>
{/* Mobile: bottom drawer */}
<div className="absolute bottom-0 left-0 right-0 z-[1001] md:hidden">
<button
onClick={() => setDrawerOpen((v: boolean) => !v)}
className="w-full bg-surface/95 border-t border-line px-4 py-2 text-xs text-ink-2 flex items-center justify-between"
>
<span>Incidents ({incidents.length})</span>
<span>{drawerOpen ? "▼" : "▲"}</span>
</button>
{drawerOpen && (
<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" />
{railIncidents.map((inc) => {
const color = severityColor(inc.severity);
const label = (
<>
<span className="font-semibold" style={{ color }}>
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"}
</span>
{" — "}
<span className="text-ink">{inc.title ?? "Incident"}</span>
</>
);
if (inc.location_coords || onSelectIncident) {
return (
<button
key={inc.incident_id}
onClick={() => {
setDrawerOpen(false);
handleIncidentSelect(inc);
}}
className="w-full text-left border rounded px-2 py-1.5 text-xs"
style={{ borderColor: color + "55" }}
>
{label}
</button>
);
}
return (
<a
key={inc.incident_id}
href={`/incidents/${inc.incident_id}`}
className="block w-full text-left border rounded px-2 py-1.5 text-xs"
style={{ borderColor: color + "55" }}
>
{label}
</a>
);
})}
</div>
)}
</div>
</>
)}
</div>
);
}