"use client"; import { useCallback, useEffect, useMemo, useState } from "react"; import { FeatureGroup, LayersControl, MapContainer, Marker, Popup, TileLayer, useMap, } from "react-leaflet"; import L from "leaflet"; import type { CallRecord, IncidentRecord, NodeRecord } from "@/lib/types"; // ── 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", }); // ── Colors ──────────────────────────────────────────────────────────────────── const INCIDENT_COLORS: Record = { fire: "#ef4444", police: "#3b82f6", ems: "#eab308", accident: "#f97316", other: "#6b7280", }; function statusColor(status: string): string { if (status === "online" || status === "recording") return "#4ade80"; if (status === "unconfigured") return "#818cf8"; return "#6b7280"; } // ── Single-node icon (with optional pulsing ring for recording) ─────────────── function nodeIcon(status: string): L.DivIcon { const isRec = status === "recording"; const color = statusColor(status); const ring = isRec ? `
` : ""; return L.divIcon({ className: "", html: `
${ring}
`, iconSize: [14, 14], iconAnchor: [7, 7], }); } function incidentIcon(type: string | null): L.DivIcon { const color = INCIDENT_COLORS[type ?? "other"] ?? INCIDENT_COLORS.other; return L.divIcon({ className: "", html: `
!
`, iconSize: [16, 16], iconAnchor: [8, 8], }); } // ── Fan / hand-of-cards icons for clustered markers ─────────────────────────── 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 `
`; }) .join(""); return L.divIcon({ className: "", html: `
${cards}
`, iconSize: [totalW, CARD + 6], iconAnchor: [totalW / 2, CARD + 6], }); } function incidentFanIcon(members: IncidentRecord[]): L.DivIcon { const n = members.length; const CARD = 14; const STEP = 8; 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 = INCIDENT_COLORS[m.type ?? "other"] ?? INCIDENT_COLORS.other; return `
!
`; }) .join(""); return L.divIcon({ className: "", html: `
${cards}
`, iconSize: [totalW, CARD + 6], iconAnchor: [totalW / 2, CARD + 6], }); } // ── 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)} eventHandlers={{ click: () => onSelect(repPlot.inc) }} >
{members.map((inc, idx) => (

{inc.title ?? "Incident"}

{inc.type ?? "other"}

{inc.location &&

{inc.location}

} View incident →
))}
); })} ); } // ── 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[]; lastUpdated?: Date | null; } export default function MapView({ nodes, activeCalls, incidents = [], lastUpdated }: Props) { const [mapInstance, setMapInstance] = useState(null); const [drawerOpen, setDrawerOpen] = useState(false); const [agoClock, setAgoClock] = useState(0); const [radarEpoch, setRadarEpoch] = useState(() => Date.now()); const [clockStr, setClockStr] = useState(() => new Date().toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" }) ); 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); }, []); // Live clock for TOC situational awareness useEffect(() => { const id = setInterval(() => setClockStr(new Date().toLocaleTimeString([], { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit" })), 1000 ); return () => clearInterval(id); }, []); // eslint-disable-next-line react-hooks/exhaustive-deps const ago = useMemo(() => (lastUpdated ? timeAgo(lastUpdated) : null), [lastUpdated, agoClock]); 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: Weather Radar — NEXRAD via Iowa Env Mesonet; key forces remount on refresh */} {/* Overlay: ADS-B — placeholder for future integration */} {/* Overlay: Meshtastic — placeholder for future integration */} {/* ── Live timestamp ───────────────────────────────────────────────────── */} {ago && (
● Live · {ago}
)} {/* ── Map action buttons — top-left, below zoom controls ──────────────── */}
{mapInstance && allPositions.length > 0 && ( )}
{/* ── Clock — bottom-left for TOC situational awareness ───────────────── */}
{clockStr}
{/* ── Legend — bottom-right to avoid incident panel on left ────────────── */}
Online
Recording
Unconfigured
Offline
Fire
Police
EMS
Accident
{/* ── Incident overlay panel ───────────────────────────────────────────── */} {incidents.length > 0 && ( <> {/* Desktop: left sidebar — kept away from the LayersControl (topright) */}
{incidents.map((inc) => { const color = INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other; const age = inc.started_at ? timeAgo(new Date(inc.started_at)) : null; const unitCount = inc.units?.length ?? 0; return ( ); })}
{/* Mobile: bottom drawer */}
{drawerOpen && (
{incidents.map((inc) => { const color = INCIDENT_COLORS[inc.type ?? "other"] ?? INCIDENT_COLORS.other; return ( ); })}
)}
)}
); }