mirror of
https://github.com/dpup/meshstream.git
synced 2026-08-07 17:22:54 +02:00
refactor(web): replace Google Maps with MapLibre, clean up map components
- Migrate all three map components (Map, GoogleMap, NetworkMap) to MapLibre GL JS - Extract shared CARTO_DARK_STYLE constants into lib/mapStyle.ts - Move buildCircleCoords to lib/mapUtils.ts (was duplicated across components) - Rename exports: Map → LocationMap, GoogleMap → NodeLocationMap - Remove dead props (width, height, nightMode) from LocationMap interface - Lazy-mount GL contexts via IntersectionObserver to prevent WebGL exhaustion - Fix Math.spread RangeError in NetworkMap bounds calculation - Remove showLinks conditional render in favour of visibility layout property - Remove cursor state; set canvas cursor style directly on map interactions - Remove Google Maps API key env vars from .env.example and .env.local - Move Vite dev server to port 5747 (avoids cached redirect on 3000) - Fix CORS/404: set VITE_API_BASE_URL="" so browser uses Vite proxy Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+100
-75
@@ -1,100 +1,125 @@
|
||||
import React from "react";
|
||||
import { getStaticMapUrl, getGoogleMapsUrl } from "../lib/mapUtils";
|
||||
import React, { useRef, useState, useEffect, useMemo } from "react";
|
||||
import ReactMap, { Source, Layer } from "react-map-gl/maplibre";
|
||||
import type { FeatureCollection } from "geojson";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import { CARTO_DARK_STYLE } from "../lib/mapStyle";
|
||||
import { buildCircleCoords, calculateAccuracyFromPrecisionBits, calculateZoomFromAccuracy, getGoogleMapsUrl } from "../lib/mapUtils";
|
||||
|
||||
interface MapProps {
|
||||
interface LocationMapProps {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
zoom?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
caption?: string;
|
||||
className?: string;
|
||||
flush?: boolean;
|
||||
nightMode?: boolean;
|
||||
precisionBits?: number; // Added for position precision
|
||||
precisionBits?: number;
|
||||
}
|
||||
|
||||
// Helper function to calculate zoom level based on precision bits
|
||||
const calculateZoomFromPrecisionBits = (precisionBits?: number): number => {
|
||||
if (!precisionBits) return 14; // Default zoom
|
||||
|
||||
// Each precision bit roughly halves the area, so we can map bits to zoom level
|
||||
// Starting with Earth at zoom 0, each bit roughly adds 1 zoom level
|
||||
// Typical values: 21 bits ~= zoom 13-14, 24 bits ~= zoom 16-17
|
||||
const baseZoom = 8; // Start with a basic zoom level
|
||||
const additionalZoom = Math.max(0, precisionBits - 16); // Each 2 bits above 16 adds ~1 zoom level
|
||||
|
||||
return Math.min(18, baseZoom + (additionalZoom / 2)); // Cap at zoom 18
|
||||
};
|
||||
|
||||
export const Map: React.FC<MapProps> = ({
|
||||
export const LocationMap: React.FC<LocationMapProps> = ({
|
||||
latitude,
|
||||
longitude,
|
||||
zoom,
|
||||
width = 300,
|
||||
height = 200,
|
||||
caption,
|
||||
className = "",
|
||||
flush = false,
|
||||
nightMode = true,
|
||||
precisionBits
|
||||
precisionBits,
|
||||
}) => {
|
||||
// Calculate zoom level based on precision bits if zoom is not provided
|
||||
const effectiveZoom = zoom || calculateZoomFromPrecisionBits(precisionBits);
|
||||
|
||||
const mapUrl = getStaticMapUrl(
|
||||
latitude,
|
||||
longitude,
|
||||
effectiveZoom,
|
||||
width,
|
||||
height,
|
||||
nightMode,
|
||||
precisionBits
|
||||
);
|
||||
const googleMapsUrl = getGoogleMapsUrl(latitude, longitude);
|
||||
|
||||
// Check if Google Maps API key is available
|
||||
const apiKeyAvailable = Boolean(import.meta.env.VITE_GOOGLE_MAPS_API_KEY);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const mapContainerClasses = flush
|
||||
// Only mount the WebGL map when the container enters the viewport.
|
||||
// This prevents exhausting the browser's WebGL context limit (~8-16)
|
||||
// when many LocationMap thumbnails are rendered in a long packet list.
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setIsVisible(entry.isIntersecting),
|
||||
{ rootMargin: "200px" }
|
||||
);
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
const accuracyMeters = calculateAccuracyFromPrecisionBits(precisionBits);
|
||||
const effectiveZoom = zoom ?? calculateZoomFromAccuracy(accuracyMeters);
|
||||
const googleMapsUrl = getGoogleMapsUrl(latitude, longitude);
|
||||
const showAccuracyCircle = precisionBits !== undefined;
|
||||
|
||||
const markerGeoJSON = useMemo((): FeatureCollection => ({
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{ type: "Feature", geometry: { type: "Point", coordinates: [longitude, latitude] }, properties: {} },
|
||||
],
|
||||
}), [latitude, longitude]);
|
||||
|
||||
const circleGeoJSON = useMemo((): FeatureCollection => ({
|
||||
type: "FeatureCollection",
|
||||
features: showAccuracyCircle
|
||||
? [
|
||||
{
|
||||
type: "Feature",
|
||||
geometry: { type: "Polygon", coordinates: [buildCircleCoords(longitude, latitude, accuracyMeters)] },
|
||||
properties: {},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}), [latitude, longitude, accuracyMeters, showAccuracyCircle]);
|
||||
|
||||
const containerClasses = flush
|
||||
? `w-full h-full overflow-hidden relative ${className}`
|
||||
: `${className} relative overflow-hidden rounded-xl border border-neutral-700 bg-neutral-800/50`;
|
||||
|
||||
if (!apiKeyAvailable) {
|
||||
return (
|
||||
<div className={flush ? "p-4 bg-neutral-800/50" : mapContainerClasses}>
|
||||
<p className="text-sm text-neutral-400">
|
||||
Map display requires a Google Maps API key.
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
Add VITE_GOOGLE_MAPS_API_KEY to your environment.
|
||||
</p>
|
||||
<div className="mt-2 text-sm text-neutral-300">
|
||||
{latitude.toFixed(6)}, {longitude.toFixed(6)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className={mapContainerClasses}>
|
||||
<a
|
||||
href={googleMapsUrl}
|
||||
target="_blank"
|
||||
<div ref={containerRef} className={containerClasses}>
|
||||
{isVisible && (
|
||||
<ReactMap
|
||||
mapStyle={CARTO_DARK_STYLE}
|
||||
initialViewState={{ longitude, latitude, zoom: effectiveZoom }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
attributionControl={{}}
|
||||
>
|
||||
{showAccuracyCircle && (
|
||||
<Source id="circle" type="geojson" data={circleGeoJSON}>
|
||||
<Layer id="circle-fill" type="fill" paint={{ "fill-color": "#4ade80", "fill-opacity": 0.15 }} />
|
||||
<Layer id="circle-outline" type="line" paint={{ "line-color": "#22c55e", "line-width": 1.5, "line-opacity": 0.8 }} />
|
||||
</Source>
|
||||
)}
|
||||
<Source id="marker" type="geojson" data={markerGeoJSON}>
|
||||
<Layer
|
||||
id="marker-dot"
|
||||
type="circle"
|
||||
paint={{
|
||||
"circle-radius": 5,
|
||||
"circle-color": "#4ade80",
|
||||
"circle-stroke-width": 2,
|
||||
"circle-stroke-color": "#22c55e",
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
</ReactMap>
|
||||
)}
|
||||
|
||||
{/* External link overlay */}
|
||||
<a
|
||||
href={googleMapsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full h-full hover:opacity-90 transition-opacity"
|
||||
className="absolute top-2 right-2 bg-black/50 text-white text-xs px-2 py-1 rounded hover:bg-black/70 transition-colors z-10"
|
||||
title="Open in Google Maps"
|
||||
>
|
||||
<img
|
||||
src={mapUrl}
|
||||
alt={`Map of ${latitude},${longitude}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
{caption && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-3 py-1 text-xs text-white">
|
||||
{caption}
|
||||
</div>
|
||||
)}
|
||||
↗
|
||||
</a>
|
||||
|
||||
{caption && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-3 py-1 text-xs text-white z-10">
|
||||
{caption}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
/** @deprecated Use LocationMap */
|
||||
export const Map = LocationMap;
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from "react";
|
||||
import { calculateAccuracyFromPrecisionBits, calculateZoomFromAccuracy } from "../../lib/mapUtils";
|
||||
import { GOOGLE_MAPS_ID } from "../../lib/config";
|
||||
import React, { useMemo } from "react";
|
||||
import ReactMap, { Source, Layer } from "react-map-gl/maplibre";
|
||||
import type { FeatureCollection } from "geojson";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import { CARTO_DARK_STYLE } from "../../lib/mapStyle";
|
||||
import { buildCircleCoords, calculateAccuracyFromPrecisionBits, calculateZoomFromAccuracy } from "../../lib/mapUtils";
|
||||
|
||||
interface GoogleMapProps {
|
||||
interface NodeLocationMapProps {
|
||||
/** Latitude coordinate */
|
||||
lat: number;
|
||||
/** Longitude coordinate */
|
||||
@@ -16,155 +19,76 @@ interface GoogleMapProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Google Maps component that uses the API loaded via script tag
|
||||
* Single-node location map with accuracy circle
|
||||
*/
|
||||
export const GoogleMap: React.FC<GoogleMapProps> = ({
|
||||
export const NodeLocationMap: React.FC<NodeLocationMapProps> = ({
|
||||
lat,
|
||||
lng,
|
||||
zoom,
|
||||
precisionBits,
|
||||
fullHeight = false,
|
||||
}) => {
|
||||
const mapRef = useRef<HTMLDivElement>(null);
|
||||
const mapInstanceRef = useRef<google.maps.Map | null>(null);
|
||||
const markerRef = useRef<google.maps.marker.AdvancedMarkerElement | null>(null);
|
||||
const [isGoogleMapsLoaded, setIsGoogleMapsLoaded] = useState(false);
|
||||
|
||||
// Calculate accuracy in meters based on precision bits
|
||||
const accuracyMeters = calculateAccuracyFromPrecisionBits(precisionBits);
|
||||
const effectiveZoom = zoom ?? calculateZoomFromAccuracy(accuracyMeters);
|
||||
const showCenterDot = precisionBits === undefined || accuracyMeters < 100;
|
||||
|
||||
// If zoom is not provided, calculate based on accuracy
|
||||
const effectiveZoom = zoom || calculateZoomFromAccuracy(accuracyMeters);
|
||||
const markerGeoJSON = useMemo((): FeatureCollection => ({
|
||||
type: "FeatureCollection",
|
||||
features: showCenterDot
|
||||
? [{ type: "Feature", geometry: { type: "Point", coordinates: [lng, lat] }, properties: {} }]
|
||||
: [],
|
||||
}), [lat, lng, showCenterDot]);
|
||||
|
||||
// Track whether the map has been initialized
|
||||
const isInitializedRef = useRef(false);
|
||||
const circleGeoJSON = useMemo((): FeatureCollection => ({
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
geometry: { type: "Polygon", coordinates: [buildCircleCoords(lng, lat, accuracyMeters)] },
|
||||
properties: {},
|
||||
},
|
||||
],
|
||||
}), [lat, lng, accuracyMeters]);
|
||||
|
||||
const initializeMap = useCallback(() => {
|
||||
if (
|
||||
mapRef.current &&
|
||||
window.google &&
|
||||
window.google.maps &&
|
||||
!isInitializedRef.current
|
||||
) {
|
||||
isInitializedRef.current = true;
|
||||
// Create map instance
|
||||
const mapOptions: google.maps.MapOptions = {
|
||||
center: { lat, lng },
|
||||
zoom: effectiveZoom,
|
||||
mapTypeId: google.maps.MapTypeId.HYBRID,
|
||||
mapTypeControl: false,
|
||||
streetViewControl: false,
|
||||
fullscreenControl: false,
|
||||
zoomControl: true,
|
||||
mapId: GOOGLE_MAPS_ID,
|
||||
};
|
||||
|
||||
mapInstanceRef.current = new google.maps.Map(mapRef.current, mapOptions);
|
||||
|
||||
// Only add the center marker if we don't have precision information or
|
||||
// it's very accurate.
|
||||
if (precisionBits === undefined || accuracyMeters < 100) {
|
||||
// Create a marker with a custom SVG circle to match the old style
|
||||
const markerContent = document.createElement('div');
|
||||
markerContent.innerHTML = `
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="8" cy="8" r="6" fill="#4ade80" stroke="#22c55e" stroke-width="2" />
|
||||
</svg>
|
||||
`;
|
||||
|
||||
// Create the advanced marker element
|
||||
markerRef.current = new google.maps.marker.AdvancedMarkerElement({
|
||||
position: { lat, lng },
|
||||
map: mapInstanceRef.current,
|
||||
title: `Node Position`,
|
||||
content: markerContent,
|
||||
});
|
||||
}
|
||||
|
||||
// Circle will always be shown, using default 300m accuracy if no
|
||||
// precision bits.
|
||||
new google.maps.Circle({
|
||||
strokeColor: "#22c55e",
|
||||
strokeOpacity: 0.8,
|
||||
strokeWeight: 2.5,
|
||||
fillColor: "#4ade80",
|
||||
fillOpacity: 0.4,
|
||||
map: mapInstanceRef.current,
|
||||
center: { lat, lng },
|
||||
radius: accuracyMeters,
|
||||
});
|
||||
}
|
||||
}, [lat, lng, effectiveZoom, accuracyMeters, precisionBits]);
|
||||
|
||||
// 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.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;
|
||||
}
|
||||
|
||||
// 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, initializeMap]);
|
||||
|
||||
// Prepare the container classes based on fullHeight flag
|
||||
const containerClassName = `w-full ${fullHeight ? 'h-full flex-1' : 'min-h-[300px]'} rounded-lg overflow-hidden effect-inset`;
|
||||
|
||||
if (!isGoogleMapsLoaded) {
|
||||
return (
|
||||
<div className={`${containerClassName} flex items-center justify-center`}>
|
||||
<div className="text-gray-400">Loading map...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const containerClassName = `w-full ${fullHeight ? "h-full flex-1" : "min-h-[300px]"} rounded-lg overflow-hidden effect-inset`;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={mapRef}
|
||||
className={containerClassName}
|
||||
/>
|
||||
<div className={containerClassName}>
|
||||
<ReactMap
|
||||
mapStyle={CARTO_DARK_STYLE}
|
||||
initialViewState={{ longitude: lng, latitude: lat, zoom: effectiveZoom }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
>
|
||||
<Source id="circle" type="geojson" data={circleGeoJSON}>
|
||||
<Layer
|
||||
id="circle-fill"
|
||||
type="fill"
|
||||
paint={{ "fill-color": "#4ade80", "fill-opacity": 0.15 }}
|
||||
/>
|
||||
<Layer
|
||||
id="circle-outline"
|
||||
type="line"
|
||||
paint={{ "line-color": "#22c55e", "line-width": 2, "line-opacity": 0.8 }}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
<Source id="marker" type="geojson" data={markerGeoJSON}>
|
||||
<Layer
|
||||
id="marker-dot"
|
||||
type="circle"
|
||||
paint={{
|
||||
"circle-radius": 6,
|
||||
"circle-color": "#4ade80",
|
||||
"circle-stroke-width": 2,
|
||||
"circle-stroke-color": "#22c55e",
|
||||
"circle-opacity": 1,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
</ReactMap>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** @deprecated Use NodeLocationMap */
|
||||
export const GoogleMap = NodeLocationMap;
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import React, { useRef, useEffect, useState, useCallback } from "react";
|
||||
import React, { useRef, useCallback, useEffect, useState, useMemo } from "react";
|
||||
import ReactMap, { Source, Layer, Popup, MapRef } from "react-map-gl/maplibre";
|
||||
import type { FeatureCollection } from "geojson";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import { CARTO_DARK_STYLE_LABELLED } from "../../lib/mapStyle";
|
||||
import { useAppSelector } from "../../hooks";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { NodeData, GatewayData } from "../../store/slices/aggregatorSlice";
|
||||
import { LinkObservation } from "../../store/slices/topologySlice";
|
||||
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 (optional, will use flex-grow by default) */
|
||||
/** Height of the map in CSS units (optional) */
|
||||
height?: string;
|
||||
/** Callback for when auto-zoom state changes */
|
||||
onAutoZoomChange?: (enabled: boolean) => void;
|
||||
@@ -18,494 +20,11 @@ interface NetworkMapProps {
|
||||
showLinks?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* NetworkMap displays all nodes with position data on a Google Map
|
||||
*/
|
||||
export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, NetworkMapProps>(
|
||||
({ height, fullHeight = false, onAutoZoomChange, showLinks = true }, 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 polylinesRef = useRef<Record<string, google.maps.Polyline>>({});
|
||||
const [isGoogleMapsLoaded, setIsGoogleMapsLoaded] = useState(false);
|
||||
|
||||
// Get nodes data from the store
|
||||
const { nodes, gateways } = useAppSelector((state) => state.aggregator);
|
||||
const topologyLinks = useAppSelector((state) => state.topology.links);
|
||||
|
||||
// Expose the resetAutoZoom function via ref
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
resetAutoZoom: () => {
|
||||
resetAutoZoom();
|
||||
}
|
||||
}));
|
||||
|
||||
// Function to fit map to bounds
|
||||
const fitMapToBounds = useCallback(() => {
|
||||
if (!mapInstanceRef.current || !window.google || !window.google.maps) return;
|
||||
|
||||
boundsRef.current = new google.maps.LatLngBounds();
|
||||
|
||||
nodesWithPosition.forEach(node => {
|
||||
const lat = node.position.latitudeI / 10000000;
|
||||
const lng = node.position.longitudeI / 10000000;
|
||||
boundsRef.current?.extend({ lat, lng });
|
||||
});
|
||||
|
||||
if (boundsRef.current) {
|
||||
mapInstanceRef.current.fitBounds(boundsRef.current);
|
||||
|
||||
if (nodesWithPosition.length === 1) {
|
||||
setTimeout(() => {
|
||||
if (mapInstanceRef.current) {
|
||||
const currentZoom = mapInstanceRef.current.getZoom() || 15;
|
||||
mapInstanceRef.current.setZoom(Math.min(currentZoom, 15));
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}, [nodesWithPosition]);
|
||||
|
||||
// Reset auto-zoom behavior
|
||||
const resetAutoZoom = useCallback(() => {
|
||||
setAutoZoomEnabled(true);
|
||||
|
||||
if (onAutoZoomChange) {
|
||||
onAutoZoomChange(true);
|
||||
}
|
||||
|
||||
if (mapInstanceRef.current && nodesWithPosition.length > 0) {
|
||||
fitMapToBounds();
|
||||
}
|
||||
}, [nodesWithPosition, onAutoZoomChange, fitMapToBounds]);
|
||||
|
||||
// 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]);
|
||||
|
||||
// Show info window for a node
|
||||
const showInfoWindow = useCallback((
|
||||
node: MapNode,
|
||||
marker: google.maps.marker.AdvancedMarkerElement
|
||||
): 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);
|
||||
const activityLevel = getActivityLevel(node.lastHeard, node.isGateway);
|
||||
const colors = getNodeColors(activityLevel, node.isGateway);
|
||||
const statusText = getStatusText(activityLevel);
|
||||
const statusDotColor = colors.fill;
|
||||
|
||||
const container = document.createElement('div');
|
||||
container.style.cssText = 'font-family: sans-serif; max-width: 240px; color: #999999;';
|
||||
|
||||
const heading = document.createElement('h3');
|
||||
heading.style.cssText = `margin: 0 0 8px; font-size: 16px; color: ${statusDotColor}; font-weight: 600;`;
|
||||
heading.textContent = nodeName;
|
||||
container.appendChild(heading);
|
||||
|
||||
const subtitle = document.createElement('div');
|
||||
subtitle.style.cssText = 'font-size: 12px; color: #555; margin-bottom: 8px; font-weight: 500;';
|
||||
subtitle.textContent = `${node.isGateway ? 'Gateway' : 'Node'} · !${node.id.toString(16)}`;
|
||||
container.appendChild(subtitle);
|
||||
|
||||
const statusRow = document.createElement('div');
|
||||
statusRow.style.cssText = 'font-size: 12px; margin-bottom: 4px; color: #333; display: flex; align-items: center;';
|
||||
const dot = document.createElement('span');
|
||||
dot.style.cssText = `display: inline-block; width: 8px; height: 8px; border-radius: 50%; background-color: ${statusDotColor}; margin-right: 6px;`;
|
||||
const statusLabel = document.createElement('span');
|
||||
statusLabel.textContent = `${statusText} - Last seen: ${lastSeenText}`;
|
||||
statusRow.appendChild(dot);
|
||||
statusRow.appendChild(statusLabel);
|
||||
container.appendChild(statusRow);
|
||||
|
||||
const counts = document.createElement('div');
|
||||
counts.style.cssText = 'font-size: 12px; margin-bottom: 8px; color: #333;';
|
||||
counts.textContent = `Packets: ${node.messageCount || 0} · Text: ${node.textMessageCount || 0}`;
|
||||
container.appendChild(counts);
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = `/node/${node.id.toString(16)}`;
|
||||
link.style.cssText = 'font-size: 13px; color: #3b82f6; text-decoration: none; font-weight: 500; display: inline-block; padding: 4px 8px; background-color: #f1f5f9; border-radius: 4px;';
|
||||
link.textContent = 'View details →';
|
||||
container.appendChild(link);
|
||||
|
||||
infoWindowRef.current.setContent(container);
|
||||
infoWindowRef.current.open(mapInstanceRef.current, marker);
|
||||
}, []);
|
||||
|
||||
// Update an existing marker
|
||||
const updateMarker = useCallback((node: MapNode, position: google.maps.LatLngLiteral): void => {
|
||||
const key = `node-${node.id}`;
|
||||
const marker = markersRef.current[key];
|
||||
marker.position = position;
|
||||
marker.content = buildMarkerContent(node);
|
||||
}, []);
|
||||
|
||||
// Create a new marker
|
||||
const createMarker = useCallback((
|
||||
node: MapNode,
|
||||
position: google.maps.LatLngLiteral,
|
||||
nodeName: string
|
||||
): void => {
|
||||
if (!mapInstanceRef.current || !infoWindowRef.current) return;
|
||||
|
||||
const key = `node-${node.id}`;
|
||||
const marker = new google.maps.marker.AdvancedMarkerElement({
|
||||
position,
|
||||
map: mapInstanceRef.current,
|
||||
title: nodeName,
|
||||
zIndex: node.isGateway ? 10 : 5,
|
||||
content: buildMarkerContent(node),
|
||||
});
|
||||
|
||||
marker.addListener('gmp-click', () => {
|
||||
showInfoWindow(node, marker);
|
||||
});
|
||||
|
||||
markersRef.current[key] = marker;
|
||||
}, [showInfoWindow]);
|
||||
|
||||
// Helper function to initialize the map
|
||||
const initializeMap = useCallback((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
|
||||
const updateNodeMarkers = useCallback((nodes: MapNode[]): void => {
|
||||
if (!mapInstanceRef.current) return;
|
||||
|
||||
if (window.google && window.google.maps) {
|
||||
boundsRef.current = new google.maps.LatLngBounds();
|
||||
} else {
|
||||
boundsRef.current = null;
|
||||
}
|
||||
const allKeys = new Set<string>();
|
||||
|
||||
nodes.forEach(node => {
|
||||
const key = `node-${node.id}`;
|
||||
allKeys.add(key);
|
||||
|
||||
const lat = node.position.latitudeI / 10000000;
|
||||
const lng = node.position.longitudeI / 10000000;
|
||||
const position = { lat, lng };
|
||||
|
||||
if (boundsRef.current) {
|
||||
boundsRef.current.extend(position);
|
||||
}
|
||||
|
||||
const nodeName = node.shortName || node.longName ||
|
||||
`${node.isGateway ? 'Gateway' : 'Node'} ${node.id.toString(16)}`;
|
||||
|
||||
if (!markersRef.current[key]) {
|
||||
createMarker(node, position, nodeName);
|
||||
} else {
|
||||
updateMarker(node, position);
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(markersRef.current).forEach(key => {
|
||||
if (!allKeys.has(key)) {
|
||||
markersRef.current[key].map = null;
|
||||
delete markersRef.current[key];
|
||||
}
|
||||
});
|
||||
|
||||
if (autoZoomEnabled && nodes.length > 0) {
|
||||
fitMapToBounds();
|
||||
}
|
||||
}, [autoZoomEnabled, fitMapToBounds, createMarker, updateMarker]);
|
||||
|
||||
// Update topology polylines on the map
|
||||
const updateLinks = useCallback((
|
||||
links: Record<string, LinkObservation>,
|
||||
nodePositions: MapNode[],
|
||||
visible: boolean
|
||||
): void => {
|
||||
if (!mapInstanceRef.current || !window.google?.maps) return;
|
||||
|
||||
// Build position lookup
|
||||
const posMap = new Map<number, google.maps.LatLngLiteral>();
|
||||
for (const node of nodePositions) {
|
||||
posMap.set(node.id, {
|
||||
lat: node.position.latitudeI / 10000000,
|
||||
lng: node.position.longitudeI / 10000000,
|
||||
});
|
||||
}
|
||||
|
||||
const activeKeys = new Set<string>();
|
||||
|
||||
for (const link of Object.values(links)) {
|
||||
const posA = posMap.get(link.nodeA);
|
||||
const posB = posMap.get(link.nodeB);
|
||||
if (!posA || !posB) continue;
|
||||
|
||||
activeKeys.add(link.key);
|
||||
|
||||
// Determine color based on best available SNR
|
||||
const snr = link.snrAtoB ?? link.snrBtoA;
|
||||
let strokeColor: string;
|
||||
if (snr === undefined) {
|
||||
strokeColor = "#6b7280"; // gray — no SNR data
|
||||
} else if (snr >= 5) {
|
||||
strokeColor = "#22c55e"; // green — strong
|
||||
} else if (snr >= 0) {
|
||||
strokeColor = "#eab308"; // yellow — marginal
|
||||
} else {
|
||||
strokeColor = "#ef4444"; // red — weak
|
||||
}
|
||||
const strokeOpacity = link.viaMqtt ? 0.4 : 0.7;
|
||||
|
||||
if (polylinesRef.current[link.key]) {
|
||||
const pl = polylinesRef.current[link.key];
|
||||
pl.setPath([posA, posB]);
|
||||
pl.setOptions({ strokeColor, strokeOpacity, visible });
|
||||
} else {
|
||||
polylinesRef.current[link.key] = new google.maps.Polyline({
|
||||
path: [posA, posB],
|
||||
geodesic: true,
|
||||
strokeColor,
|
||||
strokeOpacity,
|
||||
strokeWeight: 2,
|
||||
map: visible ? mapInstanceRef.current : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove polylines for edges no longer in state
|
||||
for (const key of Object.keys(polylinesRef.current)) {
|
||||
if (!activeKeys.has(key)) {
|
||||
polylinesRef.current[key].setMap(null);
|
||||
delete polylinesRef.current[key];
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 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);
|
||||
updateLinks(topologyLinks, nodesWithPosition, showLinks);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error initializing map:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
console.warn("Cannot initialize map - prerequisites not met");
|
||||
return false;
|
||||
}, [nodesWithPosition, topologyLinks, showLinks, updateNodeMarkers, updateLinks, 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, isGoogleMapsLoaded]);
|
||||
|
||||
// Update parent component when auto-zoom state changes
|
||||
useEffect(() => {
|
||||
if (onAutoZoomChange) {
|
||||
onAutoZoomChange(autoZoomEnabled);
|
||||
}
|
||||
}, [autoZoomEnabled, onAutoZoomChange]);
|
||||
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
const zoomListener = zoomListenerRef;
|
||||
const markers = markersRef;
|
||||
const animatingNodes = animatingNodesRef;
|
||||
const infoWindow = infoWindowRef;
|
||||
const polylines = polylinesRef;
|
||||
return () => {
|
||||
if (zoomListener.current && window.google && window.google.maps) {
|
||||
window.google.maps.event.removeListener(zoomListener.current);
|
||||
zoomListener.current = null;
|
||||
}
|
||||
Object.values(markers.current).forEach(marker => marker.map = null);
|
||||
Object.values(animatingNodes.current).forEach(timeoutId =>
|
||||
window.clearTimeout(timeoutId)
|
||||
);
|
||||
Object.values(polylines.current).forEach(pl => pl.setMap(null));
|
||||
if (infoWindow.current) {
|
||||
infoWindow.current.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const mapContainerStyle = {
|
||||
...(height && !fullHeight ? { height } : {}),
|
||||
...(fullHeight ? { height: '100%' } : {})
|
||||
};
|
||||
|
||||
const wrapperClassName = `w-full ${fullHeight ? 'h-full flex flex-col' : ''}`;
|
||||
const mapClassName = `w-full overflow-hidden effect-inset rounded-lg relative ${fullHeight ? 'flex-1' : ''}`;
|
||||
|
||||
if (!isGoogleMapsLoaded) {
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
<div
|
||||
className={`${mapClassName} flex items-center justify-center`}
|
||||
style={mapContainerStyle}
|
||||
>
|
||||
<div className="text-gray-400">Loading map...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
<div
|
||||
ref={mapRef}
|
||||
className={mapClassName}
|
||||
style={mapContainerStyle}
|
||||
/>
|
||||
</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
|
||||
latitudeI: number;
|
||||
longitudeI: number;
|
||||
};
|
||||
isGateway: boolean;
|
||||
gatewayId?: string;
|
||||
@@ -516,135 +35,367 @@ interface MapNode {
|
||||
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
|
||||
/**
|
||||
* NetworkMap displays all nodes with position data on a MapLibre GL map
|
||||
*/
|
||||
export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, NetworkMapProps>(
|
||||
({ height, fullHeight = false, onAutoZoomChange, showLinks = true }, ref) => {
|
||||
const navigate = useNavigate();
|
||||
const mapRef = useRef<MapRef>(null);
|
||||
const [mapLoaded, setMapLoaded] = useState(false);
|
||||
const [autoZoomEnabled, setAutoZoomEnabled] = useState(true);
|
||||
const [selectedNode, setSelectedNode] = useState<MapNode | null>(null);
|
||||
|
||||
const { nodes, gateways } = useAppSelector((state) => state.aggregator);
|
||||
const topologyLinks = useAppSelector((state) => state.topology.links);
|
||||
|
||||
const nodesWithPosition = useMemo(
|
||||
() => getNodesWithPosition(nodes, gateways),
|
||||
[nodes, gateways]
|
||||
);
|
||||
|
||||
// Build GeoJSON for node circles
|
||||
const nodesGeoJSON = useMemo((): FeatureCollection => ({
|
||||
type: "FeatureCollection",
|
||||
features: nodesWithPosition.map((node) => {
|
||||
const level = getActivityLevel(node.lastHeard, node.isGateway);
|
||||
const colors = getNodeColors(level, node.isGateway);
|
||||
return {
|
||||
type: "Feature",
|
||||
id: node.id,
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: [
|
||||
node.position.longitudeI / 10000000,
|
||||
node.position.latitudeI / 10000000,
|
||||
],
|
||||
},
|
||||
properties: {
|
||||
nodeId: node.id,
|
||||
name: node.shortName || node.longName || `!${node.id.toString(16)}`,
|
||||
fillColor: colors.fill,
|
||||
strokeColor: colors.stroke,
|
||||
radius: node.isGateway ? 12 : 8,
|
||||
},
|
||||
};
|
||||
}),
|
||||
}), [nodesWithPosition]);
|
||||
|
||||
// Build GeoJSON for topology links
|
||||
const linksGeoJSON = useMemo((): FeatureCollection => {
|
||||
const posMap = new Map<number, [number, number]>();
|
||||
for (const node of nodesWithPosition) {
|
||||
posMap.set(node.id, [
|
||||
node.position.longitudeI / 10000000,
|
||||
node.position.latitudeI / 10000000,
|
||||
]);
|
||||
}
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features: Object.values(topologyLinks)
|
||||
.filter((link) => posMap.has(link.nodeA) && posMap.has(link.nodeB))
|
||||
.map((link) => {
|
||||
const snr = link.snrAtoB ?? link.snrBtoA;
|
||||
const color =
|
||||
snr === undefined ? "#6b7280"
|
||||
: snr >= 5 ? "#22c55e"
|
||||
: snr >= 0 ? "#eab308"
|
||||
: "#ef4444";
|
||||
return {
|
||||
type: "Feature" as const,
|
||||
geometry: {
|
||||
type: "LineString" as const,
|
||||
coordinates: [posMap.get(link.nodeA)!, posMap.get(link.nodeB)!],
|
||||
},
|
||||
properties: { color, opacity: link.viaMqtt ? 0.4 : 0.7 },
|
||||
};
|
||||
}),
|
||||
};
|
||||
}, [topologyLinks, nodesWithPosition]);
|
||||
|
||||
// Fit map bounds when auto-zoom is enabled and nodes change
|
||||
useEffect(() => {
|
||||
if (!autoZoomEnabled || nodesWithPosition.length === 0 || !mapRef.current || !mapLoaded) return;
|
||||
let minLng = Infinity, maxLng = -Infinity, minLat = Infinity, maxLat = -Infinity;
|
||||
for (const n of nodesWithPosition) {
|
||||
const lng = n.position.longitudeI / 10000000;
|
||||
const lat = n.position.latitudeI / 10000000;
|
||||
if (lng < minLng) minLng = lng;
|
||||
if (lng > maxLng) maxLng = lng;
|
||||
if (lat < minLat) minLat = lat;
|
||||
if (lat > maxLat) maxLat = lat;
|
||||
}
|
||||
mapRef.current.fitBounds(
|
||||
[[minLng, minLat], [maxLng, maxLat]],
|
||||
{ padding: 60, maxZoom: 15, duration: 500 }
|
||||
);
|
||||
}, [autoZoomEnabled, nodesWithPosition, mapLoaded]);
|
||||
|
||||
// Notify parent of auto-zoom state
|
||||
useEffect(() => {
|
||||
onAutoZoomChange?.(autoZoomEnabled);
|
||||
}, [autoZoomEnabled, onAutoZoomChange]);
|
||||
|
||||
// Expose resetAutoZoom via ref
|
||||
React.useImperativeHandle(ref, () => ({
|
||||
resetAutoZoom: () => setAutoZoomEnabled(true),
|
||||
}));
|
||||
|
||||
// Disable auto-zoom on user interaction
|
||||
const handleUserInteraction = useCallback(() => {
|
||||
setAutoZoomEnabled(false);
|
||||
}, []);
|
||||
|
||||
// Handle node click via interactiveLayerIds
|
||||
const handleMapClick = useCallback(
|
||||
(e: { features?: Array<{ properties: Record<string, unknown> }> }) => {
|
||||
const features = e.features;
|
||||
if (!features || features.length === 0) {
|
||||
setSelectedNode(null);
|
||||
return;
|
||||
}
|
||||
const nodeId = features[0].properties?.nodeId as number | undefined;
|
||||
if (nodeId === undefined) return;
|
||||
const node = nodesWithPosition.find((n) => n.id === nodeId);
|
||||
if (node) setSelectedNode(node);
|
||||
},
|
||||
[nodesWithPosition]
|
||||
);
|
||||
|
||||
const wrapperClassName = `w-full ${fullHeight ? "h-full flex flex-col" : ""}`;
|
||||
const mapClassName = `w-full overflow-hidden effect-inset rounded-lg relative ${fullHeight ? "flex-1" : ""}`;
|
||||
const containerStyle = height && !fullHeight ? { height } : fullHeight ? { height: "100%" } : {};
|
||||
|
||||
return (
|
||||
<div className={wrapperClassName}>
|
||||
<div className={mapClassName} style={containerStyle}>
|
||||
<ReactMap
|
||||
ref={mapRef}
|
||||
mapStyle={CARTO_DARK_STYLE_LABELLED}
|
||||
initialViewState={{ longitude: -98, latitude: 39, zoom: 4 }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
interactiveLayerIds={["nodes-circles"]}
|
||||
onMouseEnter={() => {
|
||||
if (mapRef.current) mapRef.current.getMap().getCanvas().style.cursor = "pointer";
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (mapRef.current) mapRef.current.getMap().getCanvas().style.cursor = "grab";
|
||||
}}
|
||||
onClick={handleMapClick as never}
|
||||
onDragStart={handleUserInteraction}
|
||||
onZoomStart={handleUserInteraction}
|
||||
onLoad={() => setMapLoaded(true)}
|
||||
>
|
||||
{/* Topology links — always mounted, visibility controlled via layout property */}
|
||||
<Source id="links" type="geojson" data={linksGeoJSON}>
|
||||
<Layer
|
||||
id="links-line"
|
||||
type="line"
|
||||
layout={{
|
||||
"line-join": "round",
|
||||
"line-cap": "round",
|
||||
"visibility": showLinks ? "visible" : "none",
|
||||
}}
|
||||
paint={{
|
||||
"line-color": ["get", "color"],
|
||||
"line-width": 2,
|
||||
"line-opacity": ["get", "opacity"],
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* Node circles */}
|
||||
<Source id="nodes" type="geojson" data={nodesGeoJSON}>
|
||||
<Layer
|
||||
id="nodes-circles"
|
||||
type="circle"
|
||||
paint={{
|
||||
"circle-radius": ["get", "radius"],
|
||||
"circle-color": ["get", "fillColor"],
|
||||
"circle-stroke-width": 2,
|
||||
"circle-stroke-color": ["get", "strokeColor"],
|
||||
"circle-opacity": 0.9,
|
||||
"circle-stroke-opacity": 1,
|
||||
}}
|
||||
/>
|
||||
<Layer
|
||||
id="nodes-labels"
|
||||
type="symbol"
|
||||
layout={{
|
||||
"text-field": ["get", "name"],
|
||||
"text-size": 11,
|
||||
"text-offset": [0, 1.5],
|
||||
"text-anchor": "top",
|
||||
"text-optional": true,
|
||||
}}
|
||||
paint={{
|
||||
"text-color": "#e5e7eb",
|
||||
"text-halo-color": "#111827",
|
||||
"text-halo-width": 1.5,
|
||||
}}
|
||||
/>
|
||||
</Source>
|
||||
|
||||
{/* Node popup */}
|
||||
{selectedNode && (
|
||||
<Popup
|
||||
longitude={selectedNode.position.longitudeI / 10000000}
|
||||
latitude={selectedNode.position.latitudeI / 10000000}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
closeOnClick={false}
|
||||
maxWidth="240px"
|
||||
anchor="bottom"
|
||||
>
|
||||
<NodePopup
|
||||
node={selectedNode}
|
||||
onNavigate={(id) => {
|
||||
setSelectedNode(null);
|
||||
navigate({ to: "/node/$nodeId", params: { nodeId: id.toString(16) } });
|
||||
}}
|
||||
/>
|
||||
</Popup>
|
||||
)}
|
||||
</ReactMap>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
NetworkMap.displayName = "NetworkMap";
|
||||
|
||||
// ─── Popup content ────────────────────────────────────────────────────────────
|
||||
|
||||
function NodePopup({
|
||||
node,
|
||||
onNavigate,
|
||||
}: {
|
||||
node: MapNode;
|
||||
onNavigate: (id: number) => void;
|
||||
}) {
|
||||
const level = getActivityLevel(node.lastHeard, node.isGateway);
|
||||
const colors = getNodeColors(level, node.isGateway);
|
||||
const statusText = getStatusText(level);
|
||||
const secondsAgo = node.lastHeard ? Math.floor(Date.now() / 1000) - node.lastHeard : 0;
|
||||
const lastSeenText = formatLastSeen(secondsAgo);
|
||||
const nodeName = node.longName || node.shortName || `!${node.id.toString(16)}`;
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: "sans-serif", maxWidth: 220 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, color: colors.fill, marginBottom: 3 }}>
|
||||
{nodeName}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#6b7280", marginBottom: 6 }}>
|
||||
{node.isGateway ? "Gateway" : "Node"} · !{node.id.toString(16)}
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", fontSize: 11, marginBottom: 4 }}>
|
||||
<span
|
||||
style={{
|
||||
width: 7,
|
||||
height: 7,
|
||||
borderRadius: "50%",
|
||||
backgroundColor: colors.fill,
|
||||
display: "inline-block",
|
||||
marginRight: 5,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<span style={{ color: "#374151" }}>
|
||||
{statusText} · {lastSeenText}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "#6b7280", marginBottom: 8 }}>
|
||||
Packets: {node.messageCount} · Text: {node.textMessageCount}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onNavigate(node.id)}
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
color: "#3b82f6",
|
||||
background: "#f1f5f9",
|
||||
border: "none",
|
||||
borderRadius: 4,
|
||||
padding: "4px 8px",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
View details →
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
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>,
|
||||
nodes: Record<number, NodeData>,
|
||||
gateways: Record<string, GatewayData>
|
||||
): MapNode[] {
|
||||
const nodesMap = new Map<number, MapNode>(); // Use a Map to avoid duplicates
|
||||
|
||||
// Regular nodes
|
||||
const nodesMap = new Map<number, MapNode>();
|
||||
|
||||
Object.entries(nodes).forEach(([nodeIdStr, nodeData]) => {
|
||||
if (hasValidPosition(nodeData)) {
|
||||
const nodeId = parseInt(nodeIdStr);
|
||||
const position = nodeData.position as MapNode['position'];
|
||||
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
|
||||
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 &&
|
||||
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,
|
||||
gatewayId,
|
||||
position: {
|
||||
latitudeI: nodeWithMapReport.mapReport.latitudeI!,
|
||||
longitudeI: nodeWithMapReport.mapReport.longitudeI!,
|
||||
precisionBits: nodeWithMapReport.mapReport.positionPrecision,
|
||||
time: nodeWithMapReport.lastHeard || Math.floor(Date.now() / 1000)
|
||||
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
|
||||
longName: nodeWithMapReport.longName,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Second priority: Mark existing node as gateway if it already has position data
|
||||
else if (nodesMap.has(nodeId)) {
|
||||
} else if (nodesMap.has(nodeId)) {
|
||||
const existingNode = nodesMap.get(nodeId)!;
|
||||
nodesMap.set(nodeId, {
|
||||
...existingNode,
|
||||
isGateway: true,
|
||||
gatewayId: gatewayId,
|
||||
// Update data from gateway information
|
||||
gatewayId,
|
||||
lastHeard: Math.max(existingNode.lastHeard || 0, gatewayData.lastHeard || 0),
|
||||
messageCount: existingNode.messageCount || gatewayData.messageCount || 0,
|
||||
textMessageCount: existingNode.textMessageCount || gatewayData.textMessageCount || 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;
|
||||
}
|
||||
|
||||
// Build marker content element for a node (pure, no React state)
|
||||
function buildMarkerContent(node: MapNode): HTMLElement {
|
||||
const iconStyle = getMarkerIcon(node);
|
||||
const size = iconStyle.scale * 2;
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('width', String(size));
|
||||
svg.setAttribute('height', String(size));
|
||||
svg.setAttribute('viewBox', `0 0 ${size} ${size}`);
|
||||
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
circle.setAttribute('cx', String(iconStyle.scale));
|
||||
circle.setAttribute('cy', String(iconStyle.scale));
|
||||
circle.setAttribute('r', String(iconStyle.scale - iconStyle.strokeWeight));
|
||||
circle.setAttribute('fill', iconStyle.fillColor);
|
||||
circle.setAttribute('fill-opacity', String(iconStyle.fillOpacity));
|
||||
circle.setAttribute('stroke', iconStyle.strokeColor);
|
||||
circle.setAttribute('stroke-width', String(iconStyle.strokeWeight));
|
||||
svg.appendChild(circle);
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.style.cursor = 'pointer';
|
||||
wrapper.appendChild(svg);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
// Get marker icon for a node
|
||||
function getMarkerIcon(node: MapNode): MarkerIconConfig {
|
||||
const activityLevel = getActivityLevel(node.lastHeard, node.isGateway);
|
||||
const colors = getNodeColors(activityLevel, node.isGateway);
|
||||
|
||||
return {
|
||||
path: google.maps.SymbolPath.CIRCLE,
|
||||
scale: 12,
|
||||
fillColor: colors.fill,
|
||||
fillOpacity: 1,
|
||||
strokeColor: colors.stroke,
|
||||
strokeWeight: 2,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ import { Separator } from "../Separator";
|
||||
import { KeyValuePair } from "../ui/KeyValuePair";
|
||||
import { Section } from "../ui/Section";
|
||||
import { BatteryLevel } from "./BatteryLevel";
|
||||
import { GoogleMap } from "./GoogleMap";
|
||||
import { NodeLocationMap } from "./GoogleMap";
|
||||
import { NodePositionData } from "./NodePositionData";
|
||||
import { EnvironmentMetrics } from "./EnvironmentMetrics";
|
||||
import { NodePacketList } from "./NodePacketList";
|
||||
@@ -585,7 +585,7 @@ export const NodeDetail: React.FC<NodeDetailProps> = ({ nodeId }) => {
|
||||
className="mt-6"
|
||||
>
|
||||
<div className="h-[400px] rounded-lg overflow-hidden relative shadow-inner">
|
||||
<GoogleMap
|
||||
<NodeLocationMap
|
||||
lat={latitude}
|
||||
lng={longitude}
|
||||
precisionBits={precisionBits}
|
||||
|
||||
@@ -6,7 +6,7 @@ export * from './NodeDetail';
|
||||
export * from './ChannelDetail';
|
||||
export * from './BatteryLevel';
|
||||
export * from './SignalStrength';
|
||||
export * from './GoogleMap';
|
||||
export { NodeLocationMap } from './GoogleMap';
|
||||
export * from './NetworkMap';
|
||||
export * from './NodePacketList';
|
||||
export * from './NodePositionData';
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import { Map as MapIcon, MapPin, Network } from "lucide-react";
|
||||
import { PacketCard } from "./PacketCard";
|
||||
import { KeyValueGrid, KeyValuePair } from "../ui/KeyValuePair";
|
||||
import { Map } from "../Map";
|
||||
import { LocationMap } from "../Map";
|
||||
|
||||
interface MapReportPacketProps {
|
||||
packet: Packet;
|
||||
@@ -275,11 +275,9 @@ export const MapReportPacket: React.FC<MapReportPacketProps> = ({ packet }) => {
|
||||
Gateway Location
|
||||
</h3>
|
||||
<div className="h-[300px] rounded-lg overflow-hidden relative">
|
||||
<Map
|
||||
<LocationMap
|
||||
latitude={center.latitude}
|
||||
longitude={center.longitude}
|
||||
width={400}
|
||||
height={300}
|
||||
flush={true}
|
||||
caption="Gateway Location"
|
||||
precisionBits={precisionBits}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Packet } from "../../lib/types";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { PacketCard } from "./PacketCard";
|
||||
import { KeyValueGrid, KeyValuePair } from "../ui/KeyValuePair";
|
||||
import { Map } from "../Map";
|
||||
import { LocationMap } from "../Map";
|
||||
|
||||
interface PositionPacketProps {
|
||||
packet: Packet;
|
||||
@@ -84,11 +84,9 @@ export const PositionPacket: React.FC<PositionPacketProps> = ({ packet }) => {
|
||||
|
||||
{latitude !== undefined && longitude !== undefined && (
|
||||
<div className="h-[240px] w-full rounded-lg overflow-hidden">
|
||||
<Map
|
||||
<LocationMap
|
||||
latitude={latitude}
|
||||
longitude={longitude}
|
||||
width={400}
|
||||
height={240}
|
||||
flush={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Packet } from "../../lib/types";
|
||||
import { MapPin } from "lucide-react";
|
||||
import { PacketCard } from "./PacketCard";
|
||||
import { KeyValueGrid, KeyValuePair } from "../ui/KeyValuePair";
|
||||
import { Map } from "../Map";
|
||||
import { LocationMap } from "../Map";
|
||||
|
||||
interface WaypointPacketProps {
|
||||
packet: Packet;
|
||||
@@ -80,12 +80,10 @@ export const WaypointPacket: React.FC<WaypointPacketProps> = ({ packet }) => {
|
||||
|
||||
{latitude !== undefined && longitude !== undefined && (
|
||||
<div className="h-[240px] w-full rounded-lg overflow-hidden">
|
||||
<Map
|
||||
<LocationMap
|
||||
latitude={latitude}
|
||||
longitude={longitude}
|
||||
caption={waypoint.name}
|
||||
width={400}
|
||||
height={240}
|
||||
flush={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -13,8 +13,6 @@ export const SITE_DESCRIPTION =
|
||||
import.meta.env.VITE_SITE_DESCRIPTION ||
|
||||
"Realtime Meshtastic activity via MQTT.";
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "";
|
||||
export const GOOGLE_MAPS_ID = import.meta.env.VITE_GOOGLE_MAPS_ID || "demo-map-id";
|
||||
export const GOOGLE_MAPS_API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || "";
|
||||
|
||||
// API endpoints
|
||||
export const API_ENDPOINTS = {
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { StyleSpecification } from "maplibre-gl";
|
||||
|
||||
const CARTO_TILES = [
|
||||
"https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
|
||||
"https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
|
||||
"https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png",
|
||||
];
|
||||
|
||||
const CARTO_SOURCE = {
|
||||
type: "raster" as const,
|
||||
tiles: CARTO_TILES,
|
||||
tileSize: 256,
|
||||
attribution:
|
||||
'© <a href="https://carto.com/attributions">CARTO</a> © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap contributors</a>',
|
||||
maxzoom: 19,
|
||||
};
|
||||
|
||||
/** Base CartoDB Dark Matter style — raster tiles, no labels */
|
||||
export const CARTO_DARK_STYLE: StyleSpecification = {
|
||||
version: 8,
|
||||
sources: { carto: CARTO_SOURCE },
|
||||
layers: [{ id: "carto-dark", type: "raster", source: "carto" }],
|
||||
};
|
||||
|
||||
/** CartoDB Dark Matter style with glyph support for GL text labels */
|
||||
export const CARTO_DARK_STYLE_LABELLED: StyleSpecification = {
|
||||
version: 8,
|
||||
glyphs: "https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf",
|
||||
sources: { carto: CARTO_SOURCE },
|
||||
layers: [{ id: "carto-dark", type: "raster", source: "carto" }],
|
||||
};
|
||||
+24
-54
@@ -45,63 +45,33 @@ export const calculateZoomFromAccuracy = (accuracyMeters: number): number => {
|
||||
return 10;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Generate a Google Maps Static API URL for a given latitude and longitude
|
||||
* @param latitude The latitude in decimal degrees
|
||||
* @param longitude The longitude in decimal degrees
|
||||
* @param zoom The zoom level (1-20)
|
||||
* @param width The image width in pixels
|
||||
* @param height The image height in pixels
|
||||
* @param nightMode Whether to use dark styling for the map
|
||||
* @param precisionBits Optional precision bits to determine how to display the marker
|
||||
* @returns A URL string for the Google Maps Static API
|
||||
* Approximate a geographic circle as a GeoJSON polygon ring.
|
||||
* @param lng Center longitude
|
||||
* @param lat Center latitude
|
||||
* @param radiusMeters Radius in meters
|
||||
* @param points Number of polygon vertices (more = smoother)
|
||||
*/
|
||||
export const getStaticMapUrl = (
|
||||
latitude: number,
|
||||
longitude: number,
|
||||
zoom: number = 15,
|
||||
width: number = 300,
|
||||
height: number = 200,
|
||||
nightMode: boolean = true,
|
||||
precisionBits?: number
|
||||
): string => {
|
||||
// Get API key from environment variable
|
||||
const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY || "";
|
||||
|
||||
// Build the URL
|
||||
const mapUrl = new URL("https://maps.googleapis.com/maps/api/staticmap");
|
||||
|
||||
// Add parameters
|
||||
mapUrl.searchParams.append("center", `${latitude},${longitude}`);
|
||||
mapUrl.searchParams.append("zoom", zoom.toString());
|
||||
mapUrl.searchParams.append("size", `${width}x${height}`);
|
||||
mapUrl.searchParams.append("key", apiKey);
|
||||
mapUrl.searchParams.append("format", "png");
|
||||
mapUrl.searchParams.append("scale", "2"); // Retina display support
|
||||
|
||||
// Only add marker if we don't have precision information
|
||||
if (precisionBits === undefined) {
|
||||
mapUrl.searchParams.append(
|
||||
"markers",
|
||||
`color:green|${latitude},${longitude}`
|
||||
);
|
||||
export function buildCircleCoords(
|
||||
lng: number,
|
||||
lat: number,
|
||||
radiusMeters: number,
|
||||
points = 64
|
||||
): [number, number][] {
|
||||
const earthRadius = 6371000;
|
||||
const coords: [number, number][] = [];
|
||||
for (let i = 0; i <= points; i++) {
|
||||
const angle = (i / points) * 2 * Math.PI;
|
||||
const dx = radiusMeters * Math.cos(angle);
|
||||
const dy = radiusMeters * Math.sin(angle);
|
||||
const pLat = lat + (dy / earthRadius) * (180 / Math.PI);
|
||||
const pLng = lng + (dx / (earthRadius * Math.cos((lat * Math.PI) / 180))) * (180 / Math.PI);
|
||||
coords.push([pLng, pLat]);
|
||||
}
|
||||
// With static maps we can't draw circles directly, so we use a marker with different color
|
||||
// even when we have precision information, but we'll show it differently in the interactive map
|
||||
else {
|
||||
mapUrl.searchParams.append(
|
||||
"markers",
|
||||
`color:green|${latitude},${longitude}`
|
||||
);
|
||||
}
|
||||
|
||||
// Apply night mode styling using the simpler approach
|
||||
if (nightMode) {
|
||||
mapUrl.searchParams.append("style", "invert_lightness:true");
|
||||
}
|
||||
|
||||
return mapUrl.toString();
|
||||
};
|
||||
coords.push(coords[0]); // close the ring
|
||||
return coords;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Google Maps URL to open the location in Google Maps
|
||||
|
||||
Vendored
-163
@@ -1,163 +0,0 @@
|
||||
// Type definitions for Google Maps JavaScript API
|
||||
declare namespace google {
|
||||
namespace maps {
|
||||
class Map {
|
||||
constructor(
|
||||
mapDiv: Element,
|
||||
opts?: MapOptions
|
||||
);
|
||||
setZoom(zoom: number): void;
|
||||
getZoom(): number | undefined;
|
||||
fitBounds(bounds: LatLngBounds): void;
|
||||
addListener(event: string, handler: () => void): MapsEventListener;
|
||||
}
|
||||
|
||||
class Marker {
|
||||
constructor(opts?: MarkerOptions);
|
||||
setMap(map: Map | null): void;
|
||||
setPosition(position: LatLngLiteral): void;
|
||||
setIcon(icon: any): void;
|
||||
addListener(event: string, handler: () => void): MapsEventListener;
|
||||
}
|
||||
|
||||
namespace marker {
|
||||
class AdvancedMarkerElement {
|
||||
constructor(opts?: AdvancedMarkerElementOptions);
|
||||
position: LatLngLiteral | null;
|
||||
map: Map | null;
|
||||
title: string | null;
|
||||
zIndex: number | null;
|
||||
content: HTMLElement | null;
|
||||
addListener(event: string, handler: () => void): MapsEventListener;
|
||||
}
|
||||
}
|
||||
|
||||
interface AdvancedMarkerElementOptions {
|
||||
position?: LatLngLiteral;
|
||||
map?: Map;
|
||||
title?: string;
|
||||
zIndex?: number;
|
||||
content?: HTMLElement;
|
||||
}
|
||||
|
||||
class Circle {
|
||||
constructor(opts?: CircleOptions);
|
||||
setMap(map: Map | null): void;
|
||||
}
|
||||
|
||||
class InfoWindow {
|
||||
constructor(opts?: InfoWindowOptions);
|
||||
setContent(content: string | HTMLElement): void;
|
||||
open(map?: Map, anchor?: any): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
class Polyline {
|
||||
constructor(opts?: PolylineOptions);
|
||||
setMap(map: Map | null): void;
|
||||
setPath(path: LatLngLiteral[]): void;
|
||||
setOptions(opts: PolylineOptions): void;
|
||||
setVisible(visible: boolean): void;
|
||||
}
|
||||
|
||||
class LatLngBounds {
|
||||
constructor();
|
||||
extend(point: LatLngLiteral): void;
|
||||
}
|
||||
|
||||
interface LatLngLiteral {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
interface MapOptions {
|
||||
center?: LatLngLiteral;
|
||||
zoom?: number;
|
||||
mapTypeId?: string;
|
||||
colorScheme?: string;
|
||||
mapTypeControl?: boolean;
|
||||
streetViewControl?: boolean;
|
||||
fullscreenControl?: boolean;
|
||||
zoomControl?: boolean;
|
||||
styles?: Array<any>;
|
||||
mapId?: string;
|
||||
}
|
||||
|
||||
interface MarkerOptions {
|
||||
position?: LatLngLiteral;
|
||||
map?: Map;
|
||||
title?: string;
|
||||
icon?: any;
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
interface CircleOptions {
|
||||
strokeColor?: string;
|
||||
strokeOpacity?: number;
|
||||
strokeWeight?: number;
|
||||
fillColor?: string;
|
||||
fillOpacity?: number;
|
||||
map?: Map;
|
||||
center?: LatLngLiteral;
|
||||
radius?: number;
|
||||
}
|
||||
|
||||
interface InfoWindowOptions {
|
||||
content?: string;
|
||||
position?: LatLngLiteral;
|
||||
}
|
||||
|
||||
interface PolylineOptions {
|
||||
path?: LatLngLiteral[];
|
||||
geodesic?: boolean;
|
||||
strokeColor?: string;
|
||||
strokeOpacity?: number;
|
||||
strokeWeight?: number;
|
||||
map?: Map | null;
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
// Event-related functionality
|
||||
const event: {
|
||||
addListener(instance: object, event: string, listener: (Event) => void): MapsEventListener;
|
||||
/**
|
||||
* 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;
|
||||
HYBRID: string;
|
||||
TERRAIN: string;
|
||||
};
|
||||
|
||||
const SymbolPath: {
|
||||
CIRCLE: number;
|
||||
FORWARD_CLOSED_ARROW: number;
|
||||
FORWARD_OPEN_ARROW: number;
|
||||
BACKWARD_CLOSED_ARROW: number;
|
||||
BACKWARD_OPEN_ARROW: number;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Extend the Window interface
|
||||
interface Window {
|
||||
google: typeof google;
|
||||
}
|
||||
Vendored
+2
-4
@@ -1,9 +1,7 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_GOOGLE_MAPS_API_KEY: string;
|
||||
// Add other environment variables as needed
|
||||
}
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
interface ImportMetaEnv {}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
|
||||
Reference in New Issue
Block a user