Files
server-26/drb-frontend/components/MapView.tsx
T
Logan CusanoandClaude Opus 5.5 9698d463ad Live map follow-ups: stable pin icons, resolved IncidentPanel, filtered rail, weather vs camera dock
- #279: cache incident/fan divIcons by their visual inputs and React.memo
  MapView, so LiveView's 1s clock no longer rebuilds icons / calls setIcon
  and the #238 pulse ring completes its cycle.
- #275: IncidentPanel shows "resolved", "lasted X" frozen at resolved_at,
  and labels live cameras/context as current, not incident-time.
- #268: rail header (desktop + mobile drawer) reads "N of M · filtered"
  via a new filteredFrom prop while the wall filter is on.
- #278: while camera tiles are docked bottom-centre, the weather status
  chip and radar badge stack under the tiles inside the dock instead of
  being covered.

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

2238 lines
109 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, memo, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore, type ReactNode } 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, WeatherAlert, WeatherAlertsResponse, WeatherGeometry,
} from "@/lib/types";
import { useMapWeatherAlerts, useRadarMeta, type Polled, type RadarMeta } from "@/lib/useWeather";
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, RADAR_LOOP_OFFSETS_MIN, RADAR_STALE_MIN, n0qColor, radarTileUrl, wxStyle, wxTime, WX_KIND_DASH,
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 n0q; server-26#237) ───────────────────────────────────
// 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.
// The layers live in a FeatureGroup that never remounts; the loop frames are
// that group's children, so adding/removing them is ordinary group membership.
// The buster is the composite's valid time when known (so tiles, badge and
// incident sample move together), else a 5-min clock.
const RADAR_REFRESH_MS = 5 * 60 * 1000;
const RADAR_OPACITY = 0.65;
const RADAR_ATTRIBUTION = 'Radar &copy; <a href="https://mesonet.agron.iastate.edu/">IEM/NWS</a>';
const LOOP_FRAME_MS = 600;
const LOOP_HOLD_FRAMES = 3; // the newest frame is held this many ticks before the loop restarts
const RADAR_LEGEND_DBZ = [5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75];
const RADAR_LEGEND_TICKS = [5, 40, 75]; // evenly spaced over RADAR_LEGEND_DBZ, so justify-between places them right
/**
* The loop's current frame, kept OUTSIDE MapView's state: it ticks every
* LOOP_FRAME_MS, and a MapView re-render rebuilds every marker icon on the map
* (setIcon on each), so only the radar frames and the badge subscribe to it.
*/
function createLoopClock() {
let frame = 0;
let id: ReturnType<typeof setInterval> | undefined;
const subs = new Set<() => void>();
const set = (f: number) => { if (f !== frame) { frame = f; subs.forEach((s) => s()); } };
return {
subscribe: (cb: () => void) => { subs.add(cb); return () => { subs.delete(cb); }; },
get: () => frame,
run(on: boolean) {
clearInterval(id);
id = undefined;
set(0);
if (!on) return;
const last = RADAR_LOOP_OFFSETS_MIN.length - 1;
const cycle = last + LOOP_HOLD_FRAMES;
let tick = 0;
id = setInterval(() => {
if (document.hidden) return;
tick = (tick + 1) % cycle;
set(Math.min(tick, last));
}, LOOP_FRAME_MS);
},
};
}
type LoopClock = ReturnType<typeof createLoopClock>;
const useLoopFrame = (c: LoopClock) => useSyncExternalStore(c.subscribe, c.get, c.get);
function radarBuster(valid: Date | null, clock: number): string {
return `?_=${valid ? valid.getTime() : clock}`;
}
/**
* One n0q tile layer. react-leaflet 4 applies a changed `url` with setUrl on
* the same layer (no remount), which is exactly the in-place refresh above.
*/
function RadarFrame({ offsetMin, buster, opacity }: { offsetMin: number; buster: string; opacity: number }) {
return <TileLayer url={`${radarTileUrl(offsetMin)}${buster}`} attribution={RADAR_ATTRIBUTION} opacity={opacity} />;
}
/**
* Current composite, plus — while `loop` is on — the 11 earlier 5-min frames.
* All frames stay loaded; only the shown one is opaque, so the loop plays
* without re-fetching tiles every cycle.
*/
function RadarLayer({ valid, loop, loopClock }: { valid: Date | null; loop: boolean; loopClock: LoopClock }) {
const frame = useLoopFrame(loopClock);
const [clock, setClock] = useState(() => Math.floor(Date.now() / RADAR_REFRESH_MS));
useEffect(() => {
if (valid) return; // the valid time drives refreshes while it is known
const id = setInterval(() => setClock(Math.floor(Date.now() / RADAR_REFRESH_MS)), 30_000);
return () => clearInterval(id);
}, [valid]);
const buster = radarBuster(valid, clock);
const last = RADAR_LOOP_OFFSETS_MIN.length - 1;
return (
<>
{loop && RADAR_LOOP_OFFSETS_MIN.slice(0, last).map((off, i) => (
<RadarFrame key={off} offsetMin={off} buster={buster} opacity={i === frame ? RADAR_OPACITY : 0} />
))}
<RadarFrame offsetMin={0} buster={buster} opacity={!loop || frame >= last ? RADAR_OPACITY : 0} />
</>
);
}
function hhmmLocal(d: Date): string {
return d.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
}
/** Radar valid-time badge + loop toggle: what time the map's radar shows, and whether that is stale. */
function RadarBadge({ meta, loop, loopClock, onLoop }: {
meta: RadarMeta; loop: boolean; loopClock: LoopClock; onLoop: (on: boolean) => void;
}) {
const frame = useLoopFrame(loopClock);
const [, tick] = useState(0);
useEffect(() => {
const id = setInterval(() => tick((t) => t + 1), 30_000);
return () => clearInterval(id);
}, []);
const ageMin = meta.valid ? Math.max(0, Math.round((Date.now() - meta.valid.getTime()) / 60_000)) : null;
const stale = ageMin == null ? meta.loaded : ageMin > RADAR_STALE_MIN;
const last = RADAR_LOOP_OFFSETS_MIN.length - 1;
const off = loop ? RADAR_LOOP_OFFSETS_MIN[Math.min(frame, last)] : 0;
let text: string;
if (!meta.loaded) text = "Radar: checking valid time…";
else if (!meta.valid) text = `Radar: valid time unknown${meta.error ? " (IEM metadata unreachable)" : ""}`;
else if (loop && off > 0) text = `Radar loop: ${hhmmLocal(new Date(meta.valid.getTime() - off * 60_000))} (−${off} min)`;
else text = `Radar valid ${hhmmLocal(meta.valid)} · ${ageMin} min old`;
return (
<div className={`flex items-center gap-1.5 bg-surface/90 border rounded px-2 py-1 text-xs text-ink-2 pointer-events-auto ${stale ? "border-accent" : "border-line"}`}>
{stale && <span className="bg-accent text-white rounded px-1 text-[9px] font-semibold tracking-wide"
title={`The newest radar frame is more than ${RADAR_STALE_MIN} min old, or its time could not be read`}>STALE</span>}
<span className="tabular-nums whitespace-nowrap">{text}</span>
{meta.valid && meta.error && <span className="text-ink-muted whitespace-nowrap" title={meta.error}>· time not refreshing</span>}
<button type="button" onClick={() => onLoop(!loop)}
title={loop ? "Stop the loop and show the newest frame" : "Play the last hour of radar in 5-min frames"}
className="ml-1 px-1.5 rounded border border-line text-[10px] hover:bg-raised whitespace-nowrap">
{loop ? "■ Stop loop" : "▶ 1 h loop"}
</button>
</div>
);
}
// ── NWS weather warnings (server-26#237) ─────────────────────────────────────
const NWS_CREDIT: CameraCredit = { attribution: "National Weather Service", attribution_url: "https://www.weather.gov/", disclaimer: null };
/** GeoJSON (Multi)Polygon → Leaflet positions ([lon, lat] → [lat, lon]). */
function wxPositions(g: WeatherGeometry): L.LatLngExpression[][] | L.LatLngExpression[][][] {
const ring = (r: number[][]) => r.map((p) => [p[1], p[0]] as L.LatLngTuple);
return g.type === "Polygon" ? g.coordinates.map(ring) : g.coordinates.map((poly) => poly.map(ring));
}
function WeatherAlertPopupBody({ a, fetchedAt }: { a: WeatherAlert; fetchedAt: number | null }) {
const expires = wxTime(a.expires);
const ends = wxTime(a.ends);
const onset = wxTime(a.onset);
return (
<div className="space-y-1 text-xs">
<div className="font-semibold text-sm">{a.event}</div>
{/* Shown as NWS issued it — never summarised or rewritten. */}
{a.headline && <div className="leading-snug">{a.headline}</div>}
<div>
{onset ? `From ${onset}` : ""}{onset && (expires || ends) ? " · " : ""}
{ends ? `ends ${ends}` : ""}{ends && expires ? " · " : ""}{expires ? `expires ${expires}` : ""}
</div>
{a.area_desc && <div className="text-ink-muted leading-snug">{a.area_desc}</div>}
{a.sender_name && <div className="text-ink-muted">Issued by {a.sender_name}</div>}
{a.geometry_source === "zones" && (
<div className="text-ink-muted leading-snug">
Area drawn from the alert&apos;s forecast/county zones{a.zones_unresolved ? ` — ${a.zones_unresolved} zone${a.zones_unresolved !== 1 ? "s" : ""} could not be fetched, so the real area is larger` : ""}.
</div>
)}
<SourceCredit credit={NWS_CREDIT} updated={fetchedLabel(fetchedAt) ? `fetched ${fetchedLabel(fetchedAt)}` : null} />
</div>
);
}
function WeatherWarningsLayer({ data }: { data: WeatherAlertsResponse | null }) {
// Weakest first so warnings draw on top (and win the click where they overlap).
const alerts = useMemo(() => {
const rank = { statement: 0, advisory: 1, watch: 2, warning: 3 } as const;
return (data?.alerts ?? []).slice().sort((a, b) => rank[wxStyle(a.event).kind] - rank[wxStyle(b.event).kind]);
}, [data]);
return (
<>
{alerts.map((a) => {
const st = wxStyle(a.event);
return (
<Polygon key={a.id} positions={wxPositions(a.geometry)}
pathOptions={{ color: st.color, weight: st.weight, dashArray: st.dashArray, fillColor: st.color, fillOpacity: st.kind === "warning" ? 0.16 : 0.07 }}>
<Tooltip sticky>{a.event}{wxTime(a.expires) ? ` · until ${wxTime(a.expires)}` : ""}</Tooltip>
<Popup minWidth={220} maxWidth={340}>
<WeatherAlertPopupBody a={a} fetchedAt={data?.fetched_at ?? null} />
</Popup>
</Polygon>
);
})}
</>
);
}
/**
* Always-present status for the Weather Warnings overlay: never a silently
* empty layer. Feed problems use the 511 FeedProblem wording ("could not reach
* server" / "feed error, showing data from N min ago"); zone-only alerts —
* no shape to draw — are listed here, not dropped.
*/
function WeatherWarningsStatus({ state }: { state: Polled<WeatherAlertsResponse> }) {
const { data, error, loaded } = state;
const zoneOnly = data?.zone_only ?? [];
let msg: string;
let problem = false;
if (!loaded) msg = "Weather Warnings: loading…";
else if (!data) { msg = "Weather Warnings: could not reach server"; problem = true; }
else if (data.error) {
problem = true;
msg = data.fetched_at
? `Weather Warnings: NWS feed error, showing data from ${Math.round((Date.now() / 1000 - data.fetched_at) / 60)} min ago`
: "Weather Warnings: NWS feed error, no data yet";
} else {
const n = data.alerts.length;
msg = n ? `Weather Warnings: ${n} active in view` : "Weather Warnings: no active NWS alert with a mapped area in view";
}
if (data && error) msg += " · could not refresh";
return (
<div className={`bg-surface/90 border rounded px-2 py-1 text-xs text-ink-2 pointer-events-auto max-w-full ${problem ? "border-accent" : "border-line"}`}>
<div>
{msg}
{data?.fetched_at && !data.error ? ` · NWS, fetched ${fetchedLabel(data.fetched_at)}` : ""}
</div>
{zoneOnly.length > 0 && (
<details className="mt-0.5">
<summary className="cursor-pointer select-none text-ink-muted">
{zoneOnly.length} alert{zoneOnly.length !== 1 ? "s" : ""} in the served area with no mapped shape (zone-based)
</summary>
<ul className="mt-1 space-y-0.5 max-h-32 overflow-y-auto">
{zoneOnly.map((a) => (
<li key={a.id} className="leading-snug">
<b>{a.event}</b>{a.area_desc ? ` · ${a.area_desc}` : ""}{wxTime(a.expires) ? ` · until ${wxTime(a.expires)}` : ""}
</li>
))}
</ul>
</details>
)}
</div>
);
}
// ── 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, footer }: {
map: L.Map;
open: Camera[];
credits: Map<string, CameraCredit>;
onMode: (id: string, mode: CameraMode) => void;
onClose: (id: string) => void;
/**
* Stacked under the tiles (server-26#278): the weather status chip and radar
* badge live in the same bottom-centre spot, so while the dock is open they
* ride in its column instead of being covered by it.
*/
footer?: ReactNode;
}) {
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-col items-center gap-2 max-h-[calc(100%-8rem)] pointer-events-none"
>
<div className="flex flex-wrap-reverse justify-center gap-2 min-h-0 max-w-full overflow-y-auto">
{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>
{footer && <div className="shrink-0 flex flex-col items-center gap-1 max-w-full">{footer}</div>}
</div>,
map.getContainer(),
);
}
function DotCameraLayer({ onOpenCamera, dockFooter, onDockedChange }: {
onOpenCamera?: (c: Camera) => void;
dockFooter?: ReactNode;
/** How many tiles are docked bottom-centre, so MapView can move what sits there (server-26#278). */
onDockedChange?: (n: number) => 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));
const docked = useMemo(() => open.filter((o) => o.mode === "dock").map((o) => o.cam), [open]);
useEffect(() => { onDockedChange?.(docked.length); }, [docked.length, onDockedChange]);
useEffect(() => () => onDockedChange?.(0), [onDockedChange]);
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={docked} credits={feeds} onMode={setMode} onClose={close} footer={dockFooter} />
</>
);
}
// ── 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>`;
}
// Incident icons are cached by their visual inputs (server-26#279). react-leaflet
// calls setIcon whenever the icon prop's identity changes, and DivIcon rewrites
// innerHTML — so building a fresh icon on every render replaced every pin's DOM
// on each parent re-render and restarted the 1.8s pulse ring before it finished.
// Same inputs → same L.DivIcon object → no setIcon. Bounded: cleared when large
// (keys are a handful of type/severity/precision/pulse combos plus fan stacks).
const INCIDENT_ICON_CACHE_MAX = 500;
const incidentIconCache = new Map<string, L.DivIcon>();
function cachedIcon(key: string, build: () => L.DivIcon): L.DivIcon {
let icon = incidentIconCache.get(key);
if (!icon) {
if (incidentIconCache.size >= INCIDENT_ICON_CACHE_MAX) incidentIconCache.clear();
icon = build();
incidentIconCache.set(key, icon);
}
return icon;
}
function incidentIcon(
type: string | null, severity: string | null | undefined, precision: PinPrecision = "rooftop", pulse = false,
): L.DivIcon {
return cachedIcon(`pin|${type ?? ""}|${severity ?? ""}|${precision}|${pulse ? 1 : 0}`,
() => buildIncidentIcon(type, severity, precision, pulse));
}
function buildIncidentIcon(type: string | null, severity: string | null | undefined, precision: PinPrecision, pulse: boolean): 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 key = `fan|${pulse ? 1 : 0}|${members.map((m) => `${m.type ?? ""}:${m.severity ?? ""}`).join(",")}`;
return cachedIcon(key, () => buildIncidentFanIcon(members, pulse));
}
function buildIncidentFanIcon(members: IncidentRecord[], pulse: boolean): 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;
/**
* Set while the caller's incident filter is on (server-26#268): the
* unfiltered active count, so the rail header reads "N of M · filtered".
*/
filteredFrom?: number;
}
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 });
}
// Memoised (server-26#279): the live wall re-renders every second for its
// clock; with stable props that tick no longer re-renders the whole map tree.
// MapView keeps its own 10s tick (agoClock) for the rail's relative ages.
export default memo(MapView);
function MapView({
nodes, activeCalls, incidents = [], calls = [], lastUpdated,
selectedIncidentId = null, onSelectIncident, nearCameras = NO_NEAR, onOpenCamera, onAir, camCountByIncident,
pulseIncidentIds, initialLayers, initialBase, initialView, onViewStateChange, filteredFrom,
}: 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);
const [radarShown, setRadarShown] = useState(() => startLayers.has("radar"));
const [wxShown, setWxShown] = useState(() => startLayers.has("wx"));
const [radarLoop, setRadarLoop] = useState(false); // off by default (#237); not persisted
const loopClock = useMemo(createLoopClock, []);
// 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);
if (e.name === LAYER_NAMES.radar) setRadarShown(true);
if (e.name === LAYER_NAMES.wx) setWxShown(true);
};
const off = (e: L.LayersControlEvent) => {
if (e.name === LAYER_NAMES.air) setAircraftShown(false);
if (e.name === OVERLAY_OUTAGES) setOutagesShown(false);
if (e.name === LAYER_NAMES.radar) setRadarShown(false);
if (e.name === LAYER_NAMES.wx) setWxShown(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);
}, []);
// ── Weather (server-26#237): fetched only while the overlay is on ─────────
const radarMeta = useRadarMeta(radarShown);
const wxAlerts = useMapWeatherAlerts(mapInstance, wxShown);
const loopOn = radarShown && radarLoop;
useEffect(() => {
loopClock.run(loopOn);
return () => loopClock.run(false);
}, [loopClock, loopOn]);
// eslint-disable-next-line react-hooks/exhaustive-deps
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
const [dockedCams, setDockedCams] = useState(0);
const weatherStatus = radarShown || wxShown ? (
<>
{wxShown && <WeatherWarningsStatus state={wxAlerts} />}
{radarShown && <RadarBadge meta={radarMeta} loop={radarLoop} loopClock={loopClock} onLoop={setRadarLoop} />}
</>
) : null;
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} dockFooter={weatherStatus} onDockedChange={setDockedCams} />
</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 n0q via Iowa Env Mesonet (server-26#237) */}
<LayersControl.Overlay checked={layerOn("radar")} name={LAYER_NAMES.radar}>
<FeatureGroup>
<RadarLayer valid={radarMeta.valid} loop={loopOn} loopClock={loopClock} />
</FeatureGroup>
</LayersControl.Overlay>
{/* Overlay: NWS watches/warnings/advisories (server-26#237) */}
<LayersControl.Overlay checked={layerOn("wx")} name={LAYER_NAMES.wx}>
<FeatureGroup>
<WeatherWarningsLayer data={wxAlerts.data} />
</FeatureGroup>
</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>
)}
{/* ── Weather status (server-26#237) — bottom centre, clear of the rail
(left), the legend (right) and the 511/outage banners (bottom-left).
While camera tiles are docked in that same spot it moves into the
dock's column, under the tiles, so STALE / feed errors stay visible (#278). */}
{weatherStatus && dockedCams === 0 && (
<div className="absolute bottom-10 md:bottom-8 left-1/2 -translate-x-1/2 z-[1001] flex flex-col items-center gap-1 w-max max-w-[min(30rem,calc(100%-1.5rem))] pointer-events-none">
{weatherStatus}
</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 / not recorded" : 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>
)}
{radarShown && (
<div className="border-t border-line pt-1.5 space-y-1">
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Radar reflectivity (dBZ)</p>
<div
className="h-2 w-32 rounded-sm border border-line"
style={{ background: `linear-gradient(to right, ${RADAR_LEGEND_DBZ.map((d, i) => `${n0qColor(d)} ${(i / (RADAR_LEGEND_DBZ.length - 1)) * 100}%`).join(", ")})` }}
/>
<div className="flex justify-between w-32 text-[10px] text-ink-2 tabular-nums">
{RADAR_LEGEND_TICKS.map((d) => <span key={d}>{d}</span>)}
</div>
<p className="text-[10px] text-ink-muted w-32 leading-snug">~20 light · 40 moderate · 50+ heavy rain / hail</p>
</div>
)}
{wxShown && (
<div className="border-t border-line pt-1.5 space-y-1">
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">NWS alerts</p>
{(["warning", "watch", "advisory"] as const).map((k) => (
<div key={k} className="flex items-center gap-2">
<span style={{ width: 16, display: "inline-flex", justifyContent: "center" }}>
<svg width="16" height="10" viewBox="0 0 16 10"><rect x="1" y="1" width="14" height="8" fill="none" stroke="var(--ink-2)" strokeWidth={k === "warning" ? 2 : 1.5} strokeDasharray={WX_KIND_DASH[k]} /></svg>
</span>
<span className="text-ink-2">{k === "warning" ? "Warning" : k === "watch" ? "Watch" : "Advisory / statement"}</span>
</div>
))}
<p className="text-[10px] text-ink-muted w-32 leading-snug">Fill colour = NWS hazard-map colour for the event</p>
</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">
{filteredFrom != null && (
<p className="text-[11px] text-ink-2 mb-1"><b className="text-ink font-semibold tabular-nums">{incidents.length} of {filteredFrom}</b> · filtered</p>
)}
<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 ({filteredFrom != null ? `${incidents.length} of ${filteredFrom} · filtered` : 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>
);
}