feat(network-visualiser): implement hop max filter functionality with local storage support and UI updates

This commit is contained in:
Ivan
2026-04-22 12:04:14 -05:00
parent 5916dc0bcb
commit 3d09e8cb22
4 changed files with 179 additions and 29 deletions
@@ -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`, {
@@ -121,20 +121,31 @@
class="text-sm font-semibold text-gray-700 dark:text-zinc-300 cursor-pointer"
>{{ $t("visualiser.max_hops_filter") }}</label
>
<span
class="text-xs font-bold text-blue-600 dark:text-blue-400 tabular-nums min-w-[4rem] text-right"
>{{ hopFilterSlider === 0 ? $t("visualiser.all") : hopFilterSlider }}</span
>
<input
id="hop-max-hops-input"
type="text"
inputmode="numeric"
autocomplete="off"
maxlength="4"
:aria-label="$t('visualiser.max_hops_filter')"
class="w-[3.25rem] shrink-0 rounded-lg border border-gray-200 bg-white px-1.5 py-1 text-center text-xs font-bold text-blue-600 tabular-nums shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500/40 dark:border-zinc-600 dark:bg-zinc-800 dark:text-blue-400 dark:focus:border-blue-500"
:value="hopMaxInputShown"
:placeholder="$t('visualiser.all')"
@focus="onHopMaxInputFocus"
@input="onHopMaxInputInput"
@blur="onHopMaxInputBlur"
/>
</div>
<input
id="hop-filter-slider"
:value="hopFilterSlider"
type="range"
min="0"
:max="hopSliderMax"
:max="hopSliderPosAll"
step="1"
:value="hopSliderUiPos"
:aria-valuetext="hopSliderAriaText"
class="w-full h-2 rounded-lg appearance-none cursor-pointer bg-gray-200 dark:bg-zinc-700 accent-blue-600 dark:accent-blue-500"
@input="$emit('update:hopFilterSlider', Number($event.target.value))"
@input="onHopSliderInput"
/>
</div>
@@ -224,6 +235,7 @@
<script>
import Toggle from "../../forms/Toggle.vue";
import { HOP_SLIDER_POS_ALL, hopSliderPosToMaxHops, hopMaxHopsToSliderPos } from "./hopMaxFilterSliderMap.js";
export default {
name: "NetworkVisualiserToolbar",
@@ -234,8 +246,12 @@ export default {
isLoading: { type: Boolean, default: false },
autoReload: { type: Boolean, default: false },
enablePhysics: { type: Boolean, default: true },
hopFilterSlider: { type: Number, default: 0 },
hopSliderMax: { type: Number, default: 1 },
hopMaxFilter: {
default: 4,
validator(v) {
return v === null || (typeof v === "number" && Number.isFinite(v) && v >= 0 && v <= 128);
},
},
nodeCount: { type: Number, default: 0 },
edgeCount: { type: Number, default: 0 },
onlineInterfaceCount: { type: Number, default: 0 },
@@ -246,9 +262,63 @@ export default {
"update:isShowingControls",
"update:autoReload",
"update:enablePhysics",
"update:hopFilterSlider",
"update:hopMaxFilter",
"update:searchQuery",
"manual-update",
],
data() {
return {
hopMaxInputDraft: null,
};
},
computed: {
hopSliderPosAll() {
return HOP_SLIDER_POS_ALL;
},
hopSliderUiPos() {
return hopMaxHopsToSliderPos(this.hopMaxFilter);
},
hopMaxInputShown() {
if (this.hopMaxInputDraft !== null) return this.hopMaxInputDraft;
if (this.hopMaxFilter === null) return "";
return String(this.hopMaxFilter);
},
hopSliderAriaText() {
if (this.hopMaxFilter === null) return this.$t("visualiser.all");
return String(this.hopMaxFilter);
},
},
methods: {
onHopSliderInput(e) {
const v = hopSliderPosToMaxHops(Number(e.target.value));
this.$emit("update:hopMaxFilter", v);
},
onHopMaxInputFocus() {
this.hopMaxInputDraft = this.hopMaxFilter === null ? "" : String(this.hopMaxFilter);
},
onHopMaxInputInput(e) {
const raw = e.target.value.replace(/\D/g, "");
this.hopMaxInputDraft = raw;
if (raw === "") return;
const n = parseInt(raw, 10);
if (!Number.isFinite(n)) return;
const clamped = Math.max(0, Math.min(128, Math.round(n)));
this.$emit("update:hopMaxFilter", clamped);
this.hopMaxInputDraft = String(clamped);
},
onHopMaxInputBlur() {
const d = this.hopMaxInputDraft;
this.hopMaxInputDraft = null;
if (d === null) return;
const trimmed = (d || "").trim();
if (trimmed === "") {
this.$emit("update:hopMaxFilter", null);
return;
}
const n = parseInt(trimmed, 10);
if (!Number.isFinite(n)) return;
this.$emit("update:hopMaxFilter", Math.max(0, Math.min(128, Math.round(n))));
},
},
};
</script>
@@ -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));
}
@@ -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);
});
});