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 { 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<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(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<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(() => {
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(() => {

View File

@@ -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<LinksMode>("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}
/>
<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>
Gateways
</span>
<span className="text-neutral-500">·</span>
<span className="inline-flex items-center gap-1.5 text-neutral-300">
<span className="inline-block w-5 h-0.5 rounded-full bg-[#22c55e]"></span>SNR 5
</span>
<span className="inline-flex items-center gap-1.5 text-neutral-300">
<span className="inline-block w-5 h-0.5 rounded-full bg-[#eab308]"></span>SNR 04
</span>
<span className="inline-flex items-center gap-1.5 text-neutral-300">
<span className="inline-block w-5 h-0.5 rounded-full bg-[#ef4444]"></span>SNR &lt; 0
</span>
<span className="inline-flex items-center gap-1.5 text-neutral-300">
<span className="inline-block w-5 h-0.5 rounded-full bg-[#6b7280]"></span>Unknown
</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>MQTT
</span>
{linksMode === "links" && <>
<span className="text-neutral-500">·</span>
<span className="inline-flex items-center gap-1.5 text-neutral-300">
<span className="inline-block w-5 h-0.5 rounded-full bg-[#22c55e]"></span>SNR 5
</span>
<span className="inline-flex items-center gap-1.5 text-neutral-300">
<span className="inline-block w-5 h-0.5 rounded-full bg-[#eab308]"></span>SNR 04
</span>
<span className="inline-flex items-center gap-1.5 text-neutral-300">
<span className="inline-block w-5 h-0.5 rounded-full bg-[#ef4444]"></span>SNR &lt; 0
</span>
<span className="inline-flex items-center gap-1.5 text-neutral-300">
<span className="inline-block w-5 h-0.5 rounded-full bg-[#6b7280]"></span>Unknown
</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>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 className="flex items-center gap-2">
<Button
size="sm"
variant={showLinks ? "primary" : "secondary"}
onClick={() => setShowLinks((v) => !v)}
variant={linksMode !== "off" ? "primary" : "secondary"}
onClick={cycleLinksMode}
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>
{/* Always show the button, but disable it when auto-zoom is enabled */}
<Button