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); + }); +});