mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-06-08 22:11:39 +00:00
feat(network-visualiser): implement hop max filter functionality with local storage support and UI updates
This commit is contained in:
@@ -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`, {
|
||||
|
||||
+80
-10
@@ -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>
|
||||
|
||||
+38
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user