Files
server-26/drb-frontend/components/MapView.tsx
T
Logan CusanoandClaude Opus 5.5 6bf072973c Honest pins and paths: geocode precision, type-gated paths, impossible-speed stops (#236)
Backend: _geocode_location now returns {lat, lng, location_type}, so the
precision rides onto the call doc and, unchanged via _resolve_location_pair,
onto the incident pin. Display-only: every correlator read indexes lat/lng.

Frontend: pins styled by precision (rooftop / interpolated ring /
GEOMETRIC_CENTER dashed uncertainty circle labelled with the spoken place /
unverified pre-#23 pins ghosted). liveWall.pathStops() is shared by the map
and the call spine: only police/accident incidents with a pursuit tag draw a
numbered path; others get unnumbered call dots. Segments over >10 min gaps or
talkgroup changes are dashed, stops implying >8 km/min are ghosted off the
path, paths >20 km get a "may be merged" badge. New optional MapView prop
pulseIncidentIds (wired by #238).

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

1901 lines
92 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 { createContext, useCallback, useContext, 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 {
BASE_NAMES, DEFAULT_BASE, DEFAULT_LAYERS, LAYER_NAMES, NEAR_CAMERA_KM, baseKeyForName, groupCallsByIncident,
MAY_BE_MERGED_KM, PATH_GAP_DASH_MIN, incidentPoints, layerKeyForName, normaliseLayers, pathStops, roundViewport,
viewshedWedge, worstFirst,
type BaseKey, type LayerKey, type MapViewport,
} from "@/lib/liveWall";
import { OutageLayer, outageGlyphSvg } from "@/components/map/OutageLayer";
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 = LAYER_NAMES.dotcam;
const OVERLAY_TRAFFIC_EVENTS = LAYER_NAMES.traffic;
const OVERLAY_VIEWSHEDS = LAYER_NAMES.vs;
// Power outages (server-26#184). Not in LAYER_NAMES yet — lib/liveWall.ts was
// owned by #235 when this landed — so it isn't saved in the URL hash and always
// starts off. Adding `outage: "Power Outages"` to LAYER_NAMES and `checked=
// {layerOn("outage")}` below makes it persist like the rest.
const OVERLAY_OUTAGES = "Power Outages";
/**
* Overlay names checked when the map mounts (server-26#234 saved views).
* Needed because react-leaflet adds a `checked` overlay to the map BEFORE it
* registers it with the LayersControl, so no `overlayadd` fires for it — a
* layer that waited for that event would sit empty until toggled.
*/
const InitialOverlays = createContext<ReadonlySet<string>>(new Set(DEFAULT_LAYERS.map((k) => LAYER_NAMES[k])));
/** True while the named LayersControl overlay is checked (initially: whether it mounted checked). */
function useOverlayShown(map: L.Map, name: string): boolean {
const initial = useContext(InitialOverlays);
const [shown, setShown] = useState(() => initial.has(name));
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],
});
}
// ── Pin precision (server-26#236) ─────────────────────────────────────────────
// A pin is only as exact as its geocode. `location_type` rides on the coords
// since #236; `location_coords_source` (#23) ties a pin to the label it came
// from — an incident without it predates that fix and its pin is unverifiable
// (5-in-6 wrong on the 2026-08 dump; DEFERRED.md `_verified_pin` row, #28).
type PinPrecision = "rooftop" | "interpolated" | "centre" | "unknown" | "unverified";
function pinPrecision(inc: IncidentRecord): PinPrecision {
if (!inc.location_coords_source) return "unverified";
switch (inc.location_coords?.location_type) {
case "ROOFTOP": return "rooftop";
case "RANGE_INTERPOLATED": return "interpolated";
case "GEOMETRIC_CENTER": return "centre";
default: return "unknown"; // geocoded before #236 persisted the type
}
}
/** Nominal uncertainty drawn round a GEOMETRIC_CENTER pin. Google gives no error radius; this is a visual "around here", not a measurement. */
const CENTRE_UNCERTAINTY_M = 250;
const PIN_PRECISION_LABEL: Record<PinPrecision, string> = {
rooftop: "Street address (rooftop)",
interpolated: "Interpolated along the block",
centre: "Centre of a road, junction or place",
unknown: "Precision not recorded",
unverified: "Unverified pin (older incident)",
};
// ── 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. Precision is carried by what sits at
// the tip (server-26#236): nothing = rooftop; a small solid ring = interpolated;
// a wide dashed ring = centre of a road/place; the whole pin ghosted and
// dashed = unverified. `pulse` adds a pulsing ring round the head.
function teardropSvg(type: string | null, severity: string | null | undefined, precision: PinPrecision = "rooftop"): string {
const color = severityColor(severity);
const unverified = precision === "unverified";
const hollow = unverified || !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 dash = unverified ? ` stroke-dasharray="3 2"` : "";
const tip =
precision === "interpolated" ? `<circle cx="16" cy="37" r="4.5" fill="none" stroke="${color}" stroke-width="1.75"/>`
: precision === "centre" ? `<circle cx="16" cy="37" r="8.5" fill="none" stroke="${color}" stroke-width="1.5" stroke-dasharray="2.5 2"/>`
: "";
return `<svg width="32" height="48" viewBox="0 0 32 48" style="overflow:visible${unverified ? ";opacity:0.55" : ""}">
${tip}
<path d="M16 3C8.8 3 3 8.8 3 16c0 9.5 13 21 13 21s13-11.5 13-21C29 8.8 23.2 3 16 3Z"
fill="${fill}" stroke="${stroke}" stroke-width="2"${dash}/>
<g transform="translate(9.5,9.5)">${typeGlyphSvg(type, glyphColor, 13)}</g>
</svg>`;
}
function incidentIcon(
type: string | null, severity: string | null | undefined, precision: PinPrecision = "rooftop", pulse = false,
): L.DivIcon {
const ring = pulse
? `<div class="node-pulse-ring" style="position:absolute;width:26px;height:26px;border-radius:50%;border:2px solid ${severityColor(severity)};left:3px;top:3px;pointer-events:none;"></div>`
: "";
return L.divIcon({
className: "",
html: `<div style="position:relative;width:32px;height:48px">${ring}${teardropSvg(type, severity, precision)}</div>`,
iconSize: [32, 48],
iconAnchor: [16, 37],
});
}
function incidentFanIcon(members: IncidentRecord[], pulse = false): 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">${pulse
? `<div class="node-pulse-ring" style="position:absolute;width:${CARD + 4}px;height:${CARD + 4}px;border-radius:50%;border:2px solid var(--accent);left:${totalW / 2 - CARD / 2 - 2}px;top:-2px;pointer-events:none;"></div>`
: ""}${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,
pulseIds,
}: {
incidents: IncidentRecord[];
onSelect: (inc: IncidentRecord) => void;
selectedId?: string | null;
/** Incident pins that get a pulsing ring (generic — the caller decides why). */
pulseIds?: Set<string>;
}) {
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 (
<>
{/* GEOMETRIC_CENTER pins (server-26#236): a dashed "around here" ring on
the ground, labelled with the place as it was spoken. */}
{plotted
.filter((p) => pinPrecision(p.inc) === "centre")
.map((p) => (
<Circle
key={`unc-${p.id}`}
center={[p.lat, p.lng]}
radius={CENTRE_UNCERTAINTY_M}
interactive={!!p.inc.location}
pathOptions={{ color: severityColor(p.inc.severity), weight: 1.5, dashArray: "4 4", fillOpacity: 0.06 }}
>
{p.inc.location && (
<Tooltip direction="bottom" permanent={p.id === selectedId} opacity={0.9}>
≈ {p.inc.location}
</Tooltip>
)}
</Circle>
))}
{(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, members.some((m) => pulseIds?.has(m.incident_id)))
: incidentIcon(repPlot.inc.type, repPlot.inc.severity, pinPrecision(repPlot.inc), !!pulseIds?.has(repPlot.inc.incident_id))}
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>}
<p className="text-[11px] text-gray-500">Pin: {PIN_PRECISION_LABEL[pinPrecision(inc)]}</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.
//
// server-26#236 — honest paths. Stops come from liveWall.pathStops(), the same
// helper the call spine numbers from:
// • only a movement incident (police/accident with a pursuit tag) draws a
// numbered path; anything else draws small unnumbered call dots, so a fire
// with mutual aid "responding from Station 3" never reads as a chase;
// • a segment over a long silence or across a talkgroup change is dashed;
// • a stop that implies > 8 km/min is ghosted and left off the path, with a
// faint dotted spur so the bad geocode is still visible;
// • a path longer than MAY_BE_MERGED_KM carries a "may be merged" badge.
function ghostStopIcon(color: string): L.DivIcon {
return L.divIcon({
className: "",
html: `<svg width="16" height="16" viewBox="0 0 16 16" style="opacity:0.55">
<circle cx="8" cy="8" r="5.5" fill="var(--surface)" stroke="${color}" stroke-width="1.5" stroke-dasharray="2 2"/>
<text x="8" y="11" text-anchor="middle" font-size="8" font-weight="600" fill="${color}" font-family="var(--font-sans)">?</text>
</svg>`,
iconSize: [16, 16],
iconAnchor: [8, 8],
});
}
function callDotIcon(color: string, playing: boolean): L.DivIcon {
const size = playing ? 18 : 10;
const c = size / 2;
return L.divIcon({
className: "",
html: `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}">
${playing ? `<circle cx="${c}" cy="${c}" r="${c - 1.5}" fill="none" stroke="var(--accent)" stroke-width="2.5"/>` : ""}
<circle cx="${c}" cy="${c}" r="3.5" fill="${color}" stroke="var(--page)" stroke-width="1.25"/>
</svg>`,
iconSize: [size, size],
iconAnchor: [c, c],
});
}
function mayBeMergedIcon(lengthKm: number): L.DivIcon {
const label = `may be merged · ${Math.round(lengthKm)} km`;
const w = 8 + label.length * 5.6;
return L.divIcon({
className: "",
html: `<div style="width:${w}px;font:600 10px var(--font-sans);color:var(--ink-2);background:var(--surface);border:1px dashed var(--ink-muted);border-radius:4px;padding:1px 4px;white-space:nowrap;text-align:center">${label}</div>`,
iconSize: [w, 16],
iconAnchor: [w / 2, -10],
});
}
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);
const paths = useMemo(
() => incidents.map((inc) => ({ inc, path: pathStops(inc, callsByIncident.get(inc.incident_id)) })),
[incidents, callsByIncident],
);
return (
<>
{paths.map(({ inc, path }) => {
if (path.stops.length < 2) return null;
const color = severityColor(inc.severity);
const kept = path.stops.filter((s) => !s.ghost);
const numbered = kept.length;
// Segments between consecutive kept stops — movement incidents only.
const segments = path.movement
? kept.slice(1).map((s, i) => ({ from: kept[i].at, to: s.at, dashed: s.dashedIn, key: s.call.call_id }))
: [];
// A ghost's spur runs from the kept stop before it (or after it, for a ghosted first stop).
const spurs = path.stops.flatMap((s, i) => {
if (!s.ghost) return [];
const before = path.stops.slice(0, i).reverse().find((x) => !x.ghost);
const anchor = before ?? path.stops.slice(i + 1).find((x) => !x.ghost);
return anchor ? [{ from: anchor.at, to: s.at, key: s.call.call_id }] : [];
});
const head = kept.at(-1) ?? path.stops.at(-1)!;
return (
<FeatureGroup key={inc.incident_id}>
{segments.map((seg) => (
<Polyline
key={`seg-${seg.key}`}
positions={[seg.from, seg.to]}
pathOptions={{ color, weight: 3, opacity: seg.dashed ? 0.7 : 0.85, lineJoin: "round", dashArray: seg.dashed ? "6 7" : undefined }}
/>
))}
{spurs.map((sp) => (
<Polyline
key={`spur-${sp.key}`}
positions={[sp.from, sp.to]}
interactive={false}
pathOptions={{ color, weight: 1.5, opacity: 0.35, dashArray: "1 5", lineCap: "round" }}
/>
))}
{path.stops.map((s) => {
const c = s.call;
const isPlaying = c.call_id === playingCallId;
const icon = s.ghost
? ghostStopIcon(color)
: s.n != null
? pathStopIcon(s.n, s.n === 1, s.n === numbered, color, isPlaying)
: callDotIcon(color, isPlaying);
const heading = s.ghost
? "Off the path — this location implies an impossible speed"
: s.n != null ? `Stop ${s.n} of ${numbered}` : `Call at ${new Date(c.started_at).toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}`;
return (
<Marker
key={c.call_id}
position={s.at}
icon={icon}
zIndexOffset={isPlaying ? 1000 : s.ghost ? -100 : 0}
>
<Popup minWidth={160}>
<div className="text-gray-900">
<p className="text-xs text-gray-500">{heading}</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>}
{c.location_coords?.location_type === "GEOMETRIC_CENTER" && (
<p className="text-[11px] text-gray-500">Approximate: centre of a road, junction or place</p>
)}
{s.dashedIn && !s.ghost && path.movement && (
<p className="text-[11px] text-gray-500">Dashed in: long gap or talkgroup change — route unknown</p>
)}
{hasAudio(c) && (
<div className="flex items-center gap-1.5 mt-1">
<PlayButton callId={c.call_id} label={s.n != null ? `Play stop ${s.n}` : "Play this call"} />
<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>
);
})}
{path.mayBeMerged && (
<Marker position={head.at} icon={mayBeMergedIcon(path.lengthKm)} interactive={false} keyboard={false} />
)}
</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>;
/**
* Incident pins that get a pulsing ring. Generic on purpose — the caller
* decides what deserves attention (server-26#236; wired by #238).
*/
pulseIncidentIds?: Set<string>;
// ── Saved views (server-26#234). Read once at mount — remount (key) to apply new ones. ──
/** Overlays switched on at mount; default = Nodes, Active Incidents, Incident Paths. */
initialLayers?: LayerKey[];
initialBase?: BaseKey;
/** Viewport at mount; default = centred on the first node (or first incident). */
initialView?: MapViewport;
/**
* Fired (debounced) when overlays, basemap or viewport change. `view` is the
* REGION view: while an incident is selected the map is fitted to it, so the
* view last seen with nothing selected is reported instead.
*/
onViewStateChange?: (state: MapViewState) => void;
}
export interface MapViewState { layers: LayerKey[]; base: BaseKey; view?: MapViewport }
const VIEW_STATE_DEBOUNCE_MS = 400;
function currentViewport(map: L.Map): MapViewport {
const c = map.getCenter();
return roundViewport({ z: map.getZoom(), lat: c.lat, lon: c.lng });
}
export default function MapView({
nodes, activeCalls, incidents = [], calls = [], lastUpdated,
selectedIncidentId = null, onSelectIncident, nearCameras = NO_NEAR, onOpenCamera, onAir, camCountByIncident,
pulseIncidentIds, initialLayers, initialBase, initialView, onViewStateChange,
}: Props) {
const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [agoClock, setAgoClock] = useState(0);
// Initial props are frozen at mount, like MapContainer's own center/zoom.
const [startLayers] = useState(() => new Set<LayerKey>(normaliseLayers(initialLayers ?? DEFAULT_LAYERS)));
const [startBase] = useState<BaseKey>(() => initialBase ?? DEFAULT_BASE);
const [startView] = useState(() => initialView);
const startOverlayNames = useMemo(() => new Set(Array.from(startLayers).map((k) => LAYER_NAMES[k])), [startLayers]);
const layerOn = (k: LayerKey) => startLayers.has(k);
const [aircraftShown, setAircraftShown] = useState(() => startLayers.has("air"));
const [outagesShown, setOutagesShown] = useState(false);
// The altitude key only belongs in the legend while the opt-in Aircraft
// overlay is actually on (server-26#185); same for the outage key (#184).
useEffect(() => {
if (!mapInstance) return;
const on = (e: L.LayersControlEvent) => {
if (e.name === LAYER_NAMES.air) setAircraftShown(true);
if (e.name === OVERLAY_OUTAGES) setOutagesShown(true);
};
const off = (e: L.LayersControlEvent) => {
if (e.name === LAYER_NAMES.air) setAircraftShown(false);
if (e.name === OVERLAY_OUTAGES) setOutagesShown(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] = startView
? [startView.lat, startView.lon]
: nodes.length > 0
? [nodes[0].lat, nodes[0].lon]
: allPositions.length > 0
? allPositions[0]
: [39.5, -98.35];
const zoom = startView ? startView.z : 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]
);
// ── Region view + saved-view reporting (server-26#234) ────────────────────
// `regionView` is where the map was the last time nothing was selected. It
// is what gets persisted (a reload shouldn't come back zoomed into one
// incident) and where closing an incident flies back to (server-26#137).
// Declared before the fit effect below so it captures the view before the fly.
const regionView = useRef<MapViewport | null>(null);
const selectedRef = useRef(selectedIncidentId);
selectedRef.current = selectedIncidentId;
const onViewStateRef = useRef(onViewStateChange);
onViewStateRef.current = onViewStateChange;
useEffect(() => {
if (!mapInstance) return;
regionView.current = currentViewport(mapInstance);
const layers = new Set(startLayers);
let base = startBase;
let timer: ReturnType<typeof setTimeout> | undefined;
const emit = () => {
clearTimeout(timer);
timer = setTimeout(() => {
onViewStateRef.current?.({ layers: normaliseLayers(Array.from(layers)), base, view: regionView.current ?? undefined });
}, VIEW_STATE_DEBOUNCE_MS);
};
const onAdd = (e: L.LayersControlEvent) => { const k = layerKeyForName(e.name); if (k) { layers.add(k); emit(); } };
const onRemove = (e: L.LayersControlEvent) => { const k = layerKeyForName(e.name); if (k) { layers.delete(k); emit(); } };
const onBase = (e: L.LayersControlEvent) => { base = baseKeyForName(e.name) ?? base; emit(); };
const onMove = () => {
if (selectedRef.current) return;
regionView.current = currentViewport(mapInstance);
emit();
};
mapInstance.on("overlayadd", onAdd);
mapInstance.on("overlayremove", onRemove);
mapInstance.on("baselayerchange", onBase);
mapInstance.on("moveend", onMove);
// Report the starting state once, so the URL carries the viewport even if
// the viewer never pans: a copied link must not fall back to the other
// browser's remembered view.
emit();
return () => {
clearTimeout(timer);
mapInstance.off("overlayadd", onAdd);
mapInstance.off("overlayremove", onRemove);
mapInstance.off("baselayerchange", onBase);
mapInstance.off("moveend", onMove);
};
}, [mapInstance, startLayers, startBase]);
// server-26#137: closing an incident flies back to where the viewer was.
const prevSelected = useRef(selectedIncidentId);
useEffect(() => {
const prev = prevSelected.current;
prevSelected.current = selectedIncidentId;
if (!mapInstance || !prev || selectedIncidentId || !regionView.current) return;
const v = regionView.current;
mapInstance.flyTo([v.lat, v.lon], v.z, { duration: 1.0 });
}, [mapInstance, selectedIncidentId]);
// 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} />
<InitialOverlays.Provider value={startOverlayNames}>
<LayersControl position="topright">
{/* Base layers */}
<LayersControl.BaseLayer checked={startBase === "dark"} name={BASE_NAMES.dark}>
<TileLayer
url={MAP_TILE_URL}
attribution={MAP_TILE_ATTRIBUTION}
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer checked={startBase === "light"} name={BASE_NAMES.light}>
<TileLayer
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
attribution={MAP_TILE_ATTRIBUTION}
/>
</LayersControl.BaseLayer>
<LayersControl.BaseLayer checked={startBase === "streets"} name={BASE_NAMES.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={layerOn("nodes")} name={LAYER_NAMES.nodes}>
<FeatureGroup>
<FanNodeLayer nodes={nodes} activeCalls={activeCalls} />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlay: Active Incidents */}
<LayersControl.Overlay checked={layerOn("inc")} name={LAYER_NAMES.inc}>
<FeatureGroup>
<FanIncidentLayer incidents={incidents} onSelect={handleIncidentSelect} selectedId={selectedIncidentId} pulseIds={pulseIncidentIds} />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlay: Incident paths — the flagship feature */}
<LayersControl.Overlay checked={layerOn("paths")} name={LAYER_NAMES.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 checked={layerOn("air")} name={LAYER_NAMES.air}>
<FeatureGroup>
<AircraftLayer />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlay: Vessels — node-26#9 second-SDR AIS live snapshot, opt-in */}
<LayersControl.Overlay checked={layerOn("ais")} name={LAYER_NAMES.ais}>
<FeatureGroup>
<VesselLayer />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlays: 511NY DOT cameras + traffic events (server-26#183), opt-in */}
<LayersControl.Overlay checked={layerOn("dotcam")} name={OVERLAY_DOT_CAMERAS}>
<FeatureGroup>
<DotCameraLayer onOpenCamera={onOpenCamera} />
</FeatureGroup>
</LayersControl.Overlay>
<LayersControl.Overlay checked={layerOn("vs")} name={OVERLAY_VIEWSHEDS}>
<FeatureGroup>
<CameraViewshedLayer highlightIds={highlightIds} />
</FeatureGroup>
</LayersControl.Overlay>
<LayersControl.Overlay checked={layerOn("traffic")} name={OVERLAY_TRAFFIC_EVENTS}>
<FeatureGroup>
<TrafficEventLayer />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlay: Power outages (server-26#184) — licence-gated providers, opt-in */}
<LayersControl.Overlay checked={false} name={OVERLAY_OUTAGES}>
<FeatureGroup>
<OutageLayer shown={outagesShown} />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet */}
<LayersControl.Overlay checked={layerOn("radar")} name={LAYER_NAMES.radar}>
<RadarLayer />
</LayersControl.Overlay>
</LayersControl>
</InitialOverlays.Provider>
{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>
{/* Pin precision + path honesty (server-26#236). Collapsed by default:
on the 352px incident-page map the full key would overflow a legend
that can't scroll (pointer-events are off so the map pans under it). */}
<details className="border-t border-line pt-1.5 pointer-events-auto group">
<summary className="cursor-pointer list-none text-ink-muted font-medium text-[10px] uppercase tracking-wide select-none">
<span className="group-open:hidden">▸</span><span className="hidden group-open:inline">▾</span> Pin precision &amp; paths
</summary>
<div className="space-y-1 mt-1">
{(["rooftop", "interpolated", "centre", "unverified"] as PinPrecision[]).map((p) => (
<div key={p} className="flex items-center gap-2">
<span style={{ width: 16, height: 20, display: "inline-flex", justifyContent: "center", alignItems: "center" }}
dangerouslySetInnerHTML={{ __html: teardropSvg(null, "major", p).replace('width="32" height="48"', 'width="13" height="20"') }} />
<span className="text-ink-2">{p === "rooftop" ? "Street address" : p === "interpolated" ? "Interpolated" : p === "centre" ? "Road/place centre ≈" : "Unverified (older)"}</span>
</div>
))}
</div>
<div className="pt-1 space-y-1">
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Paths</p>
<div className="flex items-center gap-2">
<span style={{ width: 16, display: "inline-flex", justifyContent: "center" }}>
<svg width="14" height="14" viewBox="0 0 14 14"><circle cx="7" cy="7" r="5.5" fill="var(--ink-2)" /><text x="7" y="10" textAnchor="middle" fontSize="8" fontWeight={600} fill="var(--page)">1</text></svg>
</span>
<span className="text-ink-2">Pursuit stop (numbered)</span>
</div>
<div className="flex items-center gap-2">
<span style={{ width: 16, display: "inline-flex", justifyContent: "center" }}>
<svg width="16" height="6" viewBox="0 0 16 6"><line x1="0" y1="3" x2="16" y2="3" stroke="var(--ink-2)" strokeWidth={2} strokeDasharray="4 3" /></svg>
</span>
<span className="text-ink-2">Gap &gt; {PATH_GAP_DASH_MIN} min or TG change</span>
</div>
<div className="flex items-center gap-2">
<span style={{ width: 16, display: "inline-flex", justifyContent: "center", opacity: 0.55 }}>
<svg width="12" height="12" viewBox="0 0 12 12"><circle cx="6" cy="6" r="4.5" fill="none" stroke="var(--ink-2)" strokeWidth={1.25} strokeDasharray="2 2" /></svg>
</span>
<span className="text-ink-2">Impossible speed — off path</span>
</div>
<div className="flex items-center gap-2">
<span style={{ width: 16, display: "inline-flex", justifyContent: "center" }}>
<svg width="8" height="8" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3" fill="var(--ink-2)" /></svg>
</span>
<span className="text-ink-2">Call location (not a pursuit)</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[9px] px-0.5 rounded border border-dashed border-line text-ink-2 leading-tight">merged?</span>
<span className="text-ink-2">Path &gt; {MAY_BE_MERGED_KM} km — may be 2+ events</span>
</div>
</div>
</details>
{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>
)}
{outagesShown && (
<div className="border-t border-line pt-1.5 space-y-1">
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Power outages</p>
<div className="flex items-center gap-2">
<span style={{ width: 16, display: "inline-flex", justifyContent: "center" }} dangerouslySetInnerHTML={{ __html: outageGlyphSvg(14) }} />
<span className="text-ink-2">County with outages · size = meters out</span>
</div>
<div className="flex items-center gap-2">
<span style={{ width: 16, display: "inline-flex", justifyContent: "center", opacity: 0.6 }} dangerouslySetInnerHTML={{ __html: outageGlyphSvg(14, true) }} />
<span className="text-ink-2">Stale upstream rows only</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>
);
}