diff --git a/web/.env.example b/web/.env.example
index e468852..f369a7a 100644
--- a/web/.env.example
+++ b/web/.env.example
@@ -9,5 +9,8 @@ VITE_APP_ENV="development"
VITE_SITE_TITLE="ERSN Mesh"
VITE_SITE_DESCRIPTION="Meshtastic activity in the Ebbett's Pass region of Highway 4, CA."
-# Get one at: https://developers.google.com/maps/documentation/javascript/get-api-key
-VITE_GOOGLE_MAPS_API_KEY=OVERRIDE_IN_LOCAL_ENV
\ No newline at end of file
+# Get an API key: https://developers.google.com/maps/documentation/javascript/get-api-key
+VITE_GOOGLE_MAPS_API_KEY=OVERRIDE_IN_LOCAL_ENV
+
+# Create a Map ID for Advanced Markers support and custom map styles and customize in Google Cloud Console.
+VITE_GOOGLE_MAPS_ID=OVERRIDE_IN_LOCAL_ENV
\ No newline at end of file
diff --git a/web/index.html b/web/index.html
index 1f321d8..81c56e7 100644
--- a/web/index.html
+++ b/web/index.html
@@ -10,13 +10,13 @@
-
+
-
+
diff --git a/web/src/components/dashboard/GoogleMap.tsx b/web/src/components/dashboard/GoogleMap.tsx
index f68454b..9da6818 100644
--- a/web/src/components/dashboard/GoogleMap.tsx
+++ b/web/src/components/dashboard/GoogleMap.tsx
@@ -1,5 +1,6 @@
-import React, { useRef, useEffect } from "react";
+import React, { useRef, useEffect, useState } from "react";
import { calculateAccuracyFromPrecisionBits, calculateZoomFromAccuracy } from "../../lib/mapUtils";
+import { GOOGLE_MAPS_ID } from "../../lib/config";
interface GoogleMapProps {
/** Latitude coordinate */
@@ -23,7 +24,8 @@ export const GoogleMap: React.FC = ({
}) => {
const mapRef = useRef(null);
const mapInstanceRef = useRef(null);
- const markerRef = useRef(null);
+ const markerRef = useRef(null);
+ const [isGoogleMapsLoaded, setIsGoogleMapsLoaded] = useState(false);
// Calculate accuracy in meters based on precision bits
const accuracyMeters = calculateAccuracyFromPrecisionBits(precisionBits);
@@ -51,48 +53,7 @@ export const GoogleMap: React.FC = ({
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" }],
- },
- ],
+ mapId: GOOGLE_MAPS_ID,
};
mapInstanceRef.current = new google.maps.Map(mapRef.current, mapOptions);
@@ -100,18 +61,20 @@ export const GoogleMap: React.FC = ({
// Only add the center marker if we don't have precision information or
// it's very accurate.
if (precisionBits === undefined || accuracyMeters < 100) {
- markerRef.current = new google.maps.Marker({
+ // Create a marker with a custom SVG circle to match the old style
+ const markerContent = document.createElement('div');
+ markerContent.innerHTML = `
+
+ `;
+
+ // Create the advanced marker element
+ markerRef.current = new google.maps.marker.AdvancedMarkerElement({
position: { lat, lng },
map: mapInstanceRef.current,
title: `Node Position`,
- icon: {
- path: google.maps.SymbolPath.CIRCLE,
- scale: 8,
- fillColor: "#4ade80",
- fillOpacity: 1,
- strokeColor: "#22c55e",
- strokeWeight: 2,
- },
+ content: markerContent,
});
}
@@ -130,33 +93,69 @@ export const GoogleMap: React.FC = ({
}
};
+ // Check for Google Maps API loading - make sure all required objects are available
useEffect(() => {
- // Check if Google Maps API is already loaded
- if (window.google && window.google.maps) {
- initializeMap();
- } else {
- // Set up a listener for when the API loads
- const handleGoogleMapsLoaded = () => {
- initializeMap();
- };
-
- // Add event listener for Google Maps API loading
- window.addEventListener('google-maps-loaded', handleGoogleMapsLoaded);
-
- // Also try initializing after a short delay (backup)
- const timeoutId = setTimeout(() => {
- if (window.google && window.google.maps) {
- initializeMap();
- }
- }, 1000);
-
- // Cleanup
- return () => {
- window.removeEventListener('google-maps-loaded', handleGoogleMapsLoaded);
- clearTimeout(timeoutId);
- };
+ // 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.Circle &&
+ 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;
}
- }, [lat, lng, effectiveZoom, accuracyMeters, precisionBits]);
+
+ // 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);
+ };
+ }, []);
+
+ // Initialize map when Google Maps is loaded and props change
+ useEffect(() => {
+ if (isGoogleMapsLoaded && mapRef.current) {
+ initializeMap();
+ }
+ }, [isGoogleMapsLoaded, lat, lng, effectiveZoom, accuracyMeters, precisionBits]);
+
+ if (!isGoogleMapsLoaded) {
+ return (
+
+
Loading map...
+
+ );
+ }
return (
= ({
return type === "gateway" ? (
) : (
-
+
);
};
diff --git a/web/src/components/dashboard/NetworkMap.tsx b/web/src/components/dashboard/NetworkMap.tsx
index b99dcb0..0b55748 100644
--- a/web/src/components/dashboard/NetworkMap.tsx
+++ b/web/src/components/dashboard/NetworkMap.tsx
@@ -5,6 +5,7 @@ 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 */
@@ -21,14 +22,15 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
const navigate = useNavigate();
const mapRef = useRef(null);
const mapInstanceRef = useRef(null);
- const markersRef = useRef>({});
+ const markersRef = useRef>({});
const infoWindowRef = useRef(null);
- const boundsRef = useRef(new google.maps.LatLngBounds());
+ 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);
@@ -61,61 +63,67 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
// Function to fit map to bounds
const fitMapToBounds = useCallback(() => {
- if (!mapInstanceRef.current) return;
+ if (!mapInstanceRef.current || !window.google || !window.google.maps) return;
- // Clear the bounds for recalculation
+ // 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 });
+ 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);
+ 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) 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;
+ if (!mapInstanceRef.current || !window.google || !window.google.maps) {
+ console.warn("Cannot set up zoom listener - map or Google Maps API not ready");
+ return;
}
- // 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);
- }
+ 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
@@ -127,56 +135,98 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
// Check for Google Maps API and initialize
const tryInitializeMap = useCallback(() => {
if (mapRef.current && window.google && window.google.maps) {
- // Initialize map if not already done
- if (!mapInstanceRef.current) {
- initializeMap(mapRef.current);
+ 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;
}
-
- // 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;
}
+ console.warn("Cannot initialize map - prerequisites not met");
return false;
}, [nodesWithPosition, navigate, updateNodeMarkers, initializeMap]);
- // Handle map initialization and marker creation
+ // Check for Google Maps API loading - make sure all required objects are available
useEffect(() => {
- // Try to initialize immediately if Google Maps is already loaded
- if (tryInitializeMap()) {
+ // 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 = () => {
- tryInitializeMap();
+ // 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 initializing after a short delay (backup)
+ // Also try checking after a short delay (backup)
const timeoutId = setTimeout(() => {
- tryInitializeMap();
- }, 1000);
+ 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);
};
- }, [nodesWithPosition, navigate, tryInitializeMap]);
+ }, []);
- // Setup zoom listener when map is initialized
+ // Don't try to initialize map until we're sure Google Maps is fully loaded
useEffect(() => {
- if (mapInstanceRef.current && window.google && window.google.maps) {
+ 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]);
+ }, [setupZoomListener, mapInstanceRef.current, isGoogleMapsLoaded]);
// Update parent component when auto-zoom state changes
useEffect(() => {
@@ -211,12 +261,54 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
clearTimeout(animatingNodesRef.current[key]);
}
- // Set the animated style
- marker.setIcon(getMarkerIcon(node, true));
+ // 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(() => {
- marker.setIcon(getMarkerIcon(node, false));
+ // 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
}
@@ -231,7 +323,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
}
// Clean up markers
- Object.values(markersRef.current).forEach(marker => marker.setMap(null));
+ Object.values(markersRef.current).forEach(marker => marker.map = null);
// Clean up any pending animations
Object.values(animatingNodesRef.current).forEach(timeoutId =>
@@ -249,53 +341,12 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
function initializeMap(element: HTMLDivElement): void {
const mapOptions: google.maps.MapOptions = {
zoom: 10,
- mapTypeId: google.maps.MapTypeId.HYBRID,
+ colorScheme: 'DARK',
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" }],
- },
- ],
+ mapId: GOOGLE_MAPS_ID,
};
mapInstanceRef.current = new google.maps.Map(element, mapOptions);
@@ -307,7 +358,11 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
if (!mapInstanceRef.current) return;
// Clear the bounds for recalculation
- boundsRef.current = new google.maps.LatLngBounds();
+ 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
@@ -321,7 +376,9 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
const position = { lat, lng };
// Extend bounds to include this point
- boundsRef.current.extend(position);
+ if (boundsRef.current) {
+ boundsRef.current.extend(position);
+ }
// Get node name
const nodeName = node.shortName || node.longName ||
@@ -338,7 +395,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
// 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);
+ markersRef.current[key].map = null;
delete markersRef.current[key];
}
});
@@ -359,16 +416,39 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
if (!mapInstanceRef.current || !infoWindowRef.current) return;
const key = `node-${node.id}`;
- const marker = new google.maps.Marker({
+
+ // 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,
- icon: getMarkerIcon(node),
zIndex: node.isGateway ? 10 : 5, // Make gateways appear on top
+ content: markerContent,
});
// Add click listener to show info window
- marker.addListener("click", () => {
+ marker.addListener('gmp-click', () => {
showInfoWindow(node, marker, navigate);
});
@@ -378,14 +458,41 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
// Update an existing marker
function updateMarker(node: MapNode, position: google.maps.LatLngLiteral): void {
const key = `node-${node.id}`;
- markersRef.current[key].setPosition(position);
- markersRef.current[key].setIcon(getMarkerIcon(node));
+ 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,
+ marker: google.maps.marker.AdvancedMarkerElement,
navigate: ReturnType
): void {
if (!infoWindowRef.current || !mapInstanceRef.current) return;
@@ -433,13 +540,26 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
setTimeout(() => {
const link = document.getElementById(`view-node-${node.id}`);
if (link) {
- link.addEventListener('click', () => {
+ link.addEventListener('gmp-click', () => {
navigate({ to: `/node/$nodeId`, params: { nodeId: node.id.toString(16) } });
});
}
}, 100);
}
+ if (!isGoogleMapsLoaded) {
+ return (
+