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:
Daniel Pupius
2026-03-15 20:22:12 +00:00
parent 6e650a7c57
commit d95a74c1d7
18 changed files with 996 additions and 1039 deletions
+100 -75
View File
@@ -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;
+65 -141
View File
@@ -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;
+320 -569
View File
@@ -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,
};
}
+2 -2
View File
@@ -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}
+1 -1
View File
@@ -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>
-2
View File
@@ -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 = {
+31
View File
@@ -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
View File
@@ -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
-163
View File
@@ -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;
}
+2 -4
View File
@@ -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;