/* eslint-disable react-hooks/exhaustive-deps */ import React, { useRef, useEffect, useState, useCallback } from "react"; 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"; import { GOOGLE_MAPS_ID } from "../../lib/config"; interface NetworkMapProps { /** Height of the map in CSS units */ height?: string; /** Callback for when auto-zoom state changes */ onAutoZoomChange?: (enabled: boolean) => void; } /** * NetworkMap displays all nodes with position data on a Google Map */ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, NetworkMapProps>( ({ height = "600px", onAutoZoomChange }, ref) => { const navigate = useNavigate(); const mapRef = useRef(null); const mapInstanceRef = useRef(null); const markersRef = useRef>({}); const infoWindowRef = useRef(null); const boundsRef = useRef(null); const [nodesWithPosition, setNodesWithPosition] = useState([]); const animatingNodesRef = useRef>({}); const [autoZoomEnabled, setAutoZoomEnabled] = useState(true); // Using any for the event listener since TypeScript can't find the MapsEventListener interface const zoomListenerRef = useRef(null); const [isGoogleMapsLoaded, setIsGoogleMapsLoaded] = useState(false); // Get nodes data from the store const { nodes, gateways } = useAppSelector((state) => state.aggregator); // Get the latest packet to detect when nodes receive packets const latestPacket = useAppSelector((state) => state.packets.packets.length > 0 ? state.packets.packets[0] : null ); // Expose the resetAutoZoom function via ref React.useImperativeHandle(ref, () => ({ resetAutoZoom: () => { resetAutoZoom(); } })); // Reset auto-zoom behavior const resetAutoZoom = useCallback(() => { setAutoZoomEnabled(true); // Notify parent component of auto-zoom state change if (onAutoZoomChange) { onAutoZoomChange(true); } if (mapInstanceRef.current && nodesWithPosition.length > 0) { fitMapToBounds(); } }, [nodesWithPosition, onAutoZoomChange]); // Function to fit map to bounds const fitMapToBounds = useCallback(() => { if (!mapInstanceRef.current || !window.google || !window.google.maps) return; // Create new bounds for calculation boundsRef.current = new google.maps.LatLngBounds(); // Extend bounds for each node nodesWithPosition.forEach(node => { const lat = node.position.latitudeI / 10000000; const lng = node.position.longitudeI / 10000000; boundsRef.current?.extend({ lat, lng }); }); // Fit the bounds to see all nodes if (boundsRef.current) { mapInstanceRef.current.fitBounds(boundsRef.current); // If we only have one node, ensure we're not too zoomed in if (nodesWithPosition.length === 1) { setTimeout(() => { if (mapInstanceRef.current) { const currentZoom = mapInstanceRef.current.getZoom() || 15; mapInstanceRef.current.setZoom(Math.min(currentZoom, 15)); } }, 100); } } }, [nodesWithPosition]); // Setup zoom change listener const setupZoomListener = useCallback(() => { if (!mapInstanceRef.current || !window.google || !window.google.maps) { console.warn("Cannot set up zoom listener - map or Google Maps API not ready"); return; } try { // Remove previous listener if it exists if (zoomListenerRef.current) { // Use google.maps.event.removeListener for better compatibility window.google.maps.event.removeListener(zoomListenerRef.current); zoomListenerRef.current = null; } zoomListenerRef.current = window.google.maps.event.addListener( mapInstanceRef.current, 'zoom_changed', () => { console.log("Zoom changed detected"); // Disable auto-zoom when user manually zooms setAutoZoomEnabled(false); // Notify parent component of auto-zoom state change if (onAutoZoomChange) { onAutoZoomChange(false); } } ); } catch (error) { console.error("Error setting up zoom listener:", error); } }, [onAutoZoomChange]); // Effect to build the list of nodes with position data useEffect(() => { const nodeArray = getNodesWithPosition(nodes, gateways); setNodesWithPosition(nodeArray); }, [nodes, gateways]); // Check for Google Maps API and initialize const tryInitializeMap = useCallback(() => { if (mapRef.current && window.google && window.google.maps) { try { // Initialize map if not already done if (!mapInstanceRef.current) { initializeMap(mapRef.current); } // Create info window if not already done if (!infoWindowRef.current) { infoWindowRef.current = new google.maps.InfoWindow(); } // Update markers and fit the map updateNodeMarkers(nodesWithPosition, navigate); return true; } catch (error) { console.error("Error initializing map:", error); return false; } } console.warn("Cannot initialize map - prerequisites not met"); return false; }, [nodesWithPosition, navigate, updateNodeMarkers, initializeMap]); // Check for Google Maps API loading - make sure all required objects are available useEffect(() => { // Function to check if all required Google Maps components are loaded const checkGoogleMapsLoaded = () => { return window.google && window.google.maps && window.google.maps.Map && window.google.maps.InfoWindow && window.google.maps.marker && window.google.maps.marker.AdvancedMarkerElement; }; // Check if Google Maps is already loaded with all required components if (checkGoogleMapsLoaded()) { setIsGoogleMapsLoaded(true); return; } // Set up a listener for when the API loads const handleGoogleMapsLoaded = () => { // Wait a bit to ensure all Maps objects are initialized setTimeout(() => { if (checkGoogleMapsLoaded()) { setIsGoogleMapsLoaded(true); } }, 100); }; // Add event listener for Google Maps API loading window.addEventListener('google-maps-loaded', handleGoogleMapsLoaded); // Also try checking after a short delay (backup) const timeoutId = setTimeout(() => { if (checkGoogleMapsLoaded()) { setIsGoogleMapsLoaded(true); } else { console.warn("Google Maps API didn't fully load after timeout"); } }, 2000); // Cleanup return () => { window.removeEventListener('google-maps-loaded', handleGoogleMapsLoaded); clearTimeout(timeoutId); }; }, []); // Don't try to initialize map until we're sure Google Maps is fully loaded useEffect(() => { if (isGoogleMapsLoaded && mapRef.current && window.google?.maps?.Map && window.google?.maps?.InfoWindow && window.google?.maps?.marker?.AdvancedMarkerElement) { const initialized = tryInitializeMap(); // If we successfully initialized the map, also set up the zoom listener if (initialized && mapInstanceRef.current) { setupZoomListener(); } } }, [isGoogleMapsLoaded, nodesWithPosition, navigate, tryInitializeMap, setupZoomListener]); // Also set up zoom listener whenever the map instance changes useEffect(() => { if (mapInstanceRef.current && window.google && window.google.maps && isGoogleMapsLoaded) { setupZoomListener(); } }, [setupZoomListener, mapInstanceRef.current, isGoogleMapsLoaded]); // Update parent component when auto-zoom state changes useEffect(() => { if (onAutoZoomChange) { onAutoZoomChange(autoZoomEnabled); } }, [autoZoomEnabled, onAutoZoomChange]); // Effect to detect when a node receives a packet and trigger animation useEffect(() => { if (latestPacket && latestPacket.data.from !== undefined) { const nodeId = latestPacket.data.from; const key = `node-${nodeId}`; // If we have this node on the map, animate it if (markersRef.current[key]) { animateNodeMarker(nodeId); } } }, [latestPacket]); // Function to animate a node marker when it receives a packet function animateNodeMarker(nodeId: number): void { const key = `node-${nodeId}`; const marker = markersRef.current[key]; const node = nodesWithPosition.find(n => n.id === nodeId); if (!marker || !node) return; // Clear any existing animation for this node if (animatingNodesRef.current[key]) { clearTimeout(animatingNodesRef.current[key]); } // Get the animated style const iconStyle = getMarkerIcon(node, true); // Create updated content for the marker with animation style const markerContent = document.createElement('div'); markerContent.innerHTML = ` `; // Set cursor style markerContent.style.cursor = 'pointer'; // Update the marker content with animated style marker.content = markerContent; // Reset after a delay animatingNodesRef.current[key] = window.setTimeout(() => { // Reset to non-animated style const normalStyle = getMarkerIcon(node, false); const normalContent = document.createElement('div'); normalContent.innerHTML = ` `; normalContent.style.cursor = 'pointer'; marker.content = normalContent; delete animatingNodesRef.current[key]; }, 1000); // 1 second animation } // Cleanup on unmount useEffect(() => { return () => { // Clean up zoom listener if (zoomListenerRef.current && window.google && window.google.maps) { window.google.maps.event.removeListener(zoomListenerRef.current); zoomListenerRef.current = null; } // Clean up markers Object.values(markersRef.current).forEach(marker => marker.map = null); // Clean up any pending animations Object.values(animatingNodesRef.current).forEach(timeoutId => window.clearTimeout(timeoutId) ); // Close info window if (infoWindowRef.current) { infoWindowRef.current.close(); } }; }, []); // Helper function to initialize the map function initializeMap(element: HTMLDivElement): void { const mapOptions: google.maps.MapOptions = { zoom: 10, colorScheme: 'DARK', mapTypeControl: false, streetViewControl: false, fullscreenControl: false, zoomControl: true, mapId: GOOGLE_MAPS_ID, }; mapInstanceRef.current = new google.maps.Map(element, mapOptions); infoWindowRef.current = new google.maps.InfoWindow(); } // Helper function to update node markers on the map function updateNodeMarkers(nodes: MapNode[], navigate: ReturnType): void { if (!mapInstanceRef.current) return; // Clear the bounds for recalculation if (window.google && window.google.maps) { boundsRef.current = new google.maps.LatLngBounds(); } else { boundsRef.current = null; } const allKeys = new Set(); // Update markers for each node with position nodes.forEach(node => { const key = `node-${node.id}`; allKeys.add(key); // Convert coordinates to lat/lng const lat = node.position.latitudeI / 10000000; const lng = node.position.longitudeI / 10000000; const position = { lat, lng }; // Extend bounds to include this point if (boundsRef.current) { boundsRef.current.extend(position); } // Get node name const nodeName = node.shortName || node.longName || `${node.isGateway ? 'Gateway' : 'Node'} ${node.id.toString(16)}`; // Create or update marker if (!markersRef.current[key]) { createMarker(node, position, nodeName, navigate); } else { updateMarker(node, position); } }); // Remove markers that don't exist in the current data set Object.keys(markersRef.current).forEach(key => { if (!allKeys.has(key)) { markersRef.current[key].map = null; delete markersRef.current[key]; } }); // If auto-zoom is enabled and we have nodes, fit the map to show all of them if (autoZoomEnabled && nodes.length > 0) { fitMapToBounds(); } } // Create a new marker function createMarker( node: MapNode, position: google.maps.LatLngLiteral, nodeName: string, navigate: ReturnType ): void { if (!mapInstanceRef.current || !infoWindowRef.current) return; const key = `node-${node.id}`; // Get the marker icon style const iconStyle = getMarkerIcon(node); // Create content for the advanced marker const markerContent = document.createElement('div'); markerContent.innerHTML = ` `; // Set the container style to allow pointer events on it markerContent.style.cursor = 'pointer'; const marker = new google.maps.marker.AdvancedMarkerElement({ position, map: mapInstanceRef.current, title: nodeName, zIndex: node.isGateway ? 10 : 5, // Make gateways appear on top content: markerContent, }); // Add click listener to show info window marker.addListener('gmp-click', () => { showInfoWindow(node, marker, navigate); }); markersRef.current[key] = marker; } // Update an existing marker function updateMarker(node: MapNode, position: google.maps.LatLngLiteral): void { const key = `node-${node.id}`; const marker = markersRef.current[key]; // Update position marker.position = position; // Get the marker icon style const iconStyle = getMarkerIcon(node); // Create updated content for the marker const markerContent = document.createElement('div'); markerContent.innerHTML = ` `; // Set cursor style markerContent.style.cursor = 'pointer'; // Update the marker content marker.content = markerContent; } // Show info window for a node function showInfoWindow( node: MapNode, marker: google.maps.marker.AdvancedMarkerElement, navigate: ReturnType ): void { if (!infoWindowRef.current || !mapInstanceRef.current) return; const nodeName = node.longName || node.shortName || `!${node.id.toString(16)}`; const secondsAgo = node.lastHeard ? Math.floor(Date.now() / 1000) - node.lastHeard : 0; const lastSeenText = formatLastSeen(secondsAgo); // Get activity level and styles using the helper functions const activityLevel = getActivityLevel(node.lastHeard, node.isGateway); const colors = getNodeColors(activityLevel, node.isGateway); const statusText = getStatusText(activityLevel); // Use the dot color from our activity helper const statusDotColor = colors.fill; const infoContent = `

${nodeName}

${node.isGateway ? 'Gateway' : 'Node'} · !${node.id.toString(16)}
${statusText} - Last seen: ${lastSeenText}
Packets: ${node.messageCount || 0} · Text: ${node.textMessageCount || 0}
View details →
`; infoWindowRef.current.setContent(infoContent); infoWindowRef.current.open(mapInstanceRef.current, marker); // Add listener for the "View details" link with a delay to allow DOM to update setTimeout(() => { const link = document.getElementById(`view-node-${node.id}`); if (link) { link.addEventListener('gmp-click', () => { navigate({ to: `/node/$nodeId`, params: { nodeId: node.id.toString(16) } }); }); } }, 100); } if (!isGoogleMapsLoaded) { return (
Loading map...
); } return (
); }); NetworkMap.displayName = "NetworkMap"; // Define interface for nodes with position data for map display interface MapNode { id: number; position: Position & { latitudeI: number; // Override to make required longitudeI: number; // Override to make required }; isGateway: boolean; gatewayId?: string; shortName?: string; longName?: string; lastHeard?: number; messageCount: number; textMessageCount: number; } // Helper function to determine if a node has valid position data function hasValidPosition(node: NodeData): boolean { return Boolean( node.position && node.position.latitudeI !== undefined && node.position.longitudeI !== undefined ); } // Get a list of nodes that have position data function getNodesWithPosition( nodes: Record, gateways: Record ): MapNode[] { const nodesMap = new Map(); // Use a Map to avoid duplicates // Regular nodes 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 }); } }); // Gateways - we need to find the corresponding node for each gateway Object.entries(gateways).forEach(([gatewayId, gatewayData]) => { // Extract node ID from gateway ID (removing the '!' prefix) const nodeId = parseInt(gatewayId.substring(1), 16); // First priority: Check if we already have the node with a mapReport // (since mapReport is stored on NodeData, not GatewayData) const nodeWithMapReport = nodes[nodeId]; if ( nodeWithMapReport?.mapReport && nodeWithMapReport.mapReport.latitudeI !== undefined && nodeWithMapReport.mapReport.longitudeI !== undefined ) { // Use mapReport position from the node data if we haven't already added this node if (!nodesMap.has(nodeId)) { nodesMap.set(nodeId, { id: nodeId, isGateway: true, gatewayId: gatewayId, position: { latitudeI: nodeWithMapReport.mapReport.latitudeI!, longitudeI: nodeWithMapReport.mapReport.longitudeI!, precisionBits: nodeWithMapReport.mapReport.positionPrecision, time: nodeWithMapReport.lastHeard || Math.floor(Date.now() / 1000) }, // Include other data lastHeard: nodeWithMapReport.lastHeard, messageCount: nodeWithMapReport.messageCount || gatewayData.messageCount || 0, textMessageCount: nodeWithMapReport.textMessageCount || gatewayData.textMessageCount || 0, shortName: nodeWithMapReport.shortName, longName: nodeWithMapReport.longName }); } } // Second priority: Mark existing node as gateway if it already has position data else if (nodesMap.has(nodeId)) { const existingNode = nodesMap.get(nodeId)!; nodesMap.set(nodeId, { ...existingNode, isGateway: true, gatewayId: gatewayId, // Update data from gateway information 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()); } // Interface for marker icon configuration interface MarkerIconConfig { path: number; scale: number; fillColor: string; fillOpacity: number; strokeColor: string; strokeWeight: number; } // Get marker icon for a node function getMarkerIcon(node: MapNode, isAnimating: boolean = false): MarkerIconConfig { // Get activity level and colors using the helper functions const activityLevel = getActivityLevel(node.lastHeard, node.isGateway); const colors = getNodeColors(activityLevel, node.isGateway); return { path: google.maps.SymbolPath.CIRCLE, scale: isAnimating ? 14 : 10, // Increase size during animation fillColor: colors.fill, fillOpacity: isAnimating ? 0.8 : 1, // Slightly transparent during animation strokeColor: isAnimating ? "#ffffff" : colors.stroke, strokeWeight: isAnimating ? 3 : 2, // Thicker stroke during animation }; }