GET /traffic/511 serves a bbox slice of the statewide 511NY camera and event feeds from an in-memory cache (cameras 1h, events 2m TTL, fetched lazily) -- public data, so no Firestore writes. A failed refresh keeps the last good data and reports the error; a schema change (nothing parses) is an error, not an empty layer. Frontend: opt-in "DOT Cameras" and "Traffic Events" overlays that fetch only while shown, with an on-map notice when the feed is down or stale. NY511_API_KEY is optional in config (the API answers without one today; the terms require a registered key). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1185 lines
53 KiB
TypeScript
1185 lines
53 KiB
TypeScript
"use client";
|
||
|
||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||
import { createPortal } from "react-dom";
|
||
import {
|
||
FeatureGroup,
|
||
LayersControl,
|
||
MapContainer,
|
||
Marker,
|
||
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 type { Ny511Event, Ny511FeedStatus } from "@/lib/types";
|
||
|
||
// ── 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 = "© 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>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ── 511NY traffic layers (server-26#183) ─────────────────────────────────────
|
||
// Overlay names double as the event keys for overlayadd/overlayremove.
|
||
const OVERLAY_DOT_CAMERAS = "DOT Cameras";
|
||
const OVERLAY_TRAFFIC_EVENTS = "Traffic Events";
|
||
|
||
/** True while the named LayersControl overlay is checked. Overlays start unchecked. */
|
||
function useOverlayShown(map: L.Map, name: string): boolean {
|
||
const [shown, setShown] = useState(false);
|
||
useEffect(() => {
|
||
const on = (e: L.LayersControlEvent) => e.name === name && setShown(true);
|
||
const off = (e: L.LayersControlEvent) => e.name === name && setShown(false);
|
||
map.on("overlayadd", on);
|
||
map.on("overlayremove", off);
|
||
return () => { map.off("overlayadd", on); map.off("overlayremove", off); };
|
||
}, [map, name]);
|
||
return shown;
|
||
}
|
||
|
||
function cameraIcon(): L.DivIcon {
|
||
return L.divIcon({
|
||
className: "",
|
||
html: `<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>`,
|
||
iconSize: [16, 12],
|
||
iconAnchor: [8, 6],
|
||
});
|
||
}
|
||
|
||
// Shape + glyph per 511 event type, so the layer reads without colour alone.
|
||
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" };
|
||
|
||
function trafficEventIcon(type: string): L.DivIcon {
|
||
const st = EVENT_STYLE[type] ?? EVENT_STYLE_OTHER;
|
||
return L.divIcon({
|
||
className: "",
|
||
html: `<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>`,
|
||
iconSize: [16, 16],
|
||
iconAnchor: [8, 8],
|
||
});
|
||
}
|
||
|
||
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?.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(),
|
||
);
|
||
}
|
||
|
||
function DotCameraLayer() {
|
||
const map = useMap();
|
||
const shown = useOverlayShown(map, OVERLAY_DOT_CAMERAS);
|
||
const { data, error } = use511(map, "cameras", shown);
|
||
return (
|
||
<>
|
||
{shown && <FeedProblem map={map} label="DOT cameras" status={data.cameras_status} fetchError={error} />}
|
||
{(data.cameras ?? []).map((c) => (
|
||
<Marker key={c.id} position={[c.lat, c.lon]} icon={cameraIcon()}>
|
||
<Popup minWidth={260} maxWidth={340}>
|
||
<div className="space-y-1">
|
||
<div className="font-semibold">{c.name}</div>
|
||
{c.roadway && <div className="text-xs text-ink-muted">{c.roadway}{c.direction && c.direction !== "Unknown" ? ` · ${c.direction}` : ""}</div>}
|
||
{c.image_url && (
|
||
// Popup content mounts on open, so the timestamp busts the cache per open.
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img src={`${c.image_url}?t=${Date.now()}`} alt={`Camera: ${c.name}`} className="w-full rounded border border-line" loading="lazy" />
|
||
)}
|
||
<div className="text-[10px] text-ink-muted">Snapshot: 511NY / NYSDOT</div>
|
||
</div>
|
||
</Popup>
|
||
</Marker>
|
||
))}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function TrafficEventLayer() {
|
||
const map = useMap();
|
||
const shown = useOverlayShown(map, OVERLAY_TRAFFIC_EVENTS);
|
||
const { data, error } = use511(map, "events", shown);
|
||
return (
|
||
<>
|
||
{shown && <FeedProblem map={map} label="Traffic events" status={data.events_status} fetchError={error} />}
|
||
{(data.events ?? []).map((e: Ny511Event) => {
|
||
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={e.type === "accidentsAndIncidents" ? 200 : 0}>
|
||
<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>}
|
||
<div className="text-xs whitespace-pre-line">{e.description}</div>
|
||
<div className="text-[10px] text-ink-muted">511NY{e.updated_local ? ` · updated ${fmtLocal(e.updated_local)}` : ""}</div>
|
||
</div>
|
||
</Popup>
|
||
</Marker>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function nodeFanIcon(members: NodeRecord[]): L.DivIcon {
|
||
const n = members.length;
|
||
const CARD = 13;
|
||
const STEP = 5;
|
||
const totalW = CARD + (n - 1) * STEP;
|
||
const maxRot = Math.min(28, n * 7);
|
||
const cards = members
|
||
.map((m, i) => {
|
||
const ratio = n === 1 ? 0 : i / (n - 1) - 0.5;
|
||
const rot = ratio * maxRot;
|
||
const left = i * STEP;
|
||
return `<div style="position:absolute;width:${CARD}px;height:${CARD}px;left:${left}px;top:0;transform:rotate(${rot}deg);transform-origin:bottom center;"><svg width="${CARD}" height="${CARD}" viewBox="0 0 ${CARD} ${CARD}">${nodeDiamondSvg(m.status, CARD)}</svg></div>`;
|
||
})
|
||
.join("");
|
||
return L.divIcon({
|
||
className: "",
|
||
html: `<div style="position:relative;width:${totalW}px;height:${CARD + 6}px">${cards}</div>`,
|
||
iconSize: [totalW, CARD + 6],
|
||
iconAnchor: [totalW / 2, CARD + 6],
|
||
});
|
||
}
|
||
|
||
// ── Incident pin — teardrop filled by severity, type glyph knocked out ───────
|
||
// Minor/routine (no alarm colour) render hollow with an ink stroke so the map
|
||
// doesn't imply urgency that isn't there.
|
||
function incidentIcon(type: string | null, severity: string | null | undefined): L.DivIcon {
|
||
const color = severityColor(severity);
|
||
const hollow = !isKnownSeverity(severity) || severity === "minor" || severity === "routine";
|
||
const glyphColor = hollow ? color : "var(--page)";
|
||
const fill = hollow ? "var(--surface)" : color;
|
||
const stroke = hollow ? color : "var(--page)";
|
||
const svg = `
|
||
<svg width="28" height="36" viewBox="0 0 28 36">
|
||
<path d="M14 1C6.8 1 1 6.8 1 14c0 9.5 13 21 13 21s13-11.5 13-21C27 6.8 21.2 1 14 1Z"
|
||
fill="${fill}" stroke="${stroke}" stroke-width="2"/>
|
||
<g transform="translate(7.5,7.5)">${typeGlyphSvg(type, glyphColor, 13)}</g>
|
||
</svg>`;
|
||
return L.divIcon({ className: "", html: svg, iconSize: [28, 36], iconAnchor: [14, 34] });
|
||
}
|
||
|
||
function incidentFanIcon(members: IncidentRecord[]): L.DivIcon {
|
||
const n = members.length;
|
||
const CARD = 16;
|
||
const STEP = 9;
|
||
const totalW = CARD + (n - 1) * STEP;
|
||
const maxRot = Math.min(28, n * 7);
|
||
const cards = members
|
||
.map((m, i) => {
|
||
const ratio = n === 1 ? 0 : i / (n - 1) - 0.5;
|
||
const rot = ratio * maxRot;
|
||
const left = i * STEP;
|
||
const color = severityColor(m.severity);
|
||
const hollow = !isKnownSeverity(m.severity) || m.severity === "minor" || m.severity === "routine";
|
||
return `<div style="position:absolute;width:${CARD}px;height:${CARD}px;border-radius:3px;background:${hollow ? "var(--surface)" : color};border:1.5px solid ${color};left:${left}px;top:0;transform:rotate(${rot}deg);transform-origin:bottom center;box-shadow:0 1px 3px rgba(0,0,0,0.35);display:flex;align-items:center;justify-content:center;">${typeGlyphSvg(m.type, hollow ? color : "var(--page)", 10)}</div>`;
|
||
})
|
||
.join("");
|
||
return L.divIcon({
|
||
className: "",
|
||
html: `<div style="position:relative;width:${totalW}px;height:${CARD + 6}px">${cards}</div>`,
|
||
iconSize: [totalW, CARD + 6],
|
||
iconAnchor: [totalW / 2, CARD + 6],
|
||
});
|
||
}
|
||
|
||
// ── Incident path stop marker — numbered, shared index with the call spine ───
|
||
function pathStopIcon(index: number, isFirst: boolean, isLast: boolean, color: string): L.DivIcon {
|
||
const size = 22;
|
||
const fill = isFirst ? "var(--surface)" : color;
|
||
const textColor = isFirst ? color : "var(--page)";
|
||
const halo = isLast ? `<circle cx="11" cy="11" r="10.5" fill="none" stroke="${color}" stroke-width="2" opacity="0.5"/>` : "";
|
||
return L.divIcon({
|
||
className: "",
|
||
html: `<svg width="${size}" height="${size}" viewBox="0 0 22 22">
|
||
${halo}
|
||
<circle cx="11" cy="11" r="8" fill="${fill}" stroke="${color}" stroke-width="2"/>
|
||
<text x="11" y="14.5" text-anchor="middle" font-size="10" font-weight="600" fill="${textColor}" font-family="var(--font-sans)">${index}</text>
|
||
</svg>`,
|
||
iconSize: [size, size],
|
||
iconAnchor: [size / 2, size / 2],
|
||
});
|
||
}
|
||
|
||
// ── 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,
|
||
}: {
|
||
incidents: IncidentRecord[];
|
||
onSelect: (inc: IncidentRecord) => void;
|
||
}) {
|
||
const map = useMap();
|
||
const [tick, setTick] = useState(0);
|
||
useEffect(() => {
|
||
const h = () => setTick((t: number) => t + 1);
|
||
map.on("zoomend moveend", h);
|
||
return () => { map.off("zoomend moveend", h); };
|
||
}, [map]);
|
||
|
||
const plotted = useMemo(
|
||
() =>
|
||
incidents
|
||
.filter((i) => i.location_coords)
|
||
.map((i) => ({
|
||
id: i.incident_id,
|
||
lat: i.location_coords!.lat,
|
||
lng: i.location_coords!.lng,
|
||
inc: i,
|
||
})),
|
||
[incidents]
|
||
);
|
||
|
||
const incById = useMemo(
|
||
() => new Map(plotted.map((p: { id: string; lat: number; lng: number; inc: IncidentRecord }) => [p.id, p.inc])),
|
||
[plotted]
|
||
);
|
||
|
||
const groups = useMemo(
|
||
() => computeGroups(plotted, map),
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
[plotted, map, tick]
|
||
);
|
||
|
||
return (
|
||
<>
|
||
{(Array.from(groups.entries()) as Array<[string, { id: string; lat: number; lng: number }[]]>).map(([repId, raw]) => {
|
||
const members = raw.map((r) => incById.get(r.id)!).filter(Boolean);
|
||
const repPlot = plotted.find((p: { id: string }) => p.id === repId);
|
||
if (!repPlot) return null;
|
||
return (
|
||
<Marker
|
||
key={repId}
|
||
position={[repPlot.lat, repPlot.lng]}
|
||
icon={members.length > 1 ? incidentFanIcon(members) : incidentIcon(repPlot.inc.type, repPlot.inc.severity)}
|
||
eventHandlers={{ click: () => onSelect(repPlot.inc) }}
|
||
>
|
||
<Popup minWidth={180}>
|
||
<div className="space-y-2">
|
||
{members.map((inc, idx) => {
|
||
const color = severityColor(inc.severity);
|
||
return (
|
||
<div
|
||
key={inc.incident_id}
|
||
className={idx < members.length - 1 ? "border-b border-gray-200 pb-2" : ""}
|
||
>
|
||
<p className="font-semibold text-sm text-gray-900">{inc.title ?? "Incident"}</p>
|
||
<p className="text-xs capitalize font-medium" style={{ color }}>
|
||
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"} · {inc.type ?? "other"}
|
||
</p>
|
||
{inc.location && <p className="text-xs text-gray-600">{inc.location}</p>}
|
||
<a
|
||
href={`/incidents/${inc.incident_id}`}
|
||
onClick={(e) => { e.stopPropagation(); window.location.href = `/incidents/${inc.incident_id}`; e.preventDefault(); }}
|
||
className="text-xs text-blue-600 hover:underline block mt-0.5"
|
||
>
|
||
View incident →
|
||
</a>
|
||
</div>
|
||
);
|
||
})}
|
||
{/* Gate A / A2 (server-26#46) — same screen as the output. */}
|
||
<MachineOutputNotice variant="popup" />
|
||
</div>
|
||
</Popup>
|
||
</Marker>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ── Incident path layer — the flagship feature: a pursuit as a polyline ──────
|
||
// Per UI_REDESIGN.md §2.4/§5.1: an incident's map stops use the SAME index as
|
||
// its call timeline, so "where was he when he said that" is answered by
|
||
// looking, not cross-referencing timestamps. Needs no backend — per-call
|
||
// location_coords are already written by intelligence.py.
|
||
function IncidentPathLayer({
|
||
incidents,
|
||
callsByIncident,
|
||
}: {
|
||
incidents: IncidentRecord[];
|
||
callsByIncident: Map<string, CallRecord[]>;
|
||
}) {
|
||
return (
|
||
<>
|
||
{incidents.map((inc) => {
|
||
const calls = (callsByIncident.get(inc.incident_id) ?? [])
|
||
.filter((c) => c.location_coords)
|
||
.slice()
|
||
.sort((a, b) => a.started_at.localeCompare(b.started_at));
|
||
if (calls.length < 2) return null;
|
||
const color = severityColor(inc.severity);
|
||
const positions = calls.map((c) => [c.location_coords!.lat, c.location_coords!.lng] as [number, number]);
|
||
return (
|
||
<FeatureGroup key={inc.incident_id}>
|
||
<Polyline
|
||
positions={positions}
|
||
pathOptions={{ color, weight: 3, opacity: 0.85, lineJoin: "round" }}
|
||
/>
|
||
{calls.map((c, i) => (
|
||
<Marker
|
||
key={c.call_id}
|
||
position={[c.location_coords!.lat, c.location_coords!.lng]}
|
||
icon={pathStopIcon(i + 1, i === 0, i === calls.length - 1, color)}
|
||
>
|
||
<Popup minWidth={160}>
|
||
<div className="text-gray-900">
|
||
<p className="text-xs text-gray-500">Stop {i + 1} of {calls.length}</p>
|
||
<p className="font-semibold text-sm mt-0.5">{inc.title ?? "Incident"}</p>
|
||
{c.location && <p className="text-xs text-gray-600 mt-0.5">{c.location}</p>}
|
||
<a href={`/incidents/${inc.incident_id}`} className="text-xs text-blue-600 hover:underline block mt-1">
|
||
View incident →
|
||
</a>
|
||
{/* Gate A / A2 (server-26#46) — the stop location and its
|
||
ordering come from the transcript, not from GPS. */}
|
||
<MachineOutputNotice variant="popup" />
|
||
</div>
|
||
</Popup>
|
||
</Marker>
|
||
))}
|
||
</FeatureGroup>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||
function timeAgo(date: Date): string {
|
||
const s = Math.floor((Date.now() - date.getTime()) / 1000);
|
||
if (s < 60) return `${s}s ago`;
|
||
if (s < 3600) return `${Math.floor(s / 60)}m ago`;
|
||
return `${Math.floor(s / 3600)}h ago`;
|
||
}
|
||
|
||
// ── 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;
|
||
}
|
||
|
||
export default function MapView({ nodes, activeCalls, incidents = [], calls = [], lastUpdated }: Props) {
|
||
const [mapInstance, setMapInstance] = useState<L.Map | null>(null);
|
||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||
const [agoClock, setAgoClock] = useState(0);
|
||
const [radarEpoch, setRadarEpoch] = useState(() => Date.now());
|
||
const [aircraftShown, setAircraftShown] = useState(false);
|
||
|
||
// The altitude key only belongs in the legend while the opt-in Aircraft
|
||
// overlay is actually on (server-26#185).
|
||
useEffect(() => {
|
||
if (!mapInstance) return;
|
||
const on = (e: L.LayersControlEvent) => e.name === "Aircraft" && setAircraftShown(true);
|
||
const off = (e: L.LayersControlEvent) => e.name === "Aircraft" && setAircraftShown(false);
|
||
mapInstance.on("overlayadd", on);
|
||
mapInstance.on("overlayremove", off);
|
||
return () => {
|
||
mapInstance.off("overlayadd", on);
|
||
mapInstance.off("overlayremove", off);
|
||
};
|
||
}, [mapInstance]);
|
||
|
||
useEffect(() => {
|
||
const id = setInterval(() => setAgoClock((t: number) => t + 1), 10_000);
|
||
return () => clearInterval(id);
|
||
}, []);
|
||
|
||
// Radar tiles are static once loaded — force remount every 5 min to refresh
|
||
useEffect(() => {
|
||
const id = setInterval(() => setRadarEpoch(Date.now()), 5 * 60 * 1000);
|
||
return () => clearInterval(id);
|
||
}, []);
|
||
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]);
|
||
|
||
const callsByIncident = useMemo(() => {
|
||
const map = new Map<string, CallRecord[]>();
|
||
for (const c of calls) {
|
||
const ids = c.incident_ids?.length ? c.incident_ids : c.incident_id ? [c.incident_id] : [];
|
||
for (const id of ids) {
|
||
if (!map.has(id)) map.set(id, []);
|
||
map.get(id)!.push(c);
|
||
}
|
||
}
|
||
return map;
|
||
}, [calls]);
|
||
|
||
const allPositions = useMemo(
|
||
() => [
|
||
...nodes.map((n) => [n.lat, n.lon] as [number, number]),
|
||
...incidents
|
||
.filter((i) => i.location_coords)
|
||
.map((i) => [i.location_coords!.lat, i.location_coords!.lng] as [number, number]),
|
||
],
|
||
[nodes, incidents]
|
||
);
|
||
|
||
const center: [number, number] =
|
||
nodes.length > 0
|
||
? [nodes[0].lat, nodes[0].lon]
|
||
: allPositions.length > 0
|
||
? allPositions[0]
|
||
: [39.5, -98.35];
|
||
|
||
const zoom = nodes.length > 0 ? 10 : allPositions.length > 0 ? 14 : 4;
|
||
|
||
const handleFitAll = useCallback(() => {
|
||
if (!mapInstance || allPositions.length === 0) return;
|
||
if (allPositions.length === 1) {
|
||
mapInstance.setView(allPositions[0], 14);
|
||
} else {
|
||
mapInstance.fitBounds(L.latLngBounds(allPositions), { padding: [40, 40] });
|
||
}
|
||
}, [mapInstance, allPositions]);
|
||
|
||
const handleIncidentSelect = useCallback(
|
||
(inc: IncidentRecord) => {
|
||
if (!mapInstance || !inc.location_coords) return;
|
||
mapInstance.flyTo([inc.location_coords.lat, inc.location_coords.lng], 15, { duration: 1.2 });
|
||
},
|
||
[mapInstance]
|
||
);
|
||
|
||
const onMapReady = useCallback((m: L.Map) => setMapInstance(m), []);
|
||
|
||
return (
|
||
<div className="relative w-full h-full">
|
||
{/* ── Map container ───────────────────────────────────────────────────── */}
|
||
<MapContainer
|
||
center={center}
|
||
zoom={zoom}
|
||
className="w-full h-full"
|
||
style={{ background: "var(--map-bg)" }}
|
||
>
|
||
<MapRefCapture onReady={onMapReady} />
|
||
|
||
<LayersControl position="topright">
|
||
{/* Base layers */}
|
||
<LayersControl.BaseLayer checked name="Dark">
|
||
<TileLayer
|
||
url={MAP_TILE_URL}
|
||
attribution={MAP_TILE_ATTRIBUTION}
|
||
/>
|
||
</LayersControl.BaseLayer>
|
||
<LayersControl.BaseLayer name="Light">
|
||
<TileLayer
|
||
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
||
attribution={MAP_TILE_ATTRIBUTION}
|
||
/>
|
||
</LayersControl.BaseLayer>
|
||
<LayersControl.BaseLayer name="Streets">
|
||
<TileLayer
|
||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||
/>
|
||
</LayersControl.BaseLayer>
|
||
|
||
{/* Overlay: Nodes */}
|
||
<LayersControl.Overlay checked name="Nodes">
|
||
<FeatureGroup>
|
||
<FanNodeLayer nodes={nodes} activeCalls={activeCalls} />
|
||
</FeatureGroup>
|
||
</LayersControl.Overlay>
|
||
|
||
{/* Overlay: Active Incidents */}
|
||
<LayersControl.Overlay checked name="Active Incidents">
|
||
<FeatureGroup>
|
||
<FanIncidentLayer incidents={incidents} onSelect={handleIncidentSelect} />
|
||
</FeatureGroup>
|
||
</LayersControl.Overlay>
|
||
|
||
{/* Overlay: Incident paths — the flagship feature */}
|
||
<LayersControl.Overlay checked name="Incident Paths">
|
||
<FeatureGroup>
|
||
<IncidentPathLayer incidents={incidents} callsByIncident={callsByIncident} />
|
||
</FeatureGroup>
|
||
</LayersControl.Overlay>
|
||
|
||
{/* Overlay: Aircraft — node-26#9 second-SDR ADS-B live snapshot, opt-in */}
|
||
<LayersControl.Overlay name="Aircraft">
|
||
<FeatureGroup>
|
||
<AircraftLayer />
|
||
</FeatureGroup>
|
||
</LayersControl.Overlay>
|
||
|
||
{/* Overlay: Vessels — node-26#9 second-SDR AIS live snapshot, opt-in */}
|
||
<LayersControl.Overlay name="Vessels">
|
||
<FeatureGroup>
|
||
<VesselLayer />
|
||
</FeatureGroup>
|
||
</LayersControl.Overlay>
|
||
|
||
{/* Overlays: 511NY DOT cameras + traffic events (server-26#183), opt-in */}
|
||
<LayersControl.Overlay name={OVERLAY_DOT_CAMERAS}>
|
||
<FeatureGroup>
|
||
<DotCameraLayer />
|
||
</FeatureGroup>
|
||
</LayersControl.Overlay>
|
||
<LayersControl.Overlay name={OVERLAY_TRAFFIC_EVENTS}>
|
||
<FeatureGroup>
|
||
<TrafficEventLayer />
|
||
</FeatureGroup>
|
||
</LayersControl.Overlay>
|
||
|
||
{/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */}
|
||
<LayersControl.Overlay name="Weather Radar">
|
||
<TileLayer
|
||
key={radarEpoch}
|
||
url="https://mesonet.agron.iastate.edu/cache/tile.py/1.0.0/nexrad-n0r-900913/{z}/{x}/{y}.png"
|
||
attribution='Radar © <a href="https://mesonet.agron.iastate.edu/">IEM/NWS</a>'
|
||
opacity={0.65}
|
||
/>
|
||
</LayersControl.Overlay>
|
||
</LayersControl>
|
||
</MapContainer>
|
||
|
||
{/* ── Live timestamp ───────────────────────────────────────────────────── */}
|
||
{ago && (
|
||
<div className="absolute top-3 left-1/2 -translate-x-1/2 z-[1001] pointer-events-none">
|
||
<span className="bg-surface/90 border border-line rounded-full px-3 py-1 text-xs text-accent whitespace-nowrap">
|
||
● Live · {ago}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* ── Map action buttons — top-left, below zoom controls ──────────────── */}
|
||
<div className="absolute top-[4.5rem] left-3 z-[1002] flex flex-col gap-1">
|
||
{mapInstance && allPositions.length > 0 && (
|
||
<button
|
||
onClick={handleFitAll}
|
||
title="Fit all markers in view"
|
||
className="w-8 h-8 bg-surface/90 border border-line rounded text-ink text-base leading-none hover:bg-raised transition-colors flex items-center justify-center select-none"
|
||
>
|
||
⤢
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* ── Legend — shape-first, both themes. Never a bare colour swatch. ──── */}
|
||
<div className="absolute bottom-8 right-3 z-[1001] bg-surface/90 border border-line rounded-lg px-3 py-2.5 text-xs pointer-events-none space-y-2 max-h-[calc(100%-4rem)] overflow-y-auto">
|
||
<div className="space-y-1">
|
||
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Severity</p>
|
||
{(["major", "moderate", "minor", "routine"] as Severity[]).map((sev) => (
|
||
<div key={sev} className="flex items-center gap-2">
|
||
<span style={{ width: 16, display: "inline-flex", justifyContent: "center" }}>
|
||
{sev === "major" && (
|
||
<svg width="12" height="12" viewBox="0 0 16 16"><polygon points="8,1.5 14.5,14.5 1.5,14.5" fill={SEVERITY_COLORS.major} /></svg>
|
||
)}
|
||
{sev === "moderate" && (
|
||
<svg width="12" height="12" viewBox="0 0 16 16"><polygon points="8,1.5 14.5,14.5 1.5,14.5" fill="none" stroke={SEVERITY_COLORS.moderate} strokeWidth={1.75} /></svg>
|
||
)}
|
||
{sev === "minor" && (
|
||
<svg width="12" height="12" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6" fill="none" stroke={SEVERITY_COLORS.minor} strokeWidth={1.75} /></svg>
|
||
)}
|
||
{sev === "routine" && (
|
||
<svg width="12" height="12" viewBox="0 0 16 16" style={{ opacity: 0.6 }}><circle cx="8" cy="8" r="6" fill="none" stroke={SEVERITY_COLORS.routine} strokeWidth={1.25} /></svg>
|
||
)}
|
||
</span>
|
||
<span className="text-ink-2">{SEVERITY_LABEL[sev]}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
{aircraftShown && (
|
||
<div className="border-t border-line pt-1.5 space-y-1">
|
||
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Aircraft altitude</p>
|
||
<div
|
||
className="h-2 w-32 rounded-sm border border-line"
|
||
style={{ background: `linear-gradient(to right, ${ALTITUDE_LEGEND_TICKS.map(([ft], i) => `${altitudeColor(ft)} ${(i / (ALTITUDE_LEGEND_TICKS.length - 1)) * 100}%`).join(", ")})` }}
|
||
/>
|
||
<div className="flex justify-between w-32 text-[10px] text-ink-2 tabular-nums">
|
||
{ALTITUDE_LEGEND_TICKS.map(([ft, label]) => <span key={ft}>{label}</span>)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="border-t border-line pt-1.5 space-y-1">
|
||
<p className="text-ink-muted font-medium text-[10px] uppercase tracking-wide">Nodes</p>
|
||
{([
|
||
["recording", "Recording"],
|
||
["online", "Online"],
|
||
["offline", "Offline"],
|
||
["unconfigured", "Unconfigured"],
|
||
] as [NodeStatus, string][]).map(([status, label]) => (
|
||
<div key={status} className="flex items-center gap-2">
|
||
<span style={{ width: 16, display: "inline-flex", justifyContent: "center" }}>
|
||
<svg width="11" height="11" viewBox="0 0 11 11" dangerouslySetInnerHTML={{ __html: nodeDiamondSvg(status, 11) }} />
|
||
</span>
|
||
<span className="text-ink-2">{label}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* ── Incident overlay panel ───────────────────────────────────────────── */}
|
||
{incidents.length > 0 && (
|
||
<>
|
||
{/* Desktop: left sidebar — offset below the zoom stack + fit-all button
|
||
so it never overlaps the Leaflet +/- controls (#118). Height is
|
||
capped and the list scrolls on its own, so the rail never reaches
|
||
the bottom-right legend. pointer-events are off on the wrapper and
|
||
back on for the cards, so the map still pans in the gaps. */}
|
||
<div className="absolute top-[9.5rem] left-3 z-[1001] hidden md:flex flex-col w-56 gap-1.5 max-h-[calc(100%-12rem)] pointer-events-none">
|
||
{/* Gate A / A2 (server-26#46) — the rail's titles, locations and
|
||
unit counts are pipeline output. Pinned above the scroll area
|
||
so it cannot be scrolled off the screen it qualifies. */}
|
||
<div className="bg-surface/90 backdrop-blur-sm border border-line rounded-lg px-2 py-1.5 shrink-0 pointer-events-auto">
|
||
<MachineOutputNotice variant="inline" className="text-[10px] leading-snug items-start" />
|
||
</div>
|
||
<div className="flex flex-col gap-1.5 overflow-y-auto min-h-0 pointer-events-auto">
|
||
{incidents.map((inc) => {
|
||
const color = severityColor(inc.severity);
|
||
const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null;
|
||
const unitCount = inc.units_active?.length ?? inc.units?.length ?? 0;
|
||
const baseClass = "w-full text-left bg-surface/90 backdrop-blur-sm border rounded-lg px-3 py-2 text-xs hover:brightness-110 transition-all";
|
||
const 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>
|
||
</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>
|
||
)}
|
||
<div className="flex items-center justify-between mt-0.5">
|
||
{age && <span className="text-ink-muted font-mono">{age}</span>}
|
||
{unitCount > 0 && (
|
||
<span className="text-ink-muted font-mono">{unitCount} unit{unitCount !== 1 ? "s" : ""}</span>
|
||
)}
|
||
</div>
|
||
{!inc.location_coords && (
|
||
<p className="text-[10px] text-accent mt-1">View details →</p>
|
||
)}
|
||
</>
|
||
);
|
||
if (inc.location_coords) {
|
||
return (
|
||
<button
|
||
key={inc.incident_id}
|
||
onClick={() => handleIncidentSelect(inc)}
|
||
className={baseClass}
|
||
style={{ borderColor: color + "55" }}
|
||
>
|
||
{cardBody}
|
||
</button>
|
||
);
|
||
}
|
||
return (
|
||
<a
|
||
key={inc.incident_id}
|
||
href={`/incidents/${inc.incident_id}`}
|
||
className={`block ${baseClass}`}
|
||
style={{ borderColor: color + "55" }}
|
||
>
|
||
{cardBody}
|
||
</a>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Mobile: bottom drawer */}
|
||
<div className="absolute bottom-0 left-0 right-0 z-[1001] md:hidden">
|
||
<button
|
||
onClick={() => setDrawerOpen((v: boolean) => !v)}
|
||
className="w-full bg-surface/95 border-t border-line px-4 py-2 text-xs text-ink-2 flex items-center justify-between"
|
||
>
|
||
<span>Incidents ({incidents.length})</span>
|
||
<span>{drawerOpen ? "▼" : "▲"}</span>
|
||
</button>
|
||
{drawerOpen && (
|
||
<div className="bg-surface/95 border-t border-line max-h-52 overflow-y-auto px-3 py-2 space-y-1.5">
|
||
{/* Gate A / A2 (server-26#46) */}
|
||
<MachineOutputNotice variant="inline" className="text-[10px] items-start" />
|
||
{incidents.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) {
|
||
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>
|
||
);
|
||
}
|