import React, { useRef, useCallback, useEffect, useState, useMemo } from "react"; import maplibregl from "maplibre-gl"; import type { GeoJSONSource } from "maplibre-gl"; import type { FeatureCollection } from "geojson"; import "maplibre-gl/dist/maplibre-gl.css"; import { CARTO_DARK_STYLE_LABELLED } from "../../lib/mapStyle"; import { useAppSelector } from "../../hooks"; import { useNavigate } from "@tanstack/react-router"; import { NodeData, GatewayData } from "../../store/slices/aggregatorSlice"; import { Position } from "../../lib/types"; import { getActivityLevel, getNodeColors, getStatusText, formatLastSeen } from "../../lib/activity"; interface NetworkMapProps { height?: string; onAutoZoomChange?: (enabled: boolean) => void; fullHeight?: boolean; showLinks?: boolean; } interface MapNode { id: number; position: Position & { latitudeI: number; longitudeI: number; }; isGateway: boolean; gatewayId?: string; shortName?: string; longName?: string; lastHeard?: number; messageCount: number; textMessageCount: number; } export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, NetworkMapProps>( ({ height, fullHeight = false, onAutoZoomChange, showLinks = true }, ref) => { const navigate = useNavigate(); const containerRef = useRef(null); const mapRef = useRef(null); const autoZoomRef = useRef(true); const popupRef = useRef(null); const nodesWithPositionRef = useRef([]); const [autoZoomEnabled, setAutoZoomEnabled] = useState(true); const { nodes, gateways } = useAppSelector((state) => state.aggregator); const topologyLinks = useAppSelector((state) => state.topology.links); const nodesWithPosition = useMemo( () => getNodesWithPosition(nodes, gateways), [nodes, gateways] ); nodesWithPositionRef.current = nodesWithPosition; const nodesGeoJSON = useMemo((): FeatureCollection => ({ type: "FeatureCollection", features: nodesWithPosition.map((node) => { const level = getActivityLevel(node.lastHeard, node.isGateway); const colors = getNodeColors(level, node.isGateway); return { type: "Feature", id: node.id, geometry: { type: "Point", coordinates: [node.position.longitudeI / 10000000, node.position.latitudeI / 10000000], }, properties: { nodeId: node.id, name: node.shortName || node.longName || `!${node.id.toString(16)}`, fillColor: colors.fill, strokeColor: colors.stroke, radius: node.isGateway ? 12 : 8, }, }; }), }), [nodesWithPosition]); const linksGeoJSON = useMemo((): FeatureCollection => { const posMap = new Map(); for (const node of nodesWithPosition) { posMap.set(node.id, [node.position.longitudeI / 10000000, node.position.latitudeI / 10000000]); } return { type: "FeatureCollection", features: Object.values(topologyLinks) .filter((link) => posMap.has(link.nodeA) && posMap.has(link.nodeB)) .map((link) => { const snr = link.snrAtoB ?? link.snrBtoA; const color = snr === undefined ? "#6b7280" : snr >= 5 ? "#22c55e" : snr >= 0 ? "#eab308" : "#ef4444"; return { type: "Feature" as const, geometry: { type: "LineString" as const, coordinates: [posMap.get(link.nodeA)!, posMap.get(link.nodeB)!] }, properties: { color, opacity: link.viaMqtt ? 0.4 : 0.7 }, }; }), }; }, [topologyLinks, nodesWithPosition]); const disableAutoZoom = useCallback(() => { autoZoomRef.current = false; setAutoZoomEnabled(false); onAutoZoomChange?.(false); }, [onAutoZoomChange]); // Mount map once useEffect(() => { if (!containerRef.current) return; const map = new maplibregl.Map({ container: containerRef.current, style: CARTO_DARK_STYLE_LABELLED, center: [-98, 39], zoom: 4, attributionControl: false, }); map.addControl(new maplibregl.AttributionControl({ compact: true })); map.on("load", () => { map.addSource("links", { type: "geojson", data: { type: "FeatureCollection", features: [] } }); map.addLayer({ id: "links-line", type: "line", source: "links", layout: { "line-join": "round", "line-cap": "round", visibility: "visible" }, paint: { "line-color": ["get", "color"], "line-width": 2, "line-opacity": ["get", "opacity"] }, }); map.addSource("nodes", { type: "geojson", data: { type: "FeatureCollection", features: [] } }); map.addLayer({ id: "nodes-circles", type: "circle", source: "nodes", paint: { "circle-radius": ["get", "radius"], "circle-color": ["get", "fillColor"], "circle-stroke-width": 2, "circle-stroke-color": ["get", "strokeColor"], "circle-opacity": 0.9, "circle-stroke-opacity": 1, }, }); map.addLayer({ id: "nodes-labels", type: "symbol", source: "nodes", layout: { "text-field": ["get", "name"], "text-size": 11, "text-offset": [0, 1.5], "text-anchor": "top", "text-optional": true }, paint: { "text-color": "#e5e7eb", "text-halo-color": "#111827", "text-halo-width": 1.5 }, }); }); map.on("dragstart", disableAutoZoom); map.on("zoomstart", disableAutoZoom); map.on("mouseenter", "nodes-circles", () => { map.getCanvas().style.cursor = "pointer"; }); map.on("mouseleave", "nodes-circles", () => { map.getCanvas().style.cursor = "grab"; }); map.on("click", "nodes-circles", (e: MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => { const feature = e.features?.[0]; if (!feature) return; const props = feature.properties as { nodeId: number; name: string; fillColor: string }; const nodeId = props.nodeId; const node = nodesWithPositionRef.current.find((n) => n.id === nodeId); if (!node) return; const coords = (feature.geometry as GeoJSON.Point).coordinates as [number, number]; const level = getActivityLevel(node.lastHeard, node.isGateway); const colors = getNodeColors(level, node.isGateway); const statusText = getStatusText(level); const secondsAgo = node.lastHeard ? Math.floor(Date.now() / 1000) - node.lastHeard : 0; const lastSeenText = formatLastSeen(secondsAgo); const nodeName = node.longName || node.shortName || `!${node.id.toString(16)}`; popupRef.current?.remove(); const el = document.createElement("div"); el.style.cssText = "font-family:sans-serif;max-width:220px;padding:4px"; el.innerHTML = `
${nodeName}
${node.isGateway ? "Gateway" : "Node"} · !${node.id.toString(16)}
${statusText} · ${lastSeenText}
Packets: ${node.messageCount} · Text: ${node.textMessageCount}
`; el.querySelector("#nav-btn")?.addEventListener("click", () => { popup.remove(); navigate({ to: "/node/$nodeId", params: { nodeId: node.id.toString(16) } }); }); const popup = new maplibregl.Popup({ closeOnClick: true, maxWidth: "240px", anchor: "bottom" }) .setLngLat(coords) .setDOMContent(el) .addTo(map); popupRef.current = popup; }); map.on("click", (e: MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => { if (!e.features?.length) popupRef.current?.remove(); }); mapRef.current = map; return () => { map.remove(); mapRef.current = null; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Update node/link data useEffect(() => { const map = mapRef.current; if (!map || !map.isStyleLoaded()) return; (map.getSource("nodes") as GeoJSONSource)?.setData(nodesGeoJSON); (map.getSource("links") as GeoJSONSource)?.setData(linksGeoJSON); }, [nodesGeoJSON, linksGeoJSON]); // Toggle links visibility useEffect(() => { const map = mapRef.current; if (!map || !map.isStyleLoaded()) return; map.setLayoutProperty("links-line", "visibility", showLinks ? "visible" : "none"); }, [showLinks]); // Auto-zoom to fit nodes useEffect(() => { const map = mapRef.current; if (!autoZoomRef.current || nodesWithPosition.length === 0 || !map) return; let minLng = Infinity, maxLng = -Infinity, minLat = Infinity, maxLat = -Infinity; for (const n of nodesWithPosition) { const lng = n.position.longitudeI / 10000000; const lat = n.position.latitudeI / 10000000; if (lng < minLng) minLng = lng; if (lng > maxLng) maxLng = lng; if (lat < minLat) minLat = lat; if (lat > maxLat) maxLat = lat; } map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: 60, maxZoom: 15, duration: 500 }); }, [nodesWithPosition]); // Notify parent of auto-zoom state useEffect(() => { onAutoZoomChange?.(autoZoomEnabled); }, [autoZoomEnabled, onAutoZoomChange]); // Expose resetAutoZoom via ref React.useImperativeHandle(ref, () => ({ resetAutoZoom: () => { autoZoomRef.current = true; setAutoZoomEnabled(true); onAutoZoomChange?.(true); }, })); const wrapperClassName = `w-full ${fullHeight ? "h-full flex flex-col" : ""}`; const mapClassName = `w-full overflow-hidden effect-inset rounded-lg relative ${fullHeight ? "flex-1" : ""}`; const containerStyle = height && !fullHeight ? { height } : fullHeight ? { height: "100%" } : {}; return (
); } ); NetworkMap.displayName = "NetworkMap"; // ─── Helpers ────────────────────────────────────────────────────────────────── function hasValidPosition(node: NodeData): boolean { return Boolean( node.position && node.position.latitudeI !== undefined && node.position.longitudeI !== undefined ); } function getNodesWithPosition( nodes: Record, gateways: Record ): MapNode[] { const nodesMap = new Map(); Object.entries(nodes).forEach(([nodeIdStr, nodeData]) => { if (hasValidPosition(nodeData)) { const nodeId = parseInt(nodeIdStr); const position = nodeData.position as MapNode["position"]; nodesMap.set(nodeId, { ...nodeData, id: nodeId, isGateway: !!nodeData.isGateway, position, messageCount: nodeData.messageCount || 0, textMessageCount: nodeData.textMessageCount || 0, }); } }); Object.entries(gateways).forEach(([gatewayId, gatewayData]) => { const nodeId = parseInt(gatewayId.substring(1), 16); const nodeWithMapReport = nodes[nodeId]; if ( nodeWithMapReport?.mapReport && nodeWithMapReport.mapReport.latitudeI !== undefined && nodeWithMapReport.mapReport.longitudeI !== undefined ) { if (!nodesMap.has(nodeId)) { nodesMap.set(nodeId, { id: nodeId, isGateway: true, gatewayId, position: { latitudeI: nodeWithMapReport.mapReport.latitudeI!, longitudeI: nodeWithMapReport.mapReport.longitudeI!, precisionBits: nodeWithMapReport.mapReport.positionPrecision, time: nodeWithMapReport.lastHeard || Math.floor(Date.now() / 1000), }, lastHeard: nodeWithMapReport.lastHeard, messageCount: nodeWithMapReport.messageCount || gatewayData.messageCount || 0, textMessageCount: nodeWithMapReport.textMessageCount || gatewayData.textMessageCount || 0, shortName: nodeWithMapReport.shortName, longName: nodeWithMapReport.longName, }); } } else if (nodesMap.has(nodeId)) { const existingNode = nodesMap.get(nodeId)!; nodesMap.set(nodeId, { ...existingNode, isGateway: true, gatewayId, lastHeard: Math.max(existingNode.lastHeard || 0, gatewayData.lastHeard || 0), messageCount: existingNode.messageCount || gatewayData.messageCount || 0, textMessageCount: existingNode.textMessageCount || gatewayData.textMessageCount || 0, }); } }); return Array.from(nodesMap.values()); }