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:
Daniel Pupius
2026-03-15 23:50:56 +00:00
parent 19a81a363e
commit a7eb73c558
3 changed files with 256 additions and 308 deletions

View File

@@ -1,5 +1,6 @@
import React, { useRef, useState, useEffect, useMemo, useCallback } from "react"; import React, { useRef, useState, useEffect, useMemo } from "react";
import ReactMap, { Source, Layer } from "react-map-gl/maplibre"; import maplibregl from "maplibre-gl";
import type { GeoJSONSource } from "maplibre-gl";
import type { FeatureCollection } from "geojson"; import type { FeatureCollection } from "geojson";
import "maplibre-gl/dist/maplibre-gl.css"; import "maplibre-gl/dist/maplibre-gl.css";
import { CARTO_DARK_STYLE } from "../lib/mapStyle"; import { CARTO_DARK_STYLE } from "../lib/mapStyle";
@@ -25,9 +26,8 @@ export const LocationMap: React.FC<LocationMapProps> = ({
precisionBits, precisionBits,
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const [isVisible, setIsVisible] = useState(false); 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. // Only mount the WebGL map when the container enters the viewport.
// This prevents exhausting the browser's WebGL context limit (~8-16) // 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 => ({ const circleGeoJSON = useMemo((): FeatureCollection => ({
type: "FeatureCollection", type: "FeatureCollection",
features: showAccuracyCircle features: showAccuracyCircle
? [ ? [{
{ type: "Feature",
type: "Feature", geometry: { type: "Polygon", coordinates: [buildCircleCoords(longitude, latitude, accuracyMeters)] },
geometry: { type: "Polygon", coordinates: [buildCircleCoords(longitude, latitude, accuracyMeters)] }, properties: {},
properties: {}, }]
},
]
: [], : [],
}), [latitude, longitude, accuracyMeters, showAccuracyCircle]); }), [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 const containerClasses = flush
? `w-full h-full overflow-hidden relative ${className}` ? `w-full h-full overflow-hidden relative ${className}`
: `${className} relative overflow-hidden rounded-xl border border-neutral-700 bg-neutral-800/50`; : `${className} relative overflow-hidden rounded-xl border border-neutral-700 bg-neutral-800/50`;
return ( return (
<div ref={containerRef} className={containerClasses}> <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 */} {/* External link overlay */}
<a <a
href={googleMapsUrl} href={googleMapsUrl}

View File

@@ -1,5 +1,6 @@
import React, { useMemo, useState } from "react"; import React, { useEffect, useRef, useMemo } from "react";
import ReactMap, { Source, Layer } from "react-map-gl/maplibre"; import maplibregl from "maplibre-gl";
import type { GeoJSONSource } from "maplibre-gl";
import type { FeatureCollection } from "geojson"; import type { FeatureCollection } from "geojson";
import "maplibre-gl/dist/maplibre-gl.css"; import "maplibre-gl/dist/maplibre-gl.css";
import { CARTO_DARK_STYLE } from "../../lib/mapStyle"; import { CARTO_DARK_STYLE } from "../../lib/mapStyle";
@@ -28,9 +29,11 @@ export const NodeLocationMap: React.FC<NodeLocationMapProps> = ({
precisionBits, precisionBits,
fullHeight = false, fullHeight = false,
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const accuracyMeters = calculateAccuracyFromPrecisionBits(precisionBits); const accuracyMeters = calculateAccuracyFromPrecisionBits(precisionBits);
const effectiveZoom = zoom ?? calculateZoomFromAccuracy(accuracyMeters); const effectiveZoom = zoom ?? calculateZoomFromAccuracy(accuracyMeters);
const [mapLoaded, setMapLoaded] = useState(false);
const markerGeoJSON = useMemo((): FeatureCollection => ({ const markerGeoJSON = useMemo((): FeatureCollection => ({
type: "FeatureCollection", type: "FeatureCollection",
@@ -39,59 +42,58 @@ export const NodeLocationMap: React.FC<NodeLocationMapProps> = ({
const circleGeoJSON = useMemo((): FeatureCollection => ({ const circleGeoJSON = useMemo((): FeatureCollection => ({
type: "FeatureCollection", type: "FeatureCollection",
features: [ features: [{
{ type: "Feature",
type: "Feature", geometry: { type: "Polygon", coordinates: [buildCircleCoords(lng, lat, accuracyMeters)] },
geometry: { type: "Polygon", coordinates: [buildCircleCoords(lng, lat, accuracyMeters)] }, properties: {},
properties: {}, }],
},
],
}), [lat, lng, accuracyMeters]); }), [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`; const containerClassName = `w-full ${fullHeight ? "h-full flex-1" : "h-[300px]"} rounded-lg overflow-hidden effect-inset`;
return ( return <div ref={containerRef} className={containerClassName} />;
<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>
);
}; };
/** @deprecated Use NodeLocationMap */ /** @deprecated Use NodeLocationMap */

View File

@@ -1,5 +1,6 @@
import React, { useRef, useCallback, useEffect, useState, useMemo } from "react"; 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 type { FeatureCollection } from "geojson";
import "maplibre-gl/dist/maplibre-gl.css"; import "maplibre-gl/dist/maplibre-gl.css";
import { CARTO_DARK_STYLE_LABELLED } from "../../lib/mapStyle"; 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"; import { getActivityLevel, getNodeColors, getStatusText, formatLastSeen } from "../../lib/activity";
interface NetworkMapProps { interface NetworkMapProps {
/** Height of the map in CSS units (optional) */
height?: string; height?: string;
/** Callback for when auto-zoom state changes */
onAutoZoomChange?: (enabled: boolean) => void; onAutoZoomChange?: (enabled: boolean) => void;
/** Whether the map should take all available space (default: false) */
fullHeight?: boolean; fullHeight?: boolean;
/** Whether to show topology link polylines (default: true) */
showLinks?: boolean; showLinks?: boolean;
} }
@@ -35,16 +32,15 @@ interface MapNode {
textMessageCount: number; textMessageCount: number;
} }
/**
* NetworkMap displays all nodes with position data on a MapLibre GL map
*/
export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, NetworkMapProps>( export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, NetworkMapProps>(
({ height, fullHeight = false, onAutoZoomChange, showLinks = true }, ref) => { ({ height, fullHeight = false, onAutoZoomChange, showLinks = true }, ref) => {
const navigate = useNavigate(); const navigate = useNavigate();
const mapRef = useRef<MapRef>(null); const containerRef = useRef<HTMLDivElement>(null);
const [mapLoaded, setMapLoaded] = useState(false); 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 [autoZoomEnabled, setAutoZoomEnabled] = useState(true);
const [selectedNode, setSelectedNode] = useState<MapNode | null>(null);
const { nodes, gateways } = useAppSelector((state) => state.aggregator); const { nodes, gateways } = useAppSelector((state) => state.aggregator);
const topologyLinks = useAppSelector((state) => state.topology.links); const topologyLinks = useAppSelector((state) => state.topology.links);
@@ -53,8 +49,8 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
() => getNodesWithPosition(nodes, gateways), () => getNodesWithPosition(nodes, gateways),
[nodes, gateways] [nodes, gateways]
); );
nodesWithPositionRef.current = nodesWithPosition;
// Build GeoJSON for node circles
const nodesGeoJSON = useMemo((): FeatureCollection => ({ const nodesGeoJSON = useMemo((): FeatureCollection => ({
type: "FeatureCollection", type: "FeatureCollection",
features: nodesWithPosition.map((node) => { features: nodesWithPosition.map((node) => {
@@ -65,10 +61,7 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
id: node.id, id: node.id,
geometry: { geometry: {
type: "Point", type: "Point",
coordinates: [ coordinates: [node.position.longitudeI / 10000000, node.position.latitudeI / 10000000],
node.position.longitudeI / 10000000,
node.position.latitudeI / 10000000,
],
}, },
properties: { properties: {
nodeId: node.id, nodeId: node.id,
@@ -81,14 +74,10 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
}), }),
}), [nodesWithPosition]); }), [nodesWithPosition]);
// Build GeoJSON for topology links
const linksGeoJSON = useMemo((): FeatureCollection => { const linksGeoJSON = useMemo((): FeatureCollection => {
const posMap = new Map<number, [number, number]>(); const posMap = new Map<number, [number, number]>();
for (const node of nodesWithPosition) { for (const node of nodesWithPosition) {
posMap.set(node.id, [ posMap.set(node.id, [node.position.longitudeI / 10000000, node.position.latitudeI / 10000000]);
node.position.longitudeI / 10000000,
node.position.latitudeI / 10000000,
]);
} }
return { return {
type: "FeatureCollection", type: "FeatureCollection",
@@ -96,26 +85,145 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
.filter((link) => posMap.has(link.nodeA) && posMap.has(link.nodeB)) .filter((link) => posMap.has(link.nodeA) && posMap.has(link.nodeB))
.map((link) => { .map((link) => {
const snr = link.snrAtoB ?? link.snrBtoA; const snr = link.snrAtoB ?? link.snrBtoA;
const color = const color = snr === undefined ? "#6b7280" : snr >= 5 ? "#22c55e" : snr >= 0 ? "#eab308" : "#ef4444";
snr === undefined ? "#6b7280"
: snr >= 5 ? "#22c55e"
: snr >= 0 ? "#eab308"
: "#ef4444";
return { return {
type: "Feature" as const, type: "Feature" as const,
geometry: { geometry: { type: "LineString" as const, coordinates: [posMap.get(link.nodeA)!, posMap.get(link.nodeB)!] },
type: "LineString" as const,
coordinates: [posMap.get(link.nodeA)!, posMap.get(link.nodeB)!],
},
properties: { color, opacity: link.viaMqtt ? 0.4 : 0.7 }, properties: { color, opacity: link.viaMqtt ? 0.4 : 0.7 },
}; };
}), }),
}; };
}, [topologyLinks, nodesWithPosition]); }, [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(() => { 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; let minLng = Infinity, maxLng = -Infinity, minLat = Infinity, maxLat = -Infinity;
for (const n of nodesWithPosition) { for (const n of nodesWithPosition) {
const lng = n.position.longitudeI / 10000000; 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 < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat; if (lat > maxLat) maxLat = lat;
} }
mapRef.current.fitBounds( map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { padding: 60, maxZoom: 15, duration: 500 });
[[minLng, minLat], [maxLng, maxLat]], }, [nodesWithPosition]);
{ padding: 60, maxZoom: 15, duration: 500 }
);
}, [autoZoomEnabled, nodesWithPosition, mapLoaded]);
// Notify parent of auto-zoom state // Notify parent of auto-zoom state
useEffect(() => { useEffect(() => {
@@ -138,29 +243,12 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
// Expose resetAutoZoom via ref // Expose resetAutoZoom via ref
React.useImperativeHandle(ref, () => ({ React.useImperativeHandle(ref, () => ({
resetAutoZoom: () => setAutoZoomEnabled(true), resetAutoZoom: () => {
})); autoZoomRef.current = true;
setAutoZoomEnabled(true);
// Disable auto-zoom on user interaction onAutoZoomChange?.(true);
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 wrapperClassName = `w-full ${fullHeight ? "h-full flex flex-col" : ""}`;
const mapClassName = `w-full overflow-hidden effect-inset rounded-lg relative ${fullHeight ? "flex-1" : ""}`; 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 ( return (
<div className={wrapperClassName}> <div className={wrapperClassName}>
<div className={mapClassName} style={containerStyle}> <div className={mapClassName} style={containerStyle}>
<ReactMap <div ref={containerRef} style={{ width: "100%", height: "100%" }} />
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> </div>
</div> </div>
); );
@@ -268,68 +266,6 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
NetworkMap.displayName = "NetworkMap"; 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 ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
function hasValidPosition(node: NodeData): boolean { function hasValidPosition(node: NodeData): boolean {