"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { FeatureGroup, LayersControl, MapContainer, Marker, Polyline, Popup, TileLayer, useMap, } from "react-leaflet"; import L from "leaflet"; import type { 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"; // ── Leaflet icon fix ────────────────────────────────────────────────────────── delete (L.Icon.Default.prototype as unknown as Record)._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 = { fire: '', police: '', ems: '', collision: '', other: '', }; function typeGlyphSvg(type: string | null | undefined, color: string, size = 13): string { const key = type === "accident" ? "collision" : type && TYPE_GLYPH_PATHS[type] ? type : "other"; return `${TYPE_GLYPH_PATHS[key]}`; } // ── 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 ``; } if (status === "online") { return ``; } if (status === "unconfigured") { return ``; } return ``; } function nodeIcon(status: NodeStatus): L.DivIcon { const size = 14; const isRec = status === "recording"; const ring = isRec ? `
` : ""; return L.divIcon({ className: "", html: `
${ring}${nodeDiamondSvg(status, size)}
`, iconSize: [size, size], iconAnchor: [size / 2, size / 2], }); } // ── Aircraft icon — node-26#9 second-SDR ADS-B overlay ──────────────────────── function aircraftIcon(trackDeg: number | null): L.DivIcon { const size = 16; const rotation = trackDeg ?? 0; return L.divIcon({ className: "", html: `
`, iconSize: [size, size], iconAnchor: [size / 2, size / 2], }); } function AircraftLayer() { const { aircraft } = useAircraft(); return ( <> {aircraft .filter((a) => a.lat != null && a.lon != null) .map((a) => (
{a.callsign || a.icao}
ICAO {a.icao}
{a.altitude_ft != null &&
Altitude: {Math.round(a.altitude_ft)} ft
} {a.ground_speed_kt != null &&
Speed: {Math.round(a.ground_speed_kt)} kt
}
))} ); } 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 `
${nodeDiamondSvg(m.status, CARD)}
`; }) .join(""); return L.divIcon({ className: "", html: `
${cards}
`, 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 = ` ${typeGlyphSvg(type, glyphColor, 13)} `; 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 `
${typeGlyphSvg(m.type, hollow ? color : "var(--page)", 10)}
`; }) .join(""); return L.divIcon({ className: "", html: `
${cards}
`, 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 ? `` : ""; return L.divIcon({ className: "", html: ` ${halo} ${index} `, iconSize: [size, size], iconAnchor: [size / 2, size / 2], }); } // ── Fan cluster grouping ────────────────────────────────────────────────────── const CLUSTER_PX = 32; function computeGroups( items: T[], map: L.Map ): Map { 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(); withPx.forEach(({ item }, i) => { const root = find(i); if (!groups.has(root)) groups.set(root, []); groups.get(root)!.push(item); }); const result = new Map(); 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 ( 1 ? nodeFanIcon(members) : nodeIcon(rep.status)} >
{members.map((node, idx) => (

{node.name}

{node.node_id}

{node.status}

{activeByNode[node.node_id] && (

● TG {activeByNode[node.node_id].talkgroup_id ?? "—"}{" "} {activeByNode[node.node_id].talkgroup_name}

)}
))}
); })} ); } // ── 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 ( 1 ? incidentFanIcon(members) : incidentIcon(repPlot.inc.type, repPlot.inc.severity)} eventHandlers={{ click: () => onSelect(repPlot.inc) }} >
{members.map((inc, idx) => { const color = severityColor(inc.severity); return (

{inc.title ?? "Incident"}

{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"} · {inc.type ?? "other"}

{inc.location &&

{inc.location}

} { 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 →
); })} {/* Gate A / A2 (server-26#46) — same screen as the output. */}
); })} ); } // ── 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; }) { 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 ( {calls.map((c, i) => (

Stop {i + 1} of {calls.length}

{inc.title ?? "Incident"}

{c.location &&

{c.location}

} View incident → {/* Gate A / A2 (server-26#46) — the stop location and its ordering come from the transcript, not from GPS. */}
))}
); })} ); } // ── 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(null); const [drawerOpen, setDrawerOpen] = useState(false); const [agoClock, setAgoClock] = useState(0); const [radarEpoch, setRadarEpoch] = useState(() => Date.now()); 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(); 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 (
{/* ── Map container ───────────────────────────────────────────────────── */} {/* Base layers */} {/* Overlay: Nodes */} {/* Overlay: Active Incidents */} {/* Overlay: Incident paths — the flagship feature */} {/* Overlay: Aircraft — node-26#9 second-SDR ADS-B live snapshot, opt-in */} {/* Overlay: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */} {/* ── Live timestamp ───────────────────────────────────────────────────── */} {ago && (
● Live · {ago}
)} {/* ── Map action buttons — top-left, below zoom controls ──────────────── */}
{mapInstance && allPositions.length > 0 && ( )}
{/* ── Legend — shape-first, both themes. Never a bare colour swatch. ──── */}

Severity

{(["major", "moderate", "minor", "routine"] as Severity[]).map((sev) => (
{sev === "major" && ( )} {sev === "moderate" && ( )} {sev === "minor" && ( )} {sev === "routine" && ( )} {SEVERITY_LABEL[sev]}
))}

Nodes

{([ ["recording", "Recording"], ["online", "Online"], ["offline", "Offline"], ["unconfigured", "Unconfigured"], ] as [NodeStatus, string][]).map(([status, label]) => (
{label}
))}
{/* ── 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. */}
{/* 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. */}
{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 = ( <>
{isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"}

{inc.title ?? "Incident"}

{inc.location && (

{inc.location}

)}
{age && {age}} {unitCount > 0 && ( {unitCount} unit{unitCount !== 1 ? "s" : ""} )}
{!inc.location_coords && (

View details →

)} ); if (inc.location_coords) { return ( ); } return ( {cardBody} ); })}
{/* Mobile: bottom drawer */}
{drawerOpen && (
{/* Gate A / A2 (server-26#46) */} {incidents.map((inc) => { const color = severityColor(inc.severity); const label = ( <> {isKnownSeverity(inc.severity) ? SEVERITY_LABEL[inc.severity] : "Unknown"} {" — "} {inc.title ?? "Incident"} ); if (inc.location_coords) { return ( ); } return ( {label} ); })}
)}
)}
); }