mirror of
https://github.com/dpup/meshstream.git
synced 2026-06-14 11:14:44 +02:00
702 lines
24 KiB
TypeScript
702 lines
24 KiB
TypeScript
/* 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<HTMLDivElement>(null);
|
|
const mapInstanceRef = useRef<google.maps.Map | null>(null);
|
|
const markersRef = useRef<Record<string, google.maps.marker.AdvancedMarkerElement>>({});
|
|
const infoWindowRef = useRef<google.maps.InfoWindow | null>(null);
|
|
const boundsRef = useRef<google.maps.LatLngBounds | null>(null);
|
|
const [nodesWithPosition, setNodesWithPosition] = useState<MapNode[]>([]);
|
|
const animatingNodesRef = useRef<Record<string, number>>({});
|
|
const [autoZoomEnabled, setAutoZoomEnabled] = useState(true);
|
|
// Using any for the event listener since TypeScript can't find the MapsEventListener interface
|
|
const zoomListenerRef = useRef<any>(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 = `
|
|
<svg width="${iconStyle.scale * 2}" height="${iconStyle.scale * 2}" viewBox="0 0 ${iconStyle.scale * 2} ${iconStyle.scale * 2}" xmlns="http://www.w3.org/2000/svg">
|
|
<circle
|
|
cx="${iconStyle.scale}"
|
|
cy="${iconStyle.scale}"
|
|
r="${iconStyle.scale - iconStyle.strokeWeight}"
|
|
fill="${iconStyle.fillColor}"
|
|
fill-opacity="${iconStyle.fillOpacity}"
|
|
stroke="${iconStyle.strokeColor}"
|
|
stroke-width="${iconStyle.strokeWeight}"
|
|
/>
|
|
</svg>
|
|
`;
|
|
|
|
// 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 = `
|
|
<svg width="${normalStyle.scale * 2}" height="${normalStyle.scale * 2}" viewBox="0 0 ${normalStyle.scale * 2} ${normalStyle.scale * 2}" xmlns="http://www.w3.org/2000/svg">
|
|
<circle
|
|
cx="${normalStyle.scale}"
|
|
cy="${normalStyle.scale}"
|
|
r="${normalStyle.scale - normalStyle.strokeWeight}"
|
|
fill="${normalStyle.fillColor}"
|
|
fill-opacity="${normalStyle.fillOpacity}"
|
|
stroke="${normalStyle.strokeColor}"
|
|
stroke-width="${normalStyle.strokeWeight}"
|
|
/>
|
|
</svg>
|
|
`;
|
|
|
|
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<typeof useNavigate>): 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<string>();
|
|
|
|
// 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<typeof useNavigate>
|
|
): 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 = `
|
|
<svg width="${iconStyle.scale * 2}" height="${iconStyle.scale * 2}" viewBox="0 0 ${iconStyle.scale * 2} ${iconStyle.scale * 2}" xmlns="http://www.w3.org/2000/svg">
|
|
<circle
|
|
cx="${iconStyle.scale}"
|
|
cy="${iconStyle.scale}"
|
|
r="${iconStyle.scale - iconStyle.strokeWeight}"
|
|
fill="${iconStyle.fillColor}"
|
|
fill-opacity="${iconStyle.fillOpacity}"
|
|
stroke="${iconStyle.strokeColor}"
|
|
stroke-width="${iconStyle.strokeWeight}"
|
|
/>
|
|
</svg>
|
|
`;
|
|
|
|
// 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 = `
|
|
<svg width="${iconStyle.scale * 2}" height="${iconStyle.scale * 2}" viewBox="0 0 ${iconStyle.scale * 2} ${iconStyle.scale * 2}" xmlns="http://www.w3.org/2000/svg">
|
|
<circle
|
|
cx="${iconStyle.scale}"
|
|
cy="${iconStyle.scale}"
|
|
r="${iconStyle.scale - iconStyle.strokeWeight}"
|
|
fill="${iconStyle.fillColor}"
|
|
fill-opacity="${iconStyle.fillOpacity}"
|
|
stroke="${iconStyle.strokeColor}"
|
|
stroke-width="${iconStyle.strokeWeight}"
|
|
/>
|
|
</svg>
|
|
`;
|
|
|
|
// 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<typeof useNavigate>
|
|
): 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 = `
|
|
<div style="font-family: sans-serif; max-width: 240px; color: #181818;">
|
|
<h3 style="margin: 0 0 8px; font-size: 16px; color: ${statusDotColor}; font-weight: 600;">
|
|
${nodeName}
|
|
</h3>
|
|
<div style="font-size: 12px; color: #555; margin-bottom: 8px; font-weight: 500;">
|
|
${node.isGateway ? 'Gateway' : 'Node'} · !${node.id.toString(16)}
|
|
</div>
|
|
<div style="font-size: 12px; margin-bottom: 4px; color: #333; display: flex; align-items: center;">
|
|
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background-color: ${statusDotColor}; margin-right: 6px;"></span>
|
|
<span>${statusText} - Last seen: ${lastSeenText}</span>
|
|
</div>
|
|
<div style="font-size: 12px; margin-bottom: 8px; color: #333;">
|
|
Packets: ${node.messageCount || 0} · Text: ${node.textMessageCount || 0}
|
|
</div>
|
|
<a href="javascript:void(0);"
|
|
id="view-node-${node.id}"
|
|
style="font-size: 13px; color: #3b82f6; text-decoration: none; font-weight: 500; display: inline-block; padding: 4px 8px; background-color: #f1f5f9; border-radius: 4px;">
|
|
View details →
|
|
</a>
|
|
</div>
|
|
`;
|
|
|
|
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 (
|
|
<div className="w-full">
|
|
<div
|
|
className="w-full overflow-hidden effect-inset rounded-lg relative flex items-center justify-center"
|
|
style={{ height }}
|
|
>
|
|
<div className="text-gray-400">Loading map...</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="w-full">
|
|
<div
|
|
ref={mapRef}
|
|
className="w-full overflow-hidden effect-inset rounded-lg relative"
|
|
style={{ height }}
|
|
/>
|
|
</div>
|
|
);
|
|
});
|
|
|
|
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<number, NodeData>,
|
|
gateways: Record<string, GatewayData>
|
|
): MapNode[] {
|
|
const nodesMap = new Map<number, MapNode>(); // 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
|
|
};
|
|
}
|