mirror of
https://github.com/dpup/meshstream.git
synced 2026-03-28 17:42:37 +01:00
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:
@@ -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(() => {
|
||||
|
||||
@@ -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 0–4
|
||||
</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 < 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 0–4
|
||||
</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 < 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
|
||||
|
||||
Reference in New Issue
Block a user