diff --git a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
index 9b7fd13..0408475 100644
--- a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
+++ b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
@@ -20,8 +20,7 @@
:is-loading="isLoading"
:auto-reload="autoReload"
:enable-physics="enablePhysics"
- :hop-filter-slider="hopFilterSlider"
- :hop-slider-max="hopSliderMax"
+ :hop-max-filter="hopMaxFilter"
:node-count="nodes.length"
:edge-count="edges.length"
:online-interface-count="onlineInterfaces.length"
@@ -30,7 +29,7 @@
@update:is-showing-controls="isShowingControls = $event"
@update:auto-reload="autoReload = $event"
@update:enable-physics="enablePhysics = $event"
- @update:hop-filter-slider="hopFilterSlider = $event"
+ @update:hop-max-filter="onUserHopMaxFilterChange"
@update:search-query="searchQuery = $event"
@manual-update="manualUpdate"
/>
@@ -54,6 +53,33 @@ import NetworkVisualiserLoadingOverlay from "./internal/NetworkVisualiserLoading
import NetworkVisualiserToolbar from "./internal/NetworkVisualiserToolbar.vue";
import NetworkVisualiserLegend from "./internal/NetworkVisualiserLegend.vue";
+const HOP_MAX_FILTER_STORAGE_KEY = "meshchatx.visualiser.maxHops";
+
+function readStoredHopMaxFilter() {
+ if (typeof localStorage === "undefined") return 4;
+ try {
+ const raw = localStorage.getItem(HOP_MAX_FILTER_STORAGE_KEY);
+ if (raw === null || raw === "") return 4;
+ const v = JSON.parse(raw);
+ if (v === null) return null;
+ if (typeof v === "number" && Number.isFinite(v)) {
+ return Math.max(0, Math.min(128, Math.round(v)));
+ }
+ } catch {
+ return 4;
+ }
+ return 4;
+}
+
+function writeStoredHopMaxFilter(v) {
+ if (typeof localStorage === "undefined") return;
+ try {
+ localStorage.setItem(HOP_MAX_FILTER_STORAGE_KEY, JSON.stringify(v));
+ } catch {
+ return;
+ }
+}
+
/*
* Yields control back to the browser so it can paint, dispatch input events,
* and run other tasks. Prefers the prioritized task scheduler when available
@@ -138,7 +164,7 @@ export default {
pageSize: 1000,
searchQuery: "",
- hopFilterSlider: 0,
+ hopMaxFilter: readStoredHopMaxFilter(),
hopFilterDebounceTimer: null,
abortController: new AbortController(),
currentLOD: "high",
@@ -158,16 +184,8 @@ export default {
offlineInterfaces() {
return this.interfaces.filter((i) => !i.status);
},
- hopSliderMax() {
- let m = 0;
- for (const e of this.pathTable) {
- if (e.hops != null && e.hops > m) m = e.hops;
- }
- return Math.min(256, Math.max(1, m));
- },
hopFilterMax() {
- if (this.hopFilterSlider === 0) return null;
- return this.hopFilterSlider;
+ return this.hopMaxFilter;
},
},
watch: {
@@ -256,12 +274,7 @@ export default {
// we don't want to trigger a full update from server, just re-run the filtering on existing data
this.processVisualization();
},
- hopSliderMax() {
- if (this.hopFilterSlider > this.hopSliderMax) {
- this.hopFilterSlider = this.hopSliderMax;
- }
- },
- hopFilterSlider() {
+ hopMaxFilter() {
if (this.hopFilterDebounceTimer) clearTimeout(this.hopFilterDebounceTimer);
this.hopFilterDebounceTimer = setTimeout(() => {
this.hopFilterDebounceTimer = null;
@@ -363,6 +376,10 @@ export default {
this.init();
},
methods: {
+ onUserHopMaxFilterChange(v) {
+ this.hopMaxFilter = v;
+ writeStoredHopMaxFilter(v);
+ },
async getInterfaceStats() {
try {
const response = await window.api.get(`/api/v1/interface-stats`, {
diff --git a/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue
index 4f3eaa8..b4fe4bc 100644
--- a/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue
+++ b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue
@@ -121,20 +121,31 @@
class="text-sm font-semibold text-gray-700 dark:text-zinc-300 cursor-pointer"
>{{ $t("visualiser.max_hops_filter") }}
- {{ hopFilterSlider === 0 ? $t("visualiser.all") : hopFilterSlider }}
+
@@ -224,6 +235,7 @@
diff --git a/meshchatx/src/frontend/components/network-visualiser/internal/hopMaxFilterSliderMap.js b/meshchatx/src/frontend/components/network-visualiser/internal/hopMaxFilterSliderMap.js
new file mode 100644
index 0000000..03c2ea6
--- /dev/null
+++ b/meshchatx/src/frontend/components/network-visualiser/internal/hopMaxFilterSliderMap.js
@@ -0,0 +1,38 @@
+// SPDX-License-Identifier: 0BSD AND MIT
+
+/** Rightmost slider index: no hop limit (show all paths). */
+export const HOP_SLIDER_POS_ALL = 2047;
+
+const LOW_MAX_POS = 1599;
+const TRAN_POS = 1600;
+const HIGH_END_POS = 2046;
+
+/**
+ * @param {number} pos
+ * @returns {number|null} Inclusive max hops, or null when unlimited.
+ */
+export function hopSliderPosToMaxHops(pos) {
+ const p = Math.round(Number(pos));
+ if (!Number.isFinite(p) || p >= HOP_SLIDER_POS_ALL) return null;
+ if (p <= LOW_MAX_POS) {
+ return Math.min(32, Math.round((p / LOW_MAX_POS) * 32));
+ }
+ if (p >= HIGH_END_POS) return 128;
+ const span = HIGH_END_POS - TRAN_POS;
+ const frac = span > 0 ? (p - TRAN_POS) / span : 1;
+ return Math.min(128, Math.max(33, Math.round(33 + frac * (128 - 33))));
+}
+
+/**
+ * @param {number|null|undefined} maxHops null = unlimited
+ * @returns {number} Slider position 0 .. HOP_SLIDER_POS_ALL
+ */
+export function hopMaxHopsToSliderPos(maxHops) {
+ if (maxHops === null || maxHops === undefined) return HOP_SLIDER_POS_ALL;
+ const h = Math.max(0, Math.min(128, Math.round(Number(maxHops))));
+ if (h <= 32) {
+ return Math.round((h / 32) * LOW_MAX_POS);
+ }
+ const span = HIGH_END_POS - TRAN_POS;
+ return Math.min(HIGH_END_POS, TRAN_POS + Math.round(((h - 33) / (128 - 33)) * span));
+}
diff --git a/tests/frontend/hopMaxFilterSliderMap.test.js b/tests/frontend/hopMaxFilterSliderMap.test.js
new file mode 100644
index 0000000..579c6c6
--- /dev/null
+++ b/tests/frontend/hopMaxFilterSliderMap.test.js
@@ -0,0 +1,25 @@
+import { describe, it, expect } from "vitest";
+import {
+ HOP_SLIDER_POS_ALL,
+ hopSliderPosToMaxHops,
+ hopMaxHopsToSliderPos,
+} from "@/components/network-visualiser/internal/hopMaxFilterSliderMap.js";
+
+describe("hopMaxFilterSliderMap", () => {
+ it("maps rightmost slider position to unlimited hops", () => {
+ expect(hopSliderPosToMaxHops(HOP_SLIDER_POS_ALL)).toBe(null);
+ expect(hopSliderPosToMaxHops(HOP_SLIDER_POS_ALL + 10)).toBe(null);
+ });
+
+ it("round-trips every hop count 0..128", () => {
+ for (let h = 0; h <= 128; h++) {
+ const pos = hopMaxHopsToSliderPos(h);
+ expect(hopSliderPosToMaxHops(pos)).toBe(h);
+ }
+ });
+
+ it("round-trips null as the all position", () => {
+ expect(hopMaxHopsToSliderPos(null)).toBe(HOP_SLIDER_POS_ALL);
+ expect(hopSliderPosToMaxHops(HOP_SLIDER_POS_ALL)).toBe(null);
+ });
+});