import { useEffect, useMemo } from 'react' import { MapContainer, TileLayer, CircleMarker, Polyline, Popup, Tooltip, useMap } from 'react-leaflet' import type { LatLngBoundsExpression, LatLngTuple } from 'leaflet' import 'leaflet/dist/leaflet.css' import type { NodeInfo, EdgeInfo } from '@/lib/api' import { ExternalLink, MapPin } from 'lucide-react' // Fix Leaflet default marker icon issue with Vite import L from 'leaflet' import markerIcon from 'leaflet/dist/images/marker-icon.png' import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png' import markerShadow from 'leaflet/dist/images/marker-shadow.png' // @ts-expect-error - Leaflet icon fix delete L.Icon.Default.prototype._getIconUrl L.Icon.Default.mergeOptions({ iconUrl: markerIcon, iconRetinaUrl: markerIcon2x, shadowUrl: markerShadow, }) interface GeoMapProps { nodes: NodeInfo[] edges: EdgeInfo[] selectedNodeId: number | null onSelectNode: (nodeId: number | null) => void } const REGION_COLORS = ['#3b82f6', '#a78bfa', '#06b6d4', '#f59e0b', '#22c55e', '#ec4899', '#8b5cf6', '#14b8a6'] const INFRA_ROLES = ['ROUTER', 'ROUTER_LATE', 'REPEATER', 'TRACKER'] function getQualityColor(snr: number): string { if (snr > 12) return '#22c55e' if (snr > 8) return '#4ade80' if (snr > 5) return '#f59e0b' if (snr > 3) return '#f97316' return '#ef4444' } function getRegionIndex(lat: number | null): number { if (lat === null) return 0 if (lat > 46) return 0 if (lat > 44.5) return 1 if (lat > 43) return 2 return 3 } function formatLastHeard(lastHeard: string | null): string { if (!lastHeard) return 'Unknown' const date = new Date(lastHeard) const now = new Date() const diffMs = now.getTime() - date.getTime() const diffMins = Math.floor(diffMs / 60000) const diffHours = Math.floor(diffMs / 3600000) const diffDays = Math.floor(diffMs / 86400000) if (diffMins < 1) return 'Just now' if (diffMins < 60) return `${diffMins}m ago` if (diffHours < 24) return `${diffHours}h ago` return `${diffDays}d ago` } // Component to fit bounds on mount function FitBounds({ bounds }: { bounds: LatLngBoundsExpression | null }) { const map = useMap() useEffect(() => { if (bounds) { map.fitBounds(bounds, { padding: [50, 50] }) } }, [map, bounds]) return null } interface NodePopupProps { node: NodeInfo } function NodePopup({ node }: NodePopupProps) { const hasCoords = node.latitude !== null && node.longitude !== null const batteryText = node.battery_level !== null ? (node.battery_level > 100 || (node.voltage && node.voltage > 4.1) ? 'USB ⚡' : `${node.battery_level.toFixed(0)}%`) : 'Unknown' return (
{node.short_name}
{node.long_name}
Role
{node.role}
Hardware
{node.hardware || 'Unknown'}
Battery
{batteryText}
Last Heard
{formatLastHeard(node.last_heard)}
{hasCoords && (
Google Maps OSM
)}
) } export default function GeoMap({ nodes, edges, selectedNodeId, onSelectNode, }: GeoMapProps) { // Filter nodes with valid coordinates const geoNodes = useMemo(() => nodes.filter((n) => n.latitude !== null && n.longitude !== null), [nodes] ) const nodesWithoutCoords = nodes.length - geoNodes.length // Create node map for edge lookup const nodeMap = useMemo(() => new Map(geoNodes.map((n) => [n.node_num, n])), [geoNodes] ) // Filter edges where both nodes have coordinates const geoEdges = useMemo(() => edges.filter((e) => nodeMap.has(e.from_node) && nodeMap.has(e.to_node)), [edges, nodeMap] ) // Calculate bounds const bounds = useMemo((): LatLngBoundsExpression | null => { if (geoNodes.length === 0) return null const lats = geoNodes.map((n) => n.latitude!) const lons = geoNodes.map((n) => n.longitude!) return [ [Math.min(...lats), Math.min(...lons)], [Math.max(...lats), Math.max(...lons)], ] }, [geoNodes]) // Default center (Idaho) const defaultCenter: LatLngTuple = [43.6, -114.4] // Get neighbors of selected node const selectedNeighbors = useMemo(() => { const neighbors = new Set() if (selectedNodeId !== null) { edges.forEach((e) => { if (e.from_node === selectedNodeId) neighbors.add(e.to_node) if (e.to_node === selectedNodeId) neighbors.add(e.from_node) }) } return neighbors }, [selectedNodeId, edges]) return (
{/* Edges */} {geoEdges.map((edge, i) => { const fromNode = nodeMap.get(edge.from_node)! const toNode = nodeMap.get(edge.to_node)! const isRelated = selectedNodeId === null || edge.from_node === selectedNodeId || edge.to_node === selectedNodeId return ( ) })} {/* Nodes */} {geoNodes.map((node) => { const isSelected = node.node_num === selectedNodeId const isNeighbor = selectedNeighbors.has(node.node_num) const isRelated = selectedNodeId === null || isSelected || isNeighbor const isInfra = INFRA_ROLES.includes(node.role) const regionIndex = getRegionIndex(node.latitude) const color = REGION_COLORS[regionIndex % REGION_COLORS.length] return ( onSelectNode(isSelected ? null : node.node_num), }} > {node.short_name} ) })} {/* Stats overlay */}
Showing {geoNodes.length} of {nodes.length} nodes {nodesWithoutCoords > 0 && ( ({nodesWithoutCoords} without coordinates) )}
) }