diff --git a/web/src/components/dashboard/GatewayList.tsx b/web/src/components/dashboard/GatewayList.tsx index 30ead31..713334f 100644 --- a/web/src/components/dashboard/GatewayList.tsx +++ b/web/src/components/dashboard/GatewayList.tsx @@ -34,7 +34,7 @@ export const GatewayList: React.FC = () => { {gatewayArray.length === 1 ? "gateway" : "gateways"} -
+
{gatewayArray.length === 0 ? (
diff --git a/web/src/components/dashboard/NetworkMap.tsx b/web/src/components/dashboard/NetworkMap.tsx index 907bdde..353c550 100644 --- a/web/src/components/dashboard/NetworkMap.tsx +++ b/web/src/components/dashboard/NetworkMap.tsx @@ -1,19 +1,24 @@ /* eslint-disable react-hooks/exhaustive-deps */ -import React, { useRef, useEffect, useState } from "react"; +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 { Button } from "../ui/Button"; +import { Locate } from "lucide-react"; 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.FC = ({ height = "600px" }) => { +export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, NetworkMapProps>( + ({ height = "600px", onAutoZoomChange }, ref) => { const navigate = useNavigate(); const mapRef = useRef(null); const mapInstanceRef = useRef(null); @@ -22,6 +27,9 @@ export const NetworkMap: React.FC = ({ height = "600px" }) => { const boundsRef = useRef(new google.maps.LatLngBounds()); 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); // Get nodes data from the store const { nodes, gateways } = useAppSelector((state) => state.aggregator); @@ -30,6 +38,86 @@ export const NetworkMap: React.FC = ({ height = "600px" }) => { 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) return; + + // Clear the bounds for recalculation + 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 + 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) return; + + // 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; + } + + // Console log to debug + console.log("Setting up zoom change listener"); + + // Add new listener - using google.maps.event.addListener directly + 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); + } + } + ); + }, [onAutoZoomChange]); // Effect to build the list of nodes with position data useEffect(() => { @@ -54,7 +142,21 @@ export const NetworkMap: React.FC = ({ height = "600px" }) => { // Update markers and fit the map updateNodeMarkers(nodesWithPosition, navigate); - }, [nodesWithPosition, navigate]); + }, [nodesWithPosition, navigate, setupZoomListener]); + + // Setup zoom listener when map is initialized + useEffect(() => { + if (mapInstanceRef.current && window.google && window.google.maps) { + setupZoomListener(); + } + }, [setupZoomListener, mapInstanceRef.current]); + + // 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(() => { @@ -95,6 +197,12 @@ export const NetworkMap: React.FC = ({ height = "600px" }) => { // 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.setMap(null)); @@ -208,20 +316,9 @@ export const NetworkMap: React.FC = ({ height = "600px" }) => { } }); - // 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); - } + // If auto-zoom is enabled and we have nodes, fit the map to show all of them + if (autoZoomEnabled && nodes.length > 0) { + fitMapToBounds(); } } @@ -278,13 +375,13 @@ export const NetworkMap: React.FC = ({ height = "600px" }) => { ${nodeName}
- ${node.isGateway ? 'Gateway' : 'Node'} · ID: ${node.id.toString(16)} + ${node.isGateway ? 'Gateway' : 'Node'} · !${node.id.toString(16)}
Last seen: ${lastSeenText}
- Messages: ${node.messageCount || 0} · Text: ${node.textMessageCount || 0} + Packets: ${node.messageCount || 0} · Text: ${node.textMessageCount || 0}
= ({ height = "600px" }) => { } return ( -
+
+
+
); -}; +}); + +NetworkMap.displayName = "NetworkMap"; // Define interface for nodes with position data for map display interface MapNode { diff --git a/web/src/components/dashboard/NodeList.tsx b/web/src/components/dashboard/NodeList.tsx index c5f126a..fb030c3 100644 --- a/web/src/components/dashboard/NodeList.tsx +++ b/web/src/components/dashboard/NodeList.tsx @@ -41,7 +41,7 @@ export const NodeList: React.FC = () => { {nodeArray.length} {nodeArray.length === 1 ? "node" : "nodes"}
-
+
{nodeArray.length === 0 ? (
diff --git a/web/src/components/packets/PositionPacket.tsx b/web/src/components/packets/PositionPacket.tsx index 0ddbb1b..d7331a2 100644 --- a/web/src/components/packets/PositionPacket.tsx +++ b/web/src/components/packets/PositionPacket.tsx @@ -57,21 +57,21 @@ export const PositionPacket: React.FC = ({ packet }) => { monospace /> )} - {position.time && ( + {!!position.time && ( )} - {position.locationSource && ( + {!!position.locationSource && ( )} - {position.satsInView && ( + {!!position.satsInView && ( void }>({}); + + // Function to reset auto-zoom, will be called by the button + const handleResetZoom = () => { + if (mapRef.current.resetAutoZoom) { + mapRef.current.resetAutoZoom(); + } + }; + return (
- + -
- - - Nodes - - - - Gateways - +
+
+ + + Nodes + + + + Gateways + +
+ + {/* Always show the button, but disable it when auto-zoom is enabled */} +
diff --git a/web/src/types/google-maps.d.ts b/web/src/types/google-maps.d.ts index db60833..e81d8c2 100644 --- a/web/src/types/google-maps.d.ts +++ b/web/src/types/google-maps.d.ts @@ -9,6 +9,7 @@ declare namespace google { setZoom(zoom: number): void; getZoom(): number | undefined; fitBounds(bounds: LatLngBounds): void; + addListener(event: string, handler: Function): MapsEventListener; } class Marker { @@ -16,7 +17,7 @@ declare namespace google { setMap(map: Map | null): void; setPosition(position: LatLngLiteral): void; setIcon(icon: any): void; - addListener(event: string, handler: Function): void; + addListener(event: string, handler: Function): MapsEventListener; } class Circle { @@ -76,6 +77,28 @@ declare namespace google { position?: LatLngLiteral; } + // Event-related functionality + const event: { + /** + * Removes the given listener, which should have been returned by + * google.maps.event.addListener. + */ + removeListener(listener: MapsEventListener): void; + /** + * Removes all listeners for all events for the given instance. + */ + clearInstanceListeners(instance: Object): void; + }; + + // Maps Event Listener + interface MapsEventListener { + /** + * Removes the listener. + * Equivalent to calling google.maps.event.removeListener(listener). + */ + remove(): void; + } + const MapTypeId: { ROADMAP: string; SATELLITE: string;