feat(map): tri-state links button with MQTT uplinks mode

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 <noreply@anthropic.com>
This commit is contained in:
Daniel Pupius
2026-03-16 17:55:40 +00:00
parent bd74327515
commit 468e4a1d9d
2 changed files with 90 additions and 32 deletions

View File

@@ -9,12 +9,13 @@ import { useNavigate } from "@tanstack/react-router";
import { NodeData, GatewayData } from "../../store/slices/aggregatorSlice"; import { NodeData, GatewayData } from "../../store/slices/aggregatorSlice";
import { Position } from "../../lib/types"; import { Position } from "../../lib/types";
import { getActivityLevel, getNodeColors, getStatusText, formatLastSeen } from "../../lib/activity"; import { getActivityLevel, getNodeColors, getStatusText, formatLastSeen } from "../../lib/activity";
import type { LinksMode } from "../../routes/map";
interface NetworkMapProps { interface NetworkMapProps {
height?: string; height?: string;
onAutoZoomChange?: (enabled: boolean) => void; onAutoZoomChange?: (enabled: boolean) => void;
fullHeight?: boolean; fullHeight?: boolean;
showLinks?: boolean; linksMode?: LinksMode;
} }
interface MapNode { interface MapNode {
@@ -33,7 +34,7 @@ interface MapNode {
} }
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, linksMode = "links" }, ref) => {
const navigate = useNavigate(); const navigate = useNavigate();
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null); const mapRef = useRef<maplibregl.Map | null>(null);
@@ -96,6 +97,31 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
}; };
}, [topologyLinks, nodesWithPosition]); }, [topologyLinks, nodesWithPosition]);
// One edge per gateway→observed-node pair. Only drawn when linksMode === "uplinks".
const uplinksGeoJSON = 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]);
}
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(() => { const disableAutoZoom = useCallback(() => {
autoZoomRef.current = false; autoZoomRef.current = false;
setAutoZoomEnabled(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] }, 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.addSource("nodes", { type: "geojson", data: { type: "FeatureCollection", features: [] } });
map.addLayer({ map.addLayer({
id: "nodes-circles", id: "nodes-circles",
@@ -224,15 +259,19 @@ export const NetworkMap = React.forwardRef<{ resetAutoZoom: () => void }, Networ
if (!map || !mapLoaded) return; if (!map || !mapLoaded) return;
(map.getSource("nodes") as GeoJSONSource)?.setData(nodesGeoJSON); (map.getSource("nodes") as GeoJSONSource)?.setData(nodesGeoJSON);
(map.getSource("links") as GeoJSONSource)?.setData(linksGeoJSON); (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(() => { useEffect(() => {
const map = mapRef.current; const map = mapRef.current;
if (!map || !mapLoaded) return; if (!map || !mapLoaded) return;
map.setLayoutProperty("links-line", "visibility", showLinks ? "visible" : "none"); const topologyVisible = linksMode === "links" ? "visible" : "none";
map.setLayoutProperty("links-mqtt", "visibility", showLinks ? "visible" : "none"); const uplinksVisible = linksMode === "uplinks" ? "visible" : "none";
}, [showLinks, mapLoaded]); 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 // Auto-zoom to fit nodes
useEffect(() => { useEffect(() => {

View File

@@ -6,6 +6,10 @@ import { Button } from "../components/ui";
import { Locate, GitBranch } from "lucide-react"; import { Locate, GitBranch } from "lucide-react";
import { getNodeColors, ActivityLevel } from "../lib/activity"; import { getNodeColors, ActivityLevel } from "../lib/activity";
export type LinksMode = "links" | "uplinks" | "off";
const LINKS_CYCLE: LinksMode[] = ["links", "uplinks", "off"];
export const Route = createFileRoute("/map")({ export const Route = createFileRoute("/map")({
component: MapPage, component: MapPage,
}); });
@@ -13,9 +17,12 @@ export const Route = createFileRoute("/map")({
function MapPage() { function MapPage() {
// State to track if auto-zoom is enabled (forwarded from the NetworkMap component) // State to track if auto-zoom is enabled (forwarded from the NetworkMap component)
const [autoZoomEnabled, setAutoZoomEnabled] = React.useState(true); const [autoZoomEnabled, setAutoZoomEnabled] = React.useState(true);
const [showLinks, setShowLinks] = React.useState(true); const [linksMode, setLinksMode] = React.useState<LinksMode>("links");
const mapRef = React.useRef<{ resetAutoZoom?: () => void }>({}); 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 // Function to reset auto-zoom, will be called by the button
const handleResetZoom = () => { const handleResetZoom = () => {
if (mapRef.current.resetAutoZoom) { if (mapRef.current.resetAutoZoom) {
@@ -31,7 +38,7 @@ function MapPage() {
fullHeight fullHeight
ref={mapRef as any} ref={mapRef as any}
onAutoZoomChange={setAutoZoomEnabled} onAutoZoomChange={setAutoZoomEnabled}
showLinks={showLinks} linksMode={linksMode}
/> />
<div className="mt-2 rounded-lg p-2 text-xs flex items-center justify-between effect-inset flex-shrink-0"> <div className="mt-2 rounded-lg p-2 text-xs flex items-center justify-between effect-inset flex-shrink-0">
@@ -45,36 +52,48 @@ function MapPage() {
<span className={`w-2 h-2 ${getNodeColors(ActivityLevel.RECENT, true).statusDot} rounded-full mr-1.5`}></span> <span className={`w-2 h-2 ${getNodeColors(ActivityLevel.RECENT, true).statusDot} rounded-full mr-1.5`}></span>
Gateways Gateways
</span> </span>
<span className="text-neutral-500">·</span> {linksMode === "links" && <>
<span className="inline-flex items-center gap-1.5 text-neutral-300"> <span className="text-neutral-500">·</span>
<span className="inline-block w-5 h-0.5 rounded-full bg-[#22c55e]"></span>SNR 5 <span className="inline-flex items-center gap-1.5 text-neutral-300">
</span> <span className="inline-block w-5 h-0.5 rounded-full bg-[#22c55e]"></span>SNR 5
<span className="inline-flex items-center gap-1.5 text-neutral-300"> </span>
<span className="inline-block w-5 h-0.5 rounded-full bg-[#eab308]"></span>SNR 04 <span className="inline-flex items-center gap-1.5 text-neutral-300">
</span> <span className="inline-block w-5 h-0.5 rounded-full bg-[#eab308]"></span>SNR 04
<span className="inline-flex items-center gap-1.5 text-neutral-300"> </span>
<span className="inline-block w-5 h-0.5 rounded-full bg-[#ef4444]"></span>SNR &lt; 0 <span className="inline-flex items-center gap-1.5 text-neutral-300">
</span> <span className="inline-block w-5 h-0.5 rounded-full bg-[#ef4444]"></span>SNR &lt; 0
<span className="inline-flex items-center gap-1.5 text-neutral-300"> </span>
<span className="inline-block w-5 h-0.5 rounded-full bg-[#6b7280]"></span>Unknown <span className="inline-flex items-center gap-1.5 text-neutral-300">
</span> <span className="inline-block w-5 h-0.5 rounded-full bg-[#6b7280]"></span>Unknown
<span className="inline-flex items-center gap-1.5 text-neutral-300"> </span>
<span className="inline-block w-5 border-t-2 border-dashed border-[#a855f7] opacity-80"></span>MQTT <span className="inline-flex items-center gap-1.5 text-neutral-300">
</span> <span className="inline-block w-5 border-t-2 border-dashed border-[#a855f7] opacity-80"></span>MQTT
</span>
</>}
{linksMode === "uplinks" && <>
<span className="text-neutral-500">·</span>
<span className="inline-flex items-center gap-1.5 text-neutral-300">
<span className="inline-block w-5 border-t-2 border-dashed border-[#a855f7] opacity-80"></span>Gateway upload
</span>
</>}
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
size="sm" size="sm"
variant={showLinks ? "primary" : "secondary"} variant={linksMode !== "off" ? "primary" : "secondary"}
onClick={() => setShowLinks((v) => !v)} onClick={cycleLinksMode}
icon={GitBranch} icon={GitBranch}
title={showLinks ? "Hide link polylines" : "Show link polylines"} title={
linksMode === "links"
? "Showing topology links — click for uplinks"
: linksMode === "uplinks"
? "Showing MQTT uplinks — click to hide"
: "Links hidden — click to show topology"
}
> >
Links {linksMode === "uplinks" ? "Uplinks" : "Links"}
</Button> </Button>
{/* Always show the button, but disable it when auto-zoom is enabled */} {/* Always show the button, but disable it when auto-zoom is enabled */}
<Button <Button