mirror of
https://github.com/dpup/meshstream.git
synced 2026-03-28 17:42:37 +01:00
Replace react-map-gl with direct maplibre-gl imperative API
react-map-gl's Source/Layer components were silently dropping layers
even after onLoad gating — likely a compatibility issue with
maplibre-gl v5. Switch all three map components to the imperative API
(new maplibregl.Map, map.on('load', ...), source.setData()) which is
the approach shown in MapLibre's own docs and has no wrapper layer.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import React, { useRef, useState, useEffect, useMemo, useCallback } from "react";
|
||||
import ReactMap, { Source, Layer } from "react-map-gl/maplibre";
|
||||
import React, { useRef, useState, useEffect, 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";
|
||||
@@ -25,9 +26,8 @@ export const LocationMap: React.FC<LocationMapProps> = ({
|
||||
precisionBits,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [mapLoaded, setMapLoaded] = useState(false);
|
||||
const handleLoad = useCallback(() => setMapLoaded(true), []);
|
||||
|
||||
// Only mount the WebGL map when the container enters the viewport.
|
||||
// This prevents exhausting the browser's WebGL context limit (~8-16)
|
||||
@@ -59,55 +59,65 @@ export const LocationMap: React.FC<LocationMapProps> = ({
|
||||
const circleGeoJSON = useMemo((): FeatureCollection => ({
|
||||
type: "FeatureCollection",
|
||||
features: showAccuracyCircle
|
||||
? [
|
||||
{
|
||||
type: "Feature",
|
||||
geometry: { type: "Polygon", coordinates: [buildCircleCoords(longitude, latitude, accuracyMeters)] },
|
||||
properties: {},
|
||||
},
|
||||
]
|
||||
? [{
|
||||
type: "Feature",
|
||||
geometry: { type: "Polygon", coordinates: [buildCircleCoords(longitude, latitude, accuracyMeters)] },
|
||||
properties: {},
|
||||
}]
|
||||
: [],
|
||||
}), [latitude, longitude, accuracyMeters, showAccuracyCircle]);
|
||||
|
||||
// Mount map when visible, destroy when hidden
|
||||
useEffect(() => {
|
||||
if (!isVisible || !containerRef.current) return;
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style: CARTO_DARK_STYLE,
|
||||
center: [longitude, latitude],
|
||||
zoom: effectiveZoom,
|
||||
attributionControl: false,
|
||||
});
|
||||
|
||||
map.addControl(new maplibregl.AttributionControl({ compact: true }));
|
||||
|
||||
map.on("load", () => {
|
||||
if (showAccuracyCircle) {
|
||||
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": 1.5, "line-opacity": 0.8 } });
|
||||
}
|
||||
|
||||
map.addSource("marker", { type: "geojson", data: markerGeoJSON });
|
||||
map.addLayer({ id: "marker-dot", type: "circle", source: "marker", paint: {
|
||||
"circle-radius": 5,
|
||||
"circle-color": "#4ade80",
|
||||
"circle-stroke-width": 2,
|
||||
"circle-stroke-color": "#22c55e",
|
||||
}});
|
||||
});
|
||||
|
||||
mapRef.current = map;
|
||||
return () => { map.remove(); mapRef.current = null; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isVisible]);
|
||||
|
||||
// Update source data when coordinates change
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
(map.getSource("marker") as GeoJSONSource)?.setData(markerGeoJSON);
|
||||
if (showAccuracyCircle) {
|
||||
(map.getSource("circle") as GeoJSONSource)?.setData(circleGeoJSON);
|
||||
}
|
||||
}, [markerGeoJSON, circleGeoJSON, 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`;
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={containerClasses}>
|
||||
{isVisible && (
|
||||
<ReactMap
|
||||
mapStyle={CARTO_DARK_STYLE}
|
||||
initialViewState={{ longitude, latitude, zoom: effectiveZoom }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
attributionControl={{ compact: true }}
|
||||
onLoad={handleLoad}
|
||||
>
|
||||
{mapLoaded && (
|
||||
<>
|
||||
{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}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useMemo, useState } from "react";
|
||||
import ReactMap, { Source, Layer } from "react-map-gl/maplibre";
|
||||
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";
|
||||
@@ -28,9 +29,11 @@ export const NodeLocationMap: React.FC<NodeLocationMapProps> = ({
|
||||
precisionBits,
|
||||
fullHeight = false,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
|
||||
const accuracyMeters = calculateAccuracyFromPrecisionBits(precisionBits);
|
||||
const effectiveZoom = zoom ?? calculateZoomFromAccuracy(accuracyMeters);
|
||||
const [mapLoaded, setMapLoaded] = useState(false);
|
||||
|
||||
const markerGeoJSON = useMemo((): FeatureCollection => ({
|
||||
type: "FeatureCollection",
|
||||
@@ -39,59 +42,58 @@ export const NodeLocationMap: React.FC<NodeLocationMapProps> = ({
|
||||
|
||||
const circleGeoJSON = useMemo((): FeatureCollection => ({
|
||||
type: "FeatureCollection",
|
||||
features: [
|
||||
{
|
||||
type: "Feature",
|
||||
geometry: { type: "Polygon", coordinates: [buildCircleCoords(lng, lat, accuracyMeters)] },
|
||||
properties: {},
|
||||
},
|
||||
],
|
||||
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.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 className={containerClassName}>
|
||||
<ReactMap
|
||||
mapStyle={CARTO_DARK_STYLE}
|
||||
initialViewState={{ longitude: lng, latitude: lat, zoom: effectiveZoom }}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
attributionControl={{ compact: true }}
|
||||
onLoad={() => setMapLoaded(true)}
|
||||
>
|
||||
{mapLoaded && (
|
||||
<>
|
||||
<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>
|
||||
);
|
||||
return <div ref={containerRef} className={containerClassName} />;
|
||||
};
|
||||
|
||||
/** @deprecated Use NodeLocationMap */
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useRef, useCallback, useEffect, useState, useMemo } from "react";
|
||||
import ReactMap, { Source, Layer, Popup, MapRef } from "react-map-gl/maplibre";
|
||||
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_LABELLED } from "../../lib/mapStyle";
|
||||
@@ -10,13 +11,9 @@ import { Position } from "../../lib/types";
|
||||
import { getActivityLevel, getNodeColors, getStatusText, formatLastSeen } from "../../lib/activity";
|
||||
|
||||
interface NetworkMapProps {
|
||||
/** Height of the map in CSS units (optional) */
|
||||
height?: string;
|
||||
/** Callback for when auto-zoom state changes */
|
||||
onAutoZoomChange?: (enabled: boolean) => void;
|
||||
/** Whether the map should take all available space (default: false) */
|
||||
fullHeight?: boolean;
|
||||
/** Whether to show topology link polylines (default: true) */
|
||||
showLinks?: boolean;
|
||||
}
|
||||
|
||||
@@ -35,16 +32,15 @@ interface MapNode {
|
||||
textMessageCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 containerRef = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<maplibregl.Map | null>(null);
|
||||
const autoZoomRef = useRef(true);
|
||||
const popupRef = useRef<maplibregl.Popup | null>(null);
|
||||
const nodesWithPositionRef = useRef<MapNode[]>([]);
|
||||
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);
|
||||
@@ -53,8 +49,8 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
|
||||
() => getNodesWithPosition(nodes, gateways),
|
||||
[nodes, gateways]
|
||||
);
|
||||
nodesWithPositionRef.current = nodesWithPosition;
|
||||
|
||||
// Build GeoJSON for node circles
|
||||
const nodesGeoJSON = useMemo((): FeatureCollection => ({
|
||||
type: "FeatureCollection",
|
||||
features: nodesWithPosition.map((node) => {
|
||||
@@ -65,10 +61,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
|
||||
id: node.id,
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: [
|
||||
node.position.longitudeI / 10000000,
|
||||
node.position.latitudeI / 10000000,
|
||||
],
|
||||
coordinates: [node.position.longitudeI / 10000000, node.position.latitudeI / 10000000],
|
||||
},
|
||||
properties: {
|
||||
nodeId: node.id,
|
||||
@@ -81,14 +74,10 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
|
||||
}),
|
||||
}), [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,
|
||||
]);
|
||||
posMap.set(node.id, [node.position.longitudeI / 10000000, node.position.latitudeI / 10000000]);
|
||||
}
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
@@ -96,26 +85,145 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
|
||||
.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";
|
||||
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)!],
|
||||
},
|
||||
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
|
||||
const disableAutoZoom = useCallback(() => {
|
||||
autoZoomRef.current = false;
|
||||
setAutoZoomEnabled(false);
|
||||
onAutoZoomChange?.(false);
|
||||
}, [onAutoZoomChange]);
|
||||
|
||||
// Mount map once
|
||||
useEffect(() => {
|
||||
if (!autoZoomEnabled || nodesWithPosition.length === 0 || !mapRef.current || !mapLoaded) return;
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container: containerRef.current,
|
||||
style: CARTO_DARK_STYLE_LABELLED,
|
||||
center: [-98, 39],
|
||||
zoom: 4,
|
||||
attributionControl: false,
|
||||
});
|
||||
|
||||
map.addControl(new maplibregl.AttributionControl({ compact: true }));
|
||||
|
||||
map.on("load", () => {
|
||||
map.addSource("links", { type: "geojson", data: { type: "FeatureCollection", features: [] } });
|
||||
map.addLayer({
|
||||
id: "links-line",
|
||||
type: "line",
|
||||
source: "links",
|
||||
layout: { "line-join": "round", "line-cap": "round", visibility: "visible" },
|
||||
paint: { "line-color": ["get", "color"], "line-width": 2, "line-opacity": ["get", "opacity"] },
|
||||
});
|
||||
|
||||
map.addSource("nodes", { type: "geojson", data: { type: "FeatureCollection", features: [] } });
|
||||
map.addLayer({
|
||||
id: "nodes-circles",
|
||||
type: "circle",
|
||||
source: "nodes",
|
||||
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,
|
||||
},
|
||||
});
|
||||
map.addLayer({
|
||||
id: "nodes-labels",
|
||||
type: "symbol",
|
||||
source: "nodes",
|
||||
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 },
|
||||
});
|
||||
});
|
||||
|
||||
map.on("dragstart", disableAutoZoom);
|
||||
map.on("zoomstart", disableAutoZoom);
|
||||
|
||||
map.on("mouseenter", "nodes-circles", () => { map.getCanvas().style.cursor = "pointer"; });
|
||||
map.on("mouseleave", "nodes-circles", () => { map.getCanvas().style.cursor = "grab"; });
|
||||
|
||||
map.on("click", "nodes-circles", (e: MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => {
|
||||
const feature = e.features?.[0];
|
||||
if (!feature) return;
|
||||
const props = feature.properties as { nodeId: number; name: string; fillColor: string };
|
||||
const nodeId = props.nodeId;
|
||||
const node = nodesWithPositionRef.current.find((n) => n.id === nodeId);
|
||||
if (!node) return;
|
||||
|
||||
const coords = (feature.geometry as GeoJSON.Point).coordinates as [number, number];
|
||||
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)}`;
|
||||
|
||||
popupRef.current?.remove();
|
||||
|
||||
const el = document.createElement("div");
|
||||
el.style.cssText = "font-family:sans-serif;max-width:220px;padding:4px";
|
||||
el.innerHTML = `
|
||||
<div style="font-weight:600;font-size:14px;color:${colors.fill};margin-bottom:3px">${nodeName}</div>
|
||||
<div style="font-size:11px;color:#6b7280;margin-bottom:6px">${node.isGateway ? "Gateway" : "Node"} · !${node.id.toString(16)}</div>
|
||||
<div style="font-size:11px;color:#374151;margin-bottom:4px">
|
||||
<span style="display:inline-block;width:7px;height:7px;border-radius:50%;background:${colors.fill};margin-right:5px;vertical-align:middle"></span>
|
||||
${statusText} · ${lastSeenText}
|
||||
</div>
|
||||
<div style="font-size:11px;color:#6b7280;margin-bottom:8px">Packets: ${node.messageCount} · Text: ${node.textMessageCount}</div>
|
||||
<button id="nav-btn" style="font-size:12px;font-weight:500;color:#3b82f6;background:#f1f5f9;border:none;border-radius:4px;padding:4px 8px;cursor:pointer">View details →</button>
|
||||
`;
|
||||
el.querySelector("#nav-btn")?.addEventListener("click", () => {
|
||||
popup.remove();
|
||||
navigate({ to: "/node/$nodeId", params: { nodeId: node.id.toString(16) } });
|
||||
});
|
||||
|
||||
const popup = new maplibregl.Popup({ closeOnClick: true, maxWidth: "240px", anchor: "bottom" })
|
||||
.setLngLat(coords)
|
||||
.setDOMContent(el)
|
||||
.addTo(map);
|
||||
popupRef.current = popup;
|
||||
});
|
||||
|
||||
map.on("click", (e: MapMouseEvent & { features?: maplibregl.MapGeoJSONFeature[] }) => {
|
||||
if (!e.features?.length) popupRef.current?.remove();
|
||||
});
|
||||
|
||||
mapRef.current = map;
|
||||
return () => { map.remove(); mapRef.current = null; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Update node/link data
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
(map.getSource("nodes") as GeoJSONSource)?.setData(nodesGeoJSON);
|
||||
(map.getSource("links") as GeoJSONSource)?.setData(linksGeoJSON);
|
||||
}, [nodesGeoJSON, linksGeoJSON]);
|
||||
|
||||
// Toggle links visibility
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!map || !map.isStyleLoaded()) return;
|
||||
map.setLayoutProperty("links-line", "visibility", showLinks ? "visible" : "none");
|
||||
}, [showLinks]);
|
||||
|
||||
// Auto-zoom to fit nodes
|
||||
useEffect(() => {
|
||||
const map = mapRef.current;
|
||||
if (!autoZoomRef.current || nodesWithPosition.length === 0 || !map) return;
|
||||
let minLng = Infinity, maxLng = -Infinity, minLat = Infinity, maxLat = -Infinity;
|
||||
for (const n of nodesWithPosition) {
|
||||
const lng = n.position.longitudeI / 10000000;
|
||||
@@ -125,11 +233,8 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
|
||||
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]);
|
||||
map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: 60, maxZoom: 15, duration: 500 });
|
||||
}, [nodesWithPosition]);
|
||||
|
||||
// Notify parent of auto-zoom state
|
||||
useEffect(() => {
|
||||
@@ -138,29 +243,12 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
|
||||
|
||||
// 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);
|
||||
resetAutoZoom: () => {
|
||||
autoZoomRef.current = true;
|
||||
setAutoZoomEnabled(true);
|
||||
onAutoZoomChange?.(true);
|
||||
},
|
||||
[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" : ""}`;
|
||||
@@ -169,97 +257,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
|
||||
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%" }}
|
||||
attributionControl={{ compact: true }}
|
||||
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)}
|
||||
>
|
||||
{/* Sources and layers — only after map style has loaded */}
|
||||
{mapLoaded && (
|
||||
<>
|
||||
<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>
|
||||
|
||||
<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 ref={containerRef} style={{ width: "100%", height: "100%" }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -268,68 +266,6 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user