Files
meshstream/web/src/components/dashboard/GoogleMap.tsx
T
2026-03-16 00:08:11 +00:00

102 lines
3.6 KiB
TypeScript

import React, { useEffect, useRef, useMemo } from "react";
import maplibregl from "maplibre-gl";
import type { GeoJSONSource } from "maplibre-gl";
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 NodeLocationMapProps {
/** Latitude coordinate */
lat: number;
/** Longitude coordinate */
lng: number;
/** Optional zoom level (1-20) */
zoom?: number;
/** Precision bits used for accuracy calculation */
precisionBits?: number;
/** Whether the map should take all available space (default: false) */
fullHeight?: boolean;
}
/**
* Single-node location map with accuracy circle
*/
export const NodeLocationMap: React.FC<NodeLocationMapProps> = ({
lat,
lng,
zoom,
precisionBits,
fullHeight = false,
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const accuracyMeters = calculateAccuracyFromPrecisionBits(precisionBits);
const effectiveZoom = zoom ?? Math.max(1, calculateZoomFromAccuracy(accuracyMeters) - 1);
const markerGeoJSON = useMemo((): FeatureCollection => ({
type: "FeatureCollection",
features: [{ type: "Feature", geometry: { type: "Point", coordinates: [lng, lat] }, properties: {} }],
}), [lat, lng]);
const circleGeoJSON = useMemo((): FeatureCollection => ({
type: "FeatureCollection",
features: [{
type: "Feature",
geometry: { type: "Polygon", coordinates: [buildCircleCoords(lng, lat, accuracyMeters)] },
properties: {},
}],
}), [lat, lng, accuracyMeters]);
// Mount map once
useEffect(() => {
if (!containerRef.current) return;
const map = new maplibregl.Map({
container: containerRef.current,
style: CARTO_DARK_STYLE,
center: [lng, lat],
zoom: effectiveZoom,
attributionControl: false,
});
map.addControl(new maplibregl.AttributionControl({ compact: true }));
map.addControl(new maplibregl.NavigationControl(), 'top-left');
map.on("load", () => {
map.addSource("circle", { type: "geojson", data: circleGeoJSON });
map.addLayer({ id: "circle-fill", type: "fill", source: "circle", paint: { "fill-color": "#4ade80", "fill-opacity": 0.15 } });
map.addLayer({ id: "circle-outline", type: "line", source: "circle", paint: { "line-color": "#22c55e", "line-width": 2, "line-opacity": 0.8 } });
map.addSource("marker", { type: "geojson", data: markerGeoJSON });
map.addLayer({ id: "marker-dot", type: "circle", source: "marker", paint: {
"circle-radius": 6,
"circle-color": "#4ade80",
"circle-stroke-width": 2,
"circle-stroke-color": "#22c55e",
"circle-opacity": 1,
}});
});
mapRef.current = map;
return () => { map.remove(); mapRef.current = null; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Update source data when coordinates change
useEffect(() => {
const map = mapRef.current;
if (!map || !map.isStyleLoaded()) return;
(map.getSource("marker") as GeoJSONSource)?.setData(markerGeoJSON);
(map.getSource("circle") as GeoJSONSource)?.setData(circleGeoJSON);
}, [markerGeoJSON, circleGeoJSON]);
const containerClassName = `w-full ${fullHeight ? "h-full flex-1" : "h-[300px]"} rounded-lg overflow-hidden effect-inset`;
return <div ref={containerRef} className={containerClassName} />;
};
/** @deprecated Use NodeLocationMap */
export const GoogleMap = NodeLocationMap;