From 468e4a1d9d6985eafb275775a66d431b5524fa19 Mon Sep 17 00:00:00 2001 From: Daniel Pupius Date: Mon, 16 Mar 2026 17:55:40 +0000 Subject: [PATCH] feat(map): tri-state links button with MQTT uplinks mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new "uplinks" mode to the network map links toggle, cycling through links → uplinks → off. Uplinks draws a purple dotted line from each gateway to every node it has uploaded packets for, making the MQTT upload graph visible as a separate view from RF topology. Co-Authored-By: Claude Sonnet 4.6 --- web/src/components/dashboard/NetworkMap.tsx | 53 +++++++++++++--- web/src/routes/map.tsx | 69 +++++++++++++-------- 2 files changed, 90 insertions(+), 32 deletions(-) diff --git a/web/src/components/dashboard/NetworkMap.tsx b/web/src/components/dashboard/NetworkMap.tsx index e6b4888..1220464 100644 --- a/web/src/components/dashboard/NetworkMap.tsx +++ b/web/src/components/dashboard/NetworkMap.tsx @@ -9,12 +9,13 @@ import { useNavigate } from "@tanstack/react-router"; import { NodeData, GatewayData } from "../../store/slices/aggregatorSlice"; import { Position } from "../../lib/types"; import { getActivityLevel, getNodeColors, getStatusText, formatLastSeen } from "../../lib/activity"; +import type { LinksMode } from "../../routes/map"; interface NetworkMapProps { height?: string; onAutoZoomChange?: (enabled: boolean) => void; fullHeight?: boolean; - showLinks?: boolean; + linksMode?: LinksMode; } interface MapNode { @@ -33,7 +34,7 @@ interface MapNode { } export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, NetworkMapProps>( - ({ height, fullHeight = false, onAutoZoomChange, showLinks = true }, ref) => { + ({ height, fullHeight = false, onAutoZoomChange, linksMode = "links" }, ref) => { const navigate = useNavigate(); const containerRef = useRef(null); const mapRef = useRef(null); @@ -96,6 +97,31 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ }; }, [topologyLinks, nodesWithPosition]); + // One edge per gateway→observed-node pair. Only drawn when linksMode === "uplinks". + const uplinksGeoJSON = useMemo((): FeatureCollection => { + const posMap = new Map(); + for (const node of nodesWithPosition) { + posMap.set(node.id, [node.position.longitudeI / 10000000, node.position.latitudeI / 10000000]); + } + const features: FeatureCollection["features"] = []; + for (const [gatewayId, gatewayData] of Object.entries(gateways)) { + const gatewayNodeId = parseInt(gatewayId.substring(1), 16); + const gatewayPos = posMap.get(gatewayNodeId); + if (!gatewayPos) continue; + for (const nodeId of gatewayData.observedNodes) { + if (nodeId === gatewayNodeId) continue; + const nodePos = posMap.get(nodeId); + if (!nodePos) continue; + features.push({ + type: "Feature", + geometry: { type: "LineString", coordinates: [gatewayPos, nodePos] }, + properties: {}, + }); + } + } + return { type: "FeatureCollection", features }; + }, [gateways, nodesWithPosition]); + const disableAutoZoom = useCallback(() => { autoZoomRef.current = false; setAutoZoomEnabled(false); @@ -137,6 +163,15 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ paint: { "line-color": "#a855f7", "line-width": 2, "line-opacity": 0.6, "line-dasharray": [2, 3] }, }); + map.addSource("uplinks", { type: "geojson", data: { type: "FeatureCollection", features: [] } }); + map.addLayer({ + id: "uplinks-line", + type: "line", + source: "uplinks", + layout: { "line-join": "round", "line-cap": "butt", visibility: "none" }, + paint: { "line-color": "#a855f7", "line-width": 2, "line-opacity": 0.6, "line-dasharray": [1, 3] }, + }); + map.addSource("nodes", { type: "geojson", data: { type: "FeatureCollection", features: [] } }); map.addLayer({ id: "nodes-circles", @@ -224,15 +259,19 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ if (!map || !mapLoaded) return; (map.getSource("nodes") as GeoJSONSource)?.setData(nodesGeoJSON); (map.getSource("links") as GeoJSONSource)?.setData(linksGeoJSON); - }, [nodesGeoJSON, linksGeoJSON, mapLoaded]); + (map.getSource("uplinks") as GeoJSONSource)?.setData(uplinksGeoJSON); + }, [nodesGeoJSON, linksGeoJSON, uplinksGeoJSON, mapLoaded]); - // Toggle links visibility + // Toggle links/uplinks visibility based on mode useEffect(() => { const map = mapRef.current; if (!map || !mapLoaded) return; - map.setLayoutProperty("links-line", "visibility", showLinks ? "visible" : "none"); - map.setLayoutProperty("links-mqtt", "visibility", showLinks ? "visible" : "none"); - }, [showLinks, mapLoaded]); + const topologyVisible = linksMode === "links" ? "visible" : "none"; + const uplinksVisible = linksMode === "uplinks" ? "visible" : "none"; + map.setLayoutProperty("links-line", "visibility", topologyVisible); + map.setLayoutProperty("links-mqtt", "visibility", topologyVisible); + map.setLayoutProperty("uplinks-line", "visibility", uplinksVisible); + }, [linksMode, mapLoaded]); // Auto-zoom to fit nodes useEffect(() => { diff --git a/web/src/routes/map.tsx b/web/src/routes/map.tsx index a3d315e..e33da11 100644 --- a/web/src/routes/map.tsx +++ b/web/src/routes/map.tsx @@ -6,6 +6,10 @@ import { Button } from "../components/ui"; import { Locate, GitBranch } from "lucide-react"; import { getNodeColors, ActivityLevel } from "../lib/activity"; +export type LinksMode = "links" | "uplinks" | "off"; + +const LINKS_CYCLE: LinksMode[] = ["links", "uplinks", "off"]; + export const Route = createFileRoute("/map")({ component: MapPage, }); @@ -13,9 +17,12 @@ export const Route = createFileRoute("/map")({ function MapPage() { // State to track if auto-zoom is enabled (forwarded from the NetworkMap component) const [autoZoomEnabled, setAutoZoomEnabled] = React.useState(true); - const [showLinks, setShowLinks] = React.useState(true); + const [linksMode, setLinksMode] = React.useState("links"); const mapRef = React.useRef<{ resetAutoZoom?: () => void }>({}); + const cycleLinksMode = () => + setLinksMode((prev) => LINKS_CYCLE[(LINKS_CYCLE.indexOf(prev) + 1) % LINKS_CYCLE.length]); + // Function to reset auto-zoom, will be called by the button const handleResetZoom = () => { if (mapRef.current.resetAutoZoom) { @@ -31,7 +38,7 @@ function MapPage() { fullHeight ref={mapRef as any} onAutoZoomChange={setAutoZoomEnabled} - showLinks={showLinks} + linksMode={linksMode} />
@@ -45,36 +52,48 @@ function MapPage() { Gateways - · - - SNR ≥ 5 - - - SNR 0–4 - - - SNR < 0 - - - Unknown - - - MQTT - + {linksMode === "links" && <> + · + + SNR ≥ 5 + + + SNR 0–4 + + + SNR < 0 + + + Unknown + + + MQTT + + } + {linksMode === "uplinks" && <> + · + + Gateway upload + + }
- - - +
{/* Always show the button, but disable it when auto-zoom is enabled */}