From a7eb73c558a9c466dc0ddd804b94db2c2ba92d17 Mon Sep 17 00:00:00 2001 From: Daniel Pupius Date: Sun, 15 Mar 2026 23:50:56 +0000 Subject: [PATCH] Replace react-map-gl with direct maplibre-gl imperative API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/src/components/Map.tsx | 98 +++--- web/src/components/dashboard/GoogleMap.tsx | 106 +++--- web/src/components/dashboard/NetworkMap.tsx | 360 ++++++++------------ 3 files changed, 256 insertions(+), 308 deletions(-) diff --git a/web/src/components/Map.tsx b/web/src/components/Map.tsx index 8f382f2..2d7e5f3 100644 --- a/web/src/components/Map.tsx +++ b/web/src/components/Map.tsx @@ -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 = ({ precisionBits, }) => { const containerRef = useRef(null); + const mapRef = useRef(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 = ({ 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 (
- {isVisible && ( - - {mapLoaded && ( - <> - {showAccuracyCircle && ( - - - - - )} - - - - - )} - - )} - {/* External link overlay */} = ({ precisionBits, fullHeight = false, }) => { + const containerRef = useRef(null); + const mapRef = useRef(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 = ({ 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 ( -
- setMapLoaded(true)} - > - {mapLoaded && ( - <> - - - - - - - - - - )} - -
- ); + return
; }; /** @deprecated Use NodeLocationMap */ diff --git a/web/src/components/dashboard/NetworkMap.tsx b/web/src/components/dashboard/NetworkMap.tsx index ff6816e..21cb52b 100644 --- a/web/src/components/dashboard/NetworkMap.tsx +++ b/web/src/components/dashboard/NetworkMap.tsx @@ -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(null); - const [mapLoaded, setMapLoaded] = useState(false); + const containerRef = useRef(null); + const mapRef = useRef(null); + const autoZoomRef = useRef(true); + const popupRef = useRef(null); + const nodesWithPositionRef = useRef([]); const [autoZoomEnabled, setAutoZoomEnabled] = useState(true); - const [selectedNode, setSelectedNode] = useState(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(); 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 = ` +
${nodeName}
+
${node.isGateway ? "Gateway" : "Node"} · !${node.id.toString(16)}
+
+ + ${statusText} · ${lastSeenText} +
+
Packets: ${node.messageCount} · Text: ${node.textMessageCount}
+ + `; + 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 }> }) => { - 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 (
- { - 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 && ( - <> - - - - - - - - - - )} - - {/* Node popup */} - {selectedNode && ( - setSelectedNode(null)} - closeOnClick={false} - maxWidth="240px" - anchor="bottom" - > - { - setSelectedNode(null); - navigate({ to: "/node/$nodeId", params: { nodeId: id.toString(16) } }); - }} - /> - - )} - +
); @@ -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 ( -
-
- {nodeName} -
-
- {node.isGateway ? "Gateway" : "Node"} · !{node.id.toString(16)} -
-
- - - {statusText} · {lastSeenText} - -
-
- Packets: {node.messageCount} · Text: {node.textMessageCount} -
- -
- ); -} - // ─── Helpers ────────────────────────────────────────────────────────────────── function hasValidPosition(node: NodeData): boolean {