From 4061bbc28f599d1d3adb6d20e36a5d30c70aca27 Mon Sep 17 00:00:00 2001 From: Daniel Pupius Date: Mon, 28 Apr 2025 17:27:45 -0700 Subject: [PATCH] Network map --- web/src/components/Nav.tsx | 7 + .../components/dashboard/ChannelDetail.tsx | 1 - web/src/components/dashboard/NetworkMap.tsx | 407 ++++++++++++++++++ .../components/dashboard/NodePacketList.tsx | 1 - web/src/components/dashboard/index.ts | 1 + web/src/routeTree.gen.ts | 26 ++ web/src/routes/map.tsx | 31 ++ 7 files changed, 472 insertions(+), 2 deletions(-) create mode 100644 web/src/components/dashboard/NetworkMap.tsx create mode 100644 web/src/routes/map.tsx diff --git a/web/src/components/Nav.tsx b/web/src/components/Nav.tsx index c514244..0f80b94 100644 --- a/web/src/components/Nav.tsx +++ b/web/src/components/Nav.tsx @@ -9,6 +9,7 @@ import { LayoutDashboard, Radio, MessageSquare, + Map, LucideIcon, } from "lucide-react"; @@ -33,6 +34,12 @@ const navigationItems: NavItem[] = [ icon: LayoutDashboard, exact: true, }, + { + to: "/map", + label: "Network Map", + icon: Map, + exact: true, + }, { to: "/packets", label: "Stream", diff --git a/web/src/components/dashboard/ChannelDetail.tsx b/web/src/components/dashboard/ChannelDetail.tsx index 61b097d..d6bee2b 100644 --- a/web/src/components/dashboard/ChannelDetail.tsx +++ b/web/src/components/dashboard/ChannelDetail.tsx @@ -3,7 +3,6 @@ import { useNavigate } from "@tanstack/react-router"; import { useAppSelector } from "../../hooks"; import { Separator } from "../Separator"; import { MessageBubble } from "../messages"; -import { Section } from "../ui/Section"; import { ArrowLeft, MessageSquare, Users, Wifi } from "lucide-react"; interface ChannelDetailProps { diff --git a/web/src/components/dashboard/NetworkMap.tsx b/web/src/components/dashboard/NetworkMap.tsx new file mode 100644 index 0000000..d5111c3 --- /dev/null +++ b/web/src/components/dashboard/NetworkMap.tsx @@ -0,0 +1,407 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import React, { useRef, useEffect, useState } from "react"; +import { useAppSelector } from "../../hooks"; +import { useNavigate } from "@tanstack/react-router"; + +interface NetworkMapProps { + /** Height of the map in CSS units */ + height?: string; +} + +/** + * NetworkMap displays all nodes with position data on a Google Map + */ +export const NetworkMap: React.FC = ({ height = "600px" }) => { + const navigate = useNavigate(); + const mapRef = useRef(null); + const mapInstanceRef = useRef(null); + const markersRef = useRef>({}); + const infoWindowRef = useRef(null); + const boundsRef = useRef(new google.maps.LatLngBounds()); + const [nodesWithPosition, setNodesWithPosition] = useState([]); + const animatingNodesRef = useRef>({}); + + // 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 + ); + + // Effect to build the list of nodes with position data + useEffect(() => { + const nodeArray = getNodesWithPosition(nodes, gateways); + setNodesWithPosition(nodeArray); + }, [nodes, gateways]); + + // Handle map initialization and marker creation + useEffect(() => { + if (!mapRef.current || !window.google || !window.google.maps) return; + + // 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); + + }, [nodesWithPosition, navigate]); + + // 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) { + 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]); + } + + // Set the animated style + marker.setIcon(getMarkerIcon(node, true)); + + // Reset after a delay + animatingNodesRef.current[key] = window.setTimeout(() => { + marker.setIcon(getMarkerIcon(node, false)); + delete animatingNodesRef.current[key]; + }, 1000); // 1 second animation + } + + // Cleanup on unmount + useEffect(() => { + return () => { + // Clean up markers + Object.values(markersRef.current).forEach(marker => marker.setMap(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) { + const mapOptions: google.maps.MapOptions = { + zoom: 10, + mapTypeId: google.maps.MapTypeId.HYBRID, + mapTypeControl: false, + streetViewControl: false, + fullscreenControl: false, + zoomControl: true, + styles: [ + { + featureType: "all", + elementType: "labels.text.fill", + stylers: [{ color: "#ffffff" }], + }, + { + featureType: "all", + elementType: "labels.text.stroke", + stylers: [{ visibility: "off" }], + }, + { + featureType: "administrative", + elementType: "geometry", + stylers: [{ visibility: "on" }, { color: "#2d2d2d" }], + }, + { + featureType: "landscape", + elementType: "geometry", + stylers: [{ color: "#1a1a1a" }], + }, + { + featureType: "poi", + elementType: "geometry", + stylers: [{ color: "#1a1a1a" }], + }, + { + featureType: "road", + elementType: "geometry.fill", + stylers: [{ color: "#2d2d2d" }], + }, + { + featureType: "road", + elementType: "geometry.stroke", + stylers: [{ color: "#333333" }], + }, + { + featureType: "water", + elementType: "geometry", + stylers: [{ color: "#0f252e" }], + }, + ], + }; + + 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: any[], navigate: any) { + if (!mapInstanceRef.current) return; + + // Clear the bounds for recalculation + boundsRef.current = new google.maps.LatLngBounds(); + 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 + 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].setMap(null); + delete markersRef.current[key]; + } + }); + + // If we have nodes, fit the map to show all of them + if (nodes.length > 0) { + // Fit the bounds to see all nodes + mapInstanceRef.current?.fitBounds(boundsRef.current); + + // If we only have one node, ensure we're not too zoomed in + if (nodes.length === 1 && mapInstanceRef.current) { + setTimeout(() => { + if (mapInstanceRef.current) { + const currentZoom = mapInstanceRef.current.getZoom() || 15; + mapInstanceRef.current.setZoom(Math.min(currentZoom, 15)); + } + }, 100); + } + } + } + + // Create a new marker + function createMarker(node: any, position: google.maps.LatLngLiteral, + nodeName: string, navigate: any) { + if (!mapInstanceRef.current || !infoWindowRef.current) return; + + const key = `node-${node.id}`; + const marker = new google.maps.Marker({ + position, + map: mapInstanceRef.current, + title: nodeName, + icon: getMarkerIcon(node), + zIndex: node.isGateway ? 10 : 5, // Make gateways appear on top + }); + + // Add click listener to show info window + marker.addListener("click", () => { + showInfoWindow(node, marker, navigate); + }); + + markersRef.current[key] = marker; + } + + // Update an existing marker + function updateMarker(node: any, position: google.maps.LatLngLiteral) { + const key = `node-${node.id}`; + markersRef.current[key].setPosition(position); + markersRef.current[key].setIcon(getMarkerIcon(node)); + } + + // Show info window for a node + function showInfoWindow(node: any, marker: google.maps.Marker, navigate: any) { + if (!infoWindowRef.current || !mapInstanceRef.current) return; + + const nodeName = node.shortName || node.longName || + `${node.isGateway ? 'Gateway' : 'Node'} ${node.id.toString(16)}`; + + const secondsAgo = Math.floor(Date.now() / 1000) - node.lastHeard; + let lastSeenText = formatLastSeen(secondsAgo); + + const infoContent = ` +
+

+ ${nodeName} +

+
+ ${node.isGateway ? 'Gateway' : 'Node'} · ID: ${node.id.toString(16)} +
+
+ Last seen: ${lastSeenText} +
+
+ Messages: ${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('click', () => { + navigate({ to: `/node/$nodeId`, params: { nodeId: node.id.toString() } }); + }); + } + }, 100); + } + + return ( +
+ ); +}; + +// Helper function to determine if a node has valid position data +function hasValidPosition(node: any) { + return node.position && + node.position.latitudeI !== undefined && + node.position.longitudeI !== undefined; +} + +// Get a list of nodes that have position data +function getNodesWithPosition(nodes: any, gateways: any) { + 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); + nodesMap.set(nodeId, { + ...nodeData, + id: nodeId, + isGateway: false + }); + } + }); + + // Gateways with position data + Object.entries(gateways).forEach(([gatewayId, gatewayData]) => { + // Extract node ID from gateway ID (removing the '!' prefix) + const nodeId = parseInt(gatewayId.substring(1), 16); + + // First priority: Use gateway's mapReport position if available + if (gatewayData.mapReport && + gatewayData.mapReport.latitudeI !== undefined && + gatewayData.mapReport.longitudeI !== undefined) { + + nodesMap.set(nodeId, { + ...(nodesMap.get(nodeId) || {}), // Keep existing node data if any + id: nodeId, + isGateway: true, + gatewayId: gatewayId, + position: { + latitudeI: gatewayData.mapReport.latitudeI, + longitudeI: gatewayData.mapReport.longitudeI, + precisionBits: gatewayData.mapReport.positionPrecision + }, + // Include other gateway data + lastHeard: gatewayData.lastHeard || (nodesMap.get(nodeId)?.lastHeard), + messageCount: gatewayData.messageCount || (nodesMap.get(nodeId)?.messageCount || 0), + textMessageCount: gatewayData.textMessageCount || (nodesMap.get(nodeId)?.textMessageCount || 0), + shortName: gatewayData.shortName || (nodesMap.get(nodeId)?.shortName), + longName: gatewayData.longName || (nodesMap.get(nodeId)?.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, + // Merge other data + lastHeard: Math.max(existingNode.lastHeard || 0, gatewayData.lastHeard || 0), + messageCount: existingNode.messageCount || gatewayData.messageCount || 0, + textMessageCount: existingNode.textMessageCount || gatewayData.textMessageCount || 0, + shortName: existingNode.shortName || gatewayData.shortName, + longName: existingNode.longName || gatewayData.longName + }); + } + }); + + return Array.from(nodesMap.values()); +} + +// Get marker icon for a node +function getMarkerIcon(node: any, isAnimating: boolean = false) { + return { + path: google.maps.SymbolPath.CIRCLE, + scale: isAnimating ? 14 : 10, // Increase size during animation + fillColor: node.isGateway ? "#fb923c" : "#4ade80", // Orange for gateways, green for nodes + fillOpacity: isAnimating ? 0.8 : 1, // Slightly transparent during animation + strokeColor: isAnimating ? "#ffffff" : (node.isGateway ? "#f97316" : "#22c55e"), + strokeWeight: isAnimating ? 3 : 2, // Thicker stroke during animation + }; +} + +// Format the "last seen" text +function formatLastSeen(secondsAgo: number) { + if (secondsAgo < 60) { + return `${secondsAgo} seconds ago`; + } else if (secondsAgo < 3600) { + const minutes = Math.floor(secondsAgo / 60); + return `${minutes} minute${minutes > 1 ? 's' : ''} ago`; + } else if (secondsAgo < 86400) { + const hours = Math.floor(secondsAgo / 3600); + return `${hours} hour${hours > 1 ? 's' : ''} ago`; + } else { + const days = Math.floor(secondsAgo / 86400); + return `${days} day${days > 1 ? 's' : ''} ago`; + } +} \ No newline at end of file diff --git a/web/src/components/dashboard/NodePacketList.tsx b/web/src/components/dashboard/NodePacketList.tsx index 3482200..c566448 100644 --- a/web/src/components/dashboard/NodePacketList.tsx +++ b/web/src/components/dashboard/NodePacketList.tsx @@ -2,7 +2,6 @@ import React, { useCallback } from "react"; import { useAppSelector } from "../../hooks"; import { PacketRenderer } from "../packets/PacketRenderer"; import { Packet } from "../../lib/types"; -import { Separator } from "../Separator"; interface NodePacketListProps { /** Node ID to filter packets by */ diff --git a/web/src/components/dashboard/index.ts b/web/src/components/dashboard/index.ts index 268ee39..3b620a9 100644 --- a/web/src/components/dashboard/index.ts +++ b/web/src/components/dashboard/index.ts @@ -6,6 +6,7 @@ export * from './ChannelDetail'; export * from './BatteryLevel'; export * from './SignalStrength'; export * from './GoogleMap'; +export * from './NetworkMap'; export * from './NodePacketList'; export * from './NodePositionData'; export * from './EnvironmentMetrics'; diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index 009b012..12313d4 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -13,6 +13,7 @@ import { Route as rootRoute } from './routes/__root' import { Route as RootImport } from './routes/root' import { Route as PacketsImport } from './routes/packets' +import { Route as MapImport } from './routes/map' import { Route as HomeImport } from './routes/home' import { Route as DemoImport } from './routes/demo' import { Route as ChannelsImport } from './routes/channels' @@ -34,6 +35,12 @@ const PacketsRoute = PacketsImport.update({ getParentRoute: () => rootRoute, } as any) +const MapRoute = MapImport.update({ + id: '/map', + path: '/map', + getParentRoute: () => rootRoute, +} as any) + const HomeRoute = HomeImport.update({ id: '/home', path: '/home', @@ -102,6 +109,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof HomeImport parentRoute: typeof rootRoute } + '/map': { + id: '/map' + path: '/map' + fullPath: '/map' + preLoaderRoute: typeof MapImport + parentRoute: typeof rootRoute + } '/packets': { id: '/packets' path: '/packets' @@ -140,6 +154,7 @@ export interface FileRoutesByFullPath { '/channels': typeof ChannelsRoute '/demo': typeof DemoRoute '/home': typeof HomeRoute + '/map': typeof MapRoute '/packets': typeof PacketsRoute '/root': typeof RootRoute '/channel/$channelId': typeof ChannelChannelIdRoute @@ -151,6 +166,7 @@ export interface FileRoutesByTo { '/channels': typeof ChannelsRoute '/demo': typeof DemoRoute '/home': typeof HomeRoute + '/map': typeof MapRoute '/packets': typeof PacketsRoute '/root': typeof RootRoute '/channel/$channelId': typeof ChannelChannelIdRoute @@ -163,6 +179,7 @@ export interface FileRoutesById { '/channels': typeof ChannelsRoute '/demo': typeof DemoRoute '/home': typeof HomeRoute + '/map': typeof MapRoute '/packets': typeof PacketsRoute '/root': typeof RootRoute '/channel/$channelId': typeof ChannelChannelIdRoute @@ -176,6 +193,7 @@ export interface FileRouteTypes { | '/channels' | '/demo' | '/home' + | '/map' | '/packets' | '/root' | '/channel/$channelId' @@ -186,6 +204,7 @@ export interface FileRouteTypes { | '/channels' | '/demo' | '/home' + | '/map' | '/packets' | '/root' | '/channel/$channelId' @@ -196,6 +215,7 @@ export interface FileRouteTypes { | '/channels' | '/demo' | '/home' + | '/map' | '/packets' | '/root' | '/channel/$channelId' @@ -208,6 +228,7 @@ export interface RootRouteChildren { ChannelsRoute: typeof ChannelsRoute DemoRoute: typeof DemoRoute HomeRoute: typeof HomeRoute + MapRoute: typeof MapRoute PacketsRoute: typeof PacketsRoute RootRoute: typeof RootRoute ChannelChannelIdRoute: typeof ChannelChannelIdRoute @@ -219,6 +240,7 @@ const rootRouteChildren: RootRouteChildren = { ChannelsRoute: ChannelsRoute, DemoRoute: DemoRoute, HomeRoute: HomeRoute, + MapRoute: MapRoute, PacketsRoute: PacketsRoute, RootRoute: RootRoute, ChannelChannelIdRoute: ChannelChannelIdRoute, @@ -239,6 +261,7 @@ export const routeTree = rootRoute "/channels", "/demo", "/home", + "/map", "/packets", "/root", "/channel/$channelId", @@ -257,6 +280,9 @@ export const routeTree = rootRoute "/home": { "filePath": "home.tsx" }, + "/map": { + "filePath": "map.tsx" + }, "/packets": { "filePath": "packets.tsx" }, diff --git a/web/src/routes/map.tsx b/web/src/routes/map.tsx new file mode 100644 index 0000000..edd92e6 --- /dev/null +++ b/web/src/routes/map.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { createFileRoute } from "@tanstack/react-router"; +import { PageWrapper } from "../components"; +import { NetworkMap } from "../components/dashboard"; + +export const Route = createFileRoute("/map")({ + component: MapPage, +}); + +function MapPage() { + return ( + +
+
+ + +
+ + + Nodes + + + + Gateways + +
+
+
+
+ ); +} \ No newline at end of file