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 { 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(() => {
|
||||||
|
|||||||
@@ -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 0–4
|
<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 0–4
|
||||||
<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 < 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 < 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
|
||||||
|
|||||||
Reference in New Issue
Block a user