diff --git a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
index 79bc6de..6037be6 100644
--- a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
+++ b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue
@@ -3,287 +3,39 @@
-
-
-
-
-
{{ loadingStatus }}
-
-
-
- {{ $t("visualiser.batch") }} {{ currentBatch }} / {{ totalBatches }}
- {{ Math.round((loadedNodesCount / totalNodesToLoad) * 100) }}%
-
-
-
-
+
-
-
-
-
-
-
- {{
- $t("visualiser.reticulum_mesh")
- }}
- {{ $t("visualiser.network_visualizer") }}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {{ hopFilterSlider === 0 ? $t("visualiser.all") : hopFilterSlider }}
-
-
-
-
-
-
-
-
- Nodes
-
-
{{ nodes.length }}
-
-
-
- Links
-
-
- {{ edges.length }}
-
-
-
-
-
-
- Interfaces
-
-
-
-
-
{{ onlineInterfaces.length }} Online
-
-
-
-
{{ offlineInterfaces.length }} Offline
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Discovered ({{ discoveredInterfaces.length }})
-
-
+
+
@@ -294,12 +46,18 @@ import { DataSet } from "vis-data";
import * as mdi from "@mdi/js";
import Utils from "../../js/Utils";
import GlobalEmitter from "../../js/GlobalEmitter";
-import Toggle from "../forms/Toggle.vue";
+import ToastUtils from "../../js/ToastUtils";
+import { PONG_NODE_IDS } from "./internal/visualiserConstants.js";
+import NetworkVisualiserLoadingOverlay from "./internal/NetworkVisualiserLoadingOverlay.vue";
+import NetworkVisualiserToolbar from "./internal/NetworkVisualiserToolbar.vue";
+import NetworkVisualiserLegend from "./internal/NetworkVisualiserLegend.vue";
export default {
name: "NetworkVisualiser",
components: {
- Toggle,
+ NetworkVisualiserLoadingOverlay,
+ NetworkVisualiserToolbar,
+ NetworkVisualiserLegend,
},
data() {
return {
@@ -313,8 +71,16 @@ export default {
enablePhysics: true,
enableOrbit: false,
enableBouncingBalls: false,
+ enableFallingSkies: false,
+ enableSnake: false,
+ enablePong: false,
orbitAnimationFrame: null,
bouncingBallsAnimationFrame: null,
+ fallingSkiesAnimationFrame: null,
+ snakeAnimationFrame: null,
+ pongAnimationFrame: null,
+ showDisabledInterfaces: false,
+ showDiscoveredInterfaces: false,
loadingStatus: "Initializing...",
loadedNodesCount: 0,
totalNodesToLoad: 0,
@@ -339,6 +105,9 @@ export default {
hopFilterDebounceTimer: null,
abortController: new AbortController(),
currentLOD: "high",
+ lastVizKeys: [],
+ vizHadOneLayout: false,
+ didDisableStabilization: false,
};
},
computed: {
@@ -366,14 +135,18 @@ export default {
this.manualUpdate();
}
},
- enablePhysics(val) {
- if (this.network) {
- this.network.setOptions({ physics: { enabled: val && !this.enableOrbit } });
- }
+ enablePhysics() {
+ this.refreshPhysicsEnabled();
},
enableOrbit(val) {
if (val) {
this.enableBouncingBalls = false;
+ this.enableFallingSkies = false;
+ this.enableSnake = false;
+ this.enablePong = false;
+ this.stopFallingSkies();
+ this.stopSnake();
+ this.stopPong();
this.startOrbit();
} else {
this.stopOrbit();
@@ -382,11 +155,62 @@ export default {
enableBouncingBalls(val) {
if (val) {
this.enableOrbit = false;
+ this.enableFallingSkies = false;
+ this.enableSnake = false;
+ this.enablePong = false;
+ this.stopFallingSkies();
+ this.stopSnake();
+ this.stopPong();
this.startBouncingBalls();
} else {
this.stopBouncingBalls();
}
},
+ enableFallingSkies(val) {
+ if (val) {
+ this.enableOrbit = false;
+ this.enableBouncingBalls = false;
+ this.enableSnake = false;
+ this.enablePong = false;
+ this.stopOrbit();
+ this.stopBouncingBalls();
+ this.stopSnake();
+ this.stopPong();
+ this.startFallingSkies();
+ } else {
+ this.stopFallingSkies();
+ }
+ },
+ enableSnake(val) {
+ if (val) {
+ this.enableOrbit = false;
+ this.enableBouncingBalls = false;
+ this.enableFallingSkies = false;
+ this.enablePong = false;
+ this.stopOrbit();
+ this.stopBouncingBalls();
+ this.stopFallingSkies();
+ this.stopPong();
+ this.startSnake();
+ } else {
+ this.stopSnake();
+ }
+ },
+ enablePong(val) {
+ if (val) {
+ this.enableOrbit = false;
+ this.enableBouncingBalls = false;
+ this.enableFallingSkies = false;
+ this.enableSnake = false;
+ this.stopOrbit();
+ this.stopBouncingBalls();
+ this.stopFallingSkies();
+ this.stopSnake();
+ this.startPong();
+ } else {
+ this.stopPong();
+ }
+ },
searchQuery() {
// we don't want to trigger a full update from server, just re-run the filtering on existing data
this.processVisualization();
@@ -414,8 +238,24 @@ export default {
if (this._toggleBouncingBallsHandler) {
GlobalEmitter.off("toggle-bouncing-balls", this._toggleBouncingBallsHandler);
}
+ if (this._toggleFallingSkiesHandler) {
+ GlobalEmitter.off("toggle-falling-skies", this._toggleFallingSkiesHandler);
+ }
+ if (this._visualiserPrefsHandler) {
+ GlobalEmitter.off("visualiser-display-prefs-changed", this._visualiserPrefsHandler);
+ }
+ if (this._toggleSnakeHandler) {
+ GlobalEmitter.off("toggle-snake", this._toggleSnakeHandler);
+ }
+ if (this._togglePongHandler) {
+ GlobalEmitter.off("toggle-pong", this._togglePongHandler);
+ }
+ this.detachGameKeyListeners();
this.stopOrbit();
this.stopBouncingBalls();
+ this.stopFallingSkies();
+ this.stopSnake(false);
+ this.stopPong(false);
clearInterval(this.reloadInterval);
if (this.hopFilterDebounceTimer) {
clearTimeout(this.hopFilterDebounceTimer);
@@ -453,6 +293,30 @@ export default {
};
GlobalEmitter.on("toggle-bouncing-balls", this._toggleBouncingBallsHandler);
+ this._toggleFallingSkiesHandler = () => {
+ this.enableFallingSkies = !this.enableFallingSkies;
+ };
+ GlobalEmitter.on("toggle-falling-skies", this._toggleFallingSkiesHandler);
+
+ this._visualiserPrefsHandler = () => {
+ this.loadVisualiserDisplayPrefs();
+ if (this.network) {
+ this.processVisualization();
+ }
+ };
+ GlobalEmitter.on("visualiser-display-prefs-changed", this._visualiserPrefsHandler);
+
+ this._toggleSnakeHandler = () => {
+ this.enableSnake = !this.enableSnake;
+ };
+ GlobalEmitter.on("toggle-snake", this._toggleSnakeHandler);
+
+ this._togglePongHandler = () => {
+ this.enablePong = !this.enablePong;
+ };
+ GlobalEmitter.on("toggle-pong", this._togglePongHandler);
+
+ this.loadVisualiserDisplayPrefs();
this.init();
},
methods: {
@@ -710,73 +574,163 @@ export default {
return `
`;
},
+ loadVisualiserDisplayPrefs() {
+ try {
+ if (typeof localStorage !== "undefined") {
+ if (localStorage.getItem("meshchatx.visualiser.showDisabledInterfaces") === "true") {
+ this.showDisabledInterfaces = true;
+ }
+ if (localStorage.getItem("meshchatx.visualiser.showDiscoveredInterfaces") === "true") {
+ this.showDiscoveredInterfaces = true;
+ }
+ }
+ } catch {
+ /* localStorage unavailable */
+ }
+ },
+ refreshPhysicsEnabled() {
+ if (!this.network) return;
+ const on =
+ this.enablePhysics &&
+ !this.enableOrbit &&
+ !this.enableBouncingBalls &&
+ !this.enableFallingSkies &&
+ !this.enableSnake &&
+ !this.enablePong;
+ this.network.setOptions({ physics: { enabled: on } });
+ },
+ pickStablePosition(id, posById, initialFn) {
+ const prev = posById[id];
+ if (prev && Number.isFinite(prev.x) && Number.isFinite(prev.y)) {
+ return { x: prev.x, y: prev.y };
+ }
+ const v = initialFn();
+ posById[id] = { x: v.x, y: v.y };
+ return v;
+ },
+ edgeHiddenForMode(peerNodeId) {
+ if (this.enableBouncingBalls || this.enableSnake || this.enablePong) {
+ return true;
+ }
+ if (
+ peerNodeId != null &&
+ this.enableFallingSkies &&
+ this._fallingPendingIds &&
+ this._fallingPendingIds.has(peerNodeId)
+ ) {
+ return true;
+ }
+ return false;
+ },
+ edgesHiddenForOverlayGames() {
+ return this.enableBouncingBalls || this.enableSnake || this.enablePong;
+ },
startOrbit() {
if (!this.network) return;
this.stopOrbit();
this.stopBouncingBalls();
+ this.stopSnake(false);
+ this.stopPong(false);
- // Disable physics while orbiting
- this.network.setOptions({ physics: { enabled: false } });
+ this.refreshPhysicsEnabled();
- // Hide edges
- const edges = this.edges.get();
- const updatedEdges = edges.map((edge) => ({ id: edge.id, hidden: true }));
- this.edges.update(updatedEdges);
-
- // Get current positions of nodes to start orbit from where they are
const nodeIds = this.nodes.getIds();
- const positions = this.network.getPositions(nodeIds);
+ const positions = this.network.getPositions(nodeIds) || {};
const mePos = positions["me"] || { x: 0, y: 0 };
- this._orbitNodes = nodeIds
- .filter((id) => id !== "me")
- .map((id) => {
- const pos = positions[id] || {
- x: Math.random() * 1000 - 500,
- y: Math.random() * 1000 - 500,
- };
+ this._orbitAroundMe = [];
+ this._orbitAroundIface = [];
+
+ for (const id of nodeIds) {
+ if (id === "me") continue;
+ const n = this.nodes.get(id);
+ if (!n || !n.group) continue;
+ const pos = positions[id] || { x: mePos.x, y: mePos.y };
+ if (n.group === "interface" || n.group === "discovered") {
const dx = pos.x - mePos.x;
const dy = pos.y - mePos.y;
- const radius = Math.sqrt(dx * dx + dy * dy) || Math.random() * 500 + 100;
- return {
- id: id,
- radius: radius,
+ const r = Math.hypot(dx, dy) || 400;
+ this._orbitAroundMe.push({
+ id,
+ radius: r,
angle: Math.atan2(dy, dx),
- // Random speed based on radius - further nodes move slower usually, but let's make it more dynamic
- speed: (0.002 + Math.random() * 0.005) * (Math.random() > 0.5 ? 1 : -1),
- };
- });
+ speed: (0.0012 + Math.random() * 0.0024) * (Math.random() > 0.5 ? 1 : -1),
+ });
+ } else if (n.group === "announce" && n._parentInterface) {
+ const parentId = n._parentInterface;
+ const pPos = positions[parentId] || mePos;
+ const dx = pos.x - pPos.x;
+ const dy = pos.y - pPos.y;
+ const r = Math.hypot(dx, dy) || 140;
+ this._orbitAroundIface.push({
+ id,
+ parentId,
+ radius: r,
+ angle: Math.atan2(dy, dx),
+ speed: (0.002 + Math.random() * 0.004) * (Math.random() > 0.5 ? 1 : -1),
+ });
+ }
+ }
const animate = () => {
if (!this.enableOrbit) return;
- // Get current position of 'me' node in case it was dragged
- const positions = this.network.getPositions(["me"]);
- const mePos = positions["me"] || { x: 0, y: 0 };
+ const meP = this.network.getPositions(["me"])["me"] || { x: 0, y: 0 };
- const updates = this._orbitNodes.map((data) => {
+ const ifacePos = { me: meP };
+ const batch = [];
+
+ for (const data of this._orbitAroundMe) {
if (data.id === this._draggingNodeId) {
- // If dragging, update our internal radius/angle to match new position
- const nodePositions = this.network.getPositions([data.id]);
- const pos = nodePositions[data.id];
- if (pos) {
- const dx = pos.x - mePos.x;
- const dy = pos.y - mePos.y;
- data.radius = Math.sqrt(dx * dx + dy * dy);
+ const p = this.network.getPositions([data.id])[data.id];
+ if (p) {
+ const dx = p.x - meP.x;
+ const dy = p.y - meP.y;
+ data.radius = Math.hypot(dx, dy) || data.radius;
+ data.angle = Math.atan2(dy, dx);
+ ifacePos[data.id] = p;
+ }
+ continue;
+ }
+ data.angle += data.speed;
+ const x = meP.x + Math.cos(data.angle) * data.radius;
+ const y = meP.y + Math.sin(data.angle) * data.radius;
+ batch.push({ id: data.id, x, y });
+ ifacePos[data.id] = { x, y };
+ }
+
+ if (batch.length > 0) {
+ this.nodes.update(batch);
+ }
+
+ const annBatch = [];
+ for (const data of this._orbitAroundIface) {
+ if (data.id === this._draggingNodeId) {
+ const p = this.network.getPositions([data.id])[data.id];
+ const parent =
+ ifacePos[data.parentId] || this.network.getPositions([data.parentId])[data.parentId];
+ if (p && parent) {
+ const dx = p.x - parent.x;
+ const dy = p.y - parent.y;
+ data.radius = Math.hypot(dx, dy) || data.radius;
data.angle = Math.atan2(dy, dx);
}
- return { id: data.id, x: pos.x, y: pos.y };
+ continue;
}
-
+ const parent =
+ ifacePos[data.parentId] || this.network.getPositions([data.parentId])[data.parentId] || meP;
data.angle += data.speed;
- return {
+ annBatch.push({
id: data.id,
- x: mePos.x + Math.cos(data.angle) * data.radius,
- y: mePos.y + Math.sin(data.angle) * data.radius,
- };
- });
+ x: parent.x + Math.cos(data.angle) * data.radius,
+ y: parent.y + Math.sin(data.angle) * data.radius,
+ });
+ }
+
+ if (annBatch.length > 0) {
+ this.nodes.update(annBatch);
+ }
- this.nodes.update(updates);
this.orbitAnimationFrame = requestAnimationFrame(animate);
};
@@ -787,21 +741,78 @@ export default {
cancelAnimationFrame(this.orbitAnimationFrame);
this.orbitAnimationFrame = null;
}
-
- // Restore edges visibility
- const edges = this.edges.get();
- const updatedEdges = edges.map((edge) => ({ id: edge.id, hidden: false }));
- this.edges.update(updatedEdges);
-
- // Re-enable physics if it was enabled
- if (this.network) {
- this.network.setOptions({ physics: { enabled: this.enablePhysics } });
+ this._orbitAroundMe = [];
+ this._orbitAroundIface = [];
+ this.refreshPhysicsEnabled();
+ },
+ startFallingSkies() {
+ if (!this.network) return;
+ this.stopSnake(false);
+ this.stopPong(false);
+ this.refreshPhysicsEnabled();
+ if (this._fallingById && this._fallingById.size > 0) {
+ this.scheduleFallingTick();
}
},
+ stopFallingSkies() {
+ if (this.fallingSkiesAnimationFrame) {
+ cancelAnimationFrame(this.fallingSkiesAnimationFrame);
+ this.fallingSkiesAnimationFrame = null;
+ }
+ this._fallingById = new Map();
+ this._fallingPendingIds = new Set();
+ this.refreshPhysicsEnabled();
+ if (this.network) {
+ this.processVisualization();
+ }
+ },
+ scheduleFallingTick() {
+ if (!this.enableFallingSkies || !this.network) return;
+ if (this.fallingSkiesAnimationFrame != null) return;
+ const tick = () => {
+ this.fallingSkiesAnimationFrame = null;
+ if (!this.enableFallingSkies || !this._fallingById || this._fallingById.size === 0) {
+ return;
+ }
+ const gravity = 0.55;
+ const updates = [];
+ const done = [];
+
+ for (const [id, st] of this._fallingById) {
+ st.vy += gravity;
+ st.y += st.vy;
+ if (st.y >= st.ty - 2) {
+ updates.push({ id, x: st.tx, y: st.ty });
+ done.push({ id, edgeIds: st.edgeIds || [] });
+ } else {
+ updates.push({ id, x: st.tx, y: st.y });
+ }
+ }
+
+ if (updates.length > 0) {
+ this.nodes.update(updates);
+ }
+ const edgeUnhide = [];
+ for (const { id, edgeIds } of done) {
+ this._fallingById.delete(id);
+ this._fallingPendingIds.delete(id);
+ edgeUnhide.push(...edgeIds);
+ }
+ if (edgeUnhide.length > 0) {
+ this.edges.update(edgeUnhide.map((eid) => ({ id: eid, hidden: this.enableBouncingBalls })));
+ }
+ if (this._fallingById.size > 0) {
+ this.fallingSkiesAnimationFrame = requestAnimationFrame(tick);
+ }
+ };
+ this.fallingSkiesAnimationFrame = requestAnimationFrame(tick);
+ },
startBouncingBalls() {
if (!this.network) return;
this.stopBouncingBalls();
this.stopOrbit();
+ this.stopSnake(false);
+ this.stopPong(false);
// Disable physics
this.network.setOptions({ physics: { enabled: false } });
@@ -909,14 +920,469 @@ export default {
this.bouncingBallsAnimationFrame = null;
}
- // Restore edges visibility
const edges = this.edges.get();
const updatedEdges = edges.map((edge) => ({ id: edge.id, hidden: false }));
this.edges.update(updatedEdges);
- // Re-enable physics if it was enabled
- if (this.network) {
- this.network.setOptions({ physics: { enabled: this.enablePhysics } });
+ this.refreshPhysicsEnabled();
+ },
+ getViewCanvasBounds() {
+ const container = document.getElementById("network");
+ if (!container || !this.network) return null;
+ const scale = this.network.getScale();
+ const vp = this.network.getViewPosition();
+ const w = container.clientWidth;
+ const h = container.clientHeight;
+ const halfW = w / (2 * scale);
+ const halfH = h / (2 * scale);
+ return {
+ left: vp.x - halfW,
+ right: vp.x + halfW,
+ top: vp.y - halfH,
+ bottom: vp.y + halfH,
+ scale,
+ };
+ },
+ attachGameKeyListeners(mode) {
+ this.detachGameKeyListeners();
+ this._gameKeyMode = mode;
+ this._snakeKeys = { u: 0, d: 0, l: 0, r: 0, w: 0, s: 0 };
+ this._onGameKeyDown = (e) => {
+ const tag = (e.target && e.target.tagName) || "";
+ if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
+ if (mode === "snake") {
+ if (["ArrowUp", "w", "W"].includes(e.key)) {
+ e.preventDefault();
+ this._snakeKeys.u = 1;
+ }
+ if (["ArrowDown", "s", "S"].includes(e.key)) {
+ e.preventDefault();
+ this._snakeKeys.d = 1;
+ }
+ if (["ArrowLeft", "a", "A"].includes(e.key)) {
+ e.preventDefault();
+ this._snakeKeys.l = 1;
+ }
+ if (["ArrowRight", "d", "D"].includes(e.key)) {
+ e.preventDefault();
+ this._snakeKeys.r = 1;
+ }
+ } else if (mode === "pong") {
+ if (e.key === "w" || e.key === "W") {
+ e.preventDefault();
+ this._snakeKeys.w = 1;
+ }
+ if (e.key === "s" || e.key === "S") {
+ e.preventDefault();
+ this._snakeKeys.s = 1;
+ }
+ }
+ };
+ this._onGameKeyUp =
+ mode === "snake"
+ ? (e) => {
+ if (["ArrowUp", "w", "W"].includes(e.key)) this._snakeKeys.u = 0;
+ if (["ArrowDown", "s", "S"].includes(e.key)) this._snakeKeys.d = 0;
+ if (["ArrowLeft", "a", "A"].includes(e.key)) this._snakeKeys.l = 0;
+ if (["ArrowRight", "d", "D"].includes(e.key)) this._snakeKeys.r = 0;
+ }
+ : (e) => {
+ if (e.key === "w" || e.key === "W") this._snakeKeys.w = 0;
+ if (e.key === "s" || e.key === "S") this._snakeKeys.s = 0;
+ };
+ window.addEventListener("keydown", this._onGameKeyDown, true);
+ window.addEventListener("keyup", this._onGameKeyUp, true);
+ },
+ detachGameKeyListeners() {
+ if (this._onGameKeyDown) {
+ window.removeEventListener("keydown", this._onGameKeyDown, true);
+ this._onGameKeyDown = null;
+ }
+ if (this._onGameKeyUp) {
+ window.removeEventListener("keyup", this._onGameKeyUp, true);
+ this._onGameKeyUp = null;
+ }
+ this._gameKeyMode = null;
+ },
+ getPositionAlongTrail(trail, distBehind) {
+ if (!trail || trail.length === 0) return { x: 0, y: 0 };
+ if (trail.length === 1) return { ...trail[0] };
+ let d = 0;
+ for (let i = trail.length - 1; i > 0; i--) {
+ const p = trail[i];
+ const q = trail[i - 1];
+ const seg = Math.hypot(p.x - q.x, p.y - q.y);
+ if (d + seg >= distBehind) {
+ const t = seg > 0 ? (distBehind - d) / seg : 0;
+ return { x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t };
+ }
+ d += seg;
+ }
+ return { ...trail[0] };
+ },
+ startSnake() {
+ if (!this.network) return;
+ this.stopSnake(false);
+ this.stopPong(false);
+ this.stopOrbit();
+ this.stopBouncingBalls();
+ this.stopFallingSkies();
+ this.network.setOptions({ physics: { enabled: false } });
+ const edges = this.edges.get();
+ this.edges.update(edges.map((edge) => ({ id: edge.id, hidden: true })));
+
+ const me = this.network.getPositions(["me"]).me || { x: 0, y: 0 };
+ this._snakeVx = 7;
+ this._snakeVy = 0;
+ this._snakeHeadX = me.x;
+ this._snakeHeadY = me.y;
+ this._snakeTrail = [{ x: me.x, y: me.y }];
+ this._snakeEatenIds = [];
+ this._snakeFoodIds = new Set();
+ for (const id of this.nodes.getIds()) {
+ if (id !== "me") this._snakeFoodIds.add(id);
+ }
+ this.attachGameKeyListeners("snake");
+ ToastUtils.info(this.$t("visualiser.snake_hint"));
+
+ const speed = 8;
+ const margin = 40;
+ const headR = 28;
+ const tailGap = 42;
+
+ const tick = () => {
+ if (!this.enableSnake || !this.network) return;
+ const b = this.getViewCanvasBounds();
+ if (!b) return;
+
+ let vx = 0;
+ let vy = 0;
+ const k = this._snakeKeys || {};
+ if (k.u) vy -= 1;
+ if (k.d) vy += 1;
+ if (k.l) vx -= 1;
+ if (k.r) vx += 1;
+ if (vx !== 0 || vy !== 0) {
+ const len = Math.hypot(vx, vy) || 1;
+ this._snakeVx = (vx / len) * speed;
+ this._snakeVy = (vy / len) * speed;
+ }
+
+ let hx = this._snakeHeadX + this._snakeVx;
+ let hy = this._snakeHeadY + this._snakeVy;
+ hx = Math.max(b.left + margin, Math.min(b.right - margin, hx));
+ hy = Math.max(b.top + margin, Math.min(b.bottom - margin, hy));
+ this._snakeHeadX = hx;
+ this._snakeHeadY = hy;
+
+ this.nodes.update([{ id: "me", x: hx, y: hy }]);
+
+ const last = this._snakeTrail[this._snakeTrail.length - 1];
+ if (!last || Math.hypot(hx - last.x, hy - last.y) > 3) {
+ this._snakeTrail.push({ x: hx, y: hy });
+ if (this._snakeTrail.length > 8000) {
+ this._snakeTrail.splice(0, 1500);
+ }
+ }
+
+ const updates = [];
+ for (let i = 0; i < this._snakeEatenIds.length; i++) {
+ const id = this._snakeEatenIds[i];
+ const pos = this.getPositionAlongTrail(this._snakeTrail, (i + 1) * tailGap);
+ updates.push({ id, x: pos.x, y: pos.y });
+ }
+ if (updates.length > 0) this.nodes.update(updates);
+
+ const foodArr = [...this._snakeFoodIds];
+ const posMap = foodArr.length > 0 ? this.network.getPositions(foodArr) : {};
+ for (const fid of foodArr) {
+ const fp = posMap[fid];
+ if (!fp) {
+ this._snakeFoodIds.delete(fid);
+ continue;
+ }
+ const n = this.nodes.get(fid);
+ const nr = n && n.size ? n.size * 0.45 : 14;
+ if (Math.hypot(hx - fp.x, hy - fp.y) < headR + nr) {
+ this._snakeFoodIds.delete(fid);
+ this._snakeEatenIds.push(fid);
+ }
+ }
+
+ for (let i = 0; i < this._snakeEatenIds.length; i++) {
+ const pos = this.getPositionAlongTrail(this._snakeTrail, (i + 1) * tailGap);
+ if (Math.hypot(hx - pos.x, hy - pos.y) < headR * 0.55) {
+ if (this.snakeAnimationFrame) {
+ cancelAnimationFrame(this.snakeAnimationFrame);
+ this.snakeAnimationFrame = null;
+ }
+ ToastUtils.info(this.$t("visualiser.snake_hit_self"));
+ this.enableSnake = false;
+ return;
+ }
+ }
+
+ if (this._snakeFoodIds.size === 0 && this._snakeEatenIds.length > 0) {
+ if (this.snakeAnimationFrame) {
+ cancelAnimationFrame(this.snakeAnimationFrame);
+ this.snakeAnimationFrame = null;
+ }
+ ToastUtils.success(this.$t("visualiser.snake_win"));
+ this.enableSnake = false;
+ return;
+ }
+
+ this.snakeAnimationFrame = requestAnimationFrame(tick);
+ };
+
+ this.snakeAnimationFrame = requestAnimationFrame(tick);
+ },
+ stopSnake(runProcessViz = true) {
+ if (this.snakeAnimationFrame) {
+ cancelAnimationFrame(this.snakeAnimationFrame);
+ this.snakeAnimationFrame = null;
+ }
+ this.detachGameKeyListeners();
+ this._snakeTrail = [];
+ this._snakeFoodIds = null;
+ this._snakeEatenIds = [];
+ const edges = this.edges.get();
+ this.edges.update(edges.map((edge) => ({ id: edge.id, hidden: false })));
+ this.refreshPhysicsEnabled();
+ if (runProcessViz && this.network) {
+ this.processVisualization();
+ }
+ },
+ startPong() {
+ if (!this.network) return;
+ this.stopPong(false);
+ this.stopSnake(false);
+ this.stopOrbit();
+ this.stopBouncingBalls();
+ this.stopFallingSkies();
+ this.network.setOptions({ physics: { enabled: false } });
+ const edges = this.edges.get();
+ this.edges.update(edges.map((edge) => ({ id: edge.id, hidden: true })));
+
+ const b = this.getViewCanvasBounds();
+ if (!b) return;
+ const midX = (b.left + b.right) / 2;
+ const midY = (b.top + b.bottom) / 2;
+ const padH = 100;
+ const padW = 14;
+ const ballR = 10;
+
+ this._pongBall = { x: midX, y: midY, vx: 9, vy: 6, r: ballR };
+ this._pongPadL = { x: b.left + 36, y: midY, w: padW, h: padH };
+ this._pongPadR = { x: b.right - 36, y: midY, w: padW, h: padH };
+ this._pongScoreYou = 0;
+ this._pongScoreAi = 0;
+ this._pongWinPoints = 7;
+
+ const isDark = document.documentElement.classList.contains("dark");
+ const padBg = isDark ? "#1e40af" : "#60a5fa";
+ const padBr = isDark ? "#3b82f6" : "#2563eb";
+ const hudFg = isDark ? "#fafafa" : "#18181b";
+ const hudBg = isDark ? "#27272a" : "#f4f4f5";
+
+ this.nodes.update([
+ {
+ id: "__pong_ball",
+ group: "pong",
+ shape: "dot",
+ size: ballR * 2,
+ color: this.nodeColor("#e2e8f0", isDark ? "#f8fafc" : "#0f172a"),
+ label: "",
+ font: { size: 0 },
+ x: this._pongBall.x,
+ y: this._pongBall.y,
+ physics: false,
+ },
+ {
+ id: "__pong_hud",
+ group: "pong",
+ shape: "box",
+ label: `0 - 0`,
+ font: { size: 16, color: hudFg, bold: true },
+ margin: 10,
+ color: {
+ background: hudBg,
+ border: padBr,
+ highlight: { background: hudBg, border: padBr },
+ hover: { background: hudBg, border: padBr },
+ },
+ x: midX,
+ y: b.top + 44,
+ physics: false,
+ },
+ {
+ id: "__pong_pad_l",
+ group: "pong",
+ shape: "box",
+ label: "",
+ font: { size: 0 },
+ margin: 6,
+ widthConstraint: { minimum: padW * 2, maximum: padW * 2 },
+ heightConstraint: { minimum: padH, maximum: padH },
+ color: { background: padBg, border: padBr, highlight: { background: padBg, border: padBr } },
+ x: this._pongPadL.x,
+ y: this._pongPadL.y,
+ physics: false,
+ },
+ {
+ id: "__pong_pad_r",
+ group: "pong",
+ shape: "box",
+ label: "",
+ font: { size: 0 },
+ margin: 6,
+ widthConstraint: { minimum: padW * 2, maximum: padW * 2 },
+ heightConstraint: { minimum: padH, maximum: padH },
+ color: {
+ background: isDark ? "#4c1d95" : "#a78bfa",
+ border: padBr,
+ highlight: { background: padBg, border: padBr },
+ },
+ x: this._pongPadR.x,
+ y: this._pongPadR.y,
+ physics: false,
+ },
+ ]);
+
+ this.attachGameKeyListeners("pong");
+ ToastUtils.info(this.$t("visualiser.pong_hint"));
+
+ const paddleSpeed = 11;
+ const aiMaxStep = paddleSpeed * 0.92;
+
+ const resetBall = (bounds, towardSign) => {
+ const bb = this._pongBall;
+ bb.x = (bounds.left + bounds.right) / 2;
+ bb.y = (bounds.top + bounds.bottom) / 2;
+ const ramp = Math.min(5, this._pongScoreYou + this._pongScoreAi);
+ const base = 8.5 + ramp * 0.35;
+ bb.vx = towardSign * base * (0.95 + Math.random() * 0.1);
+ bb.vy = (Math.random() * 8 + 3) * (Math.random() > 0.5 ? 1 : -1);
+ };
+
+ const loop = () => {
+ if (!this.enablePong || !this.network || !this._pongBall) return;
+ const bounds = this.getViewCanvasBounds();
+ if (!bounds) return;
+ const ball = this._pongBall;
+ const pl = this._pongPadL;
+ const pr = this._pongPadR;
+
+ if (this._snakeKeys.w) pl.y -= paddleSpeed;
+ if (this._snakeKeys.s) pl.y += paddleSpeed;
+
+ const ph = padH / 2;
+ const dy = ball.y - pr.y;
+ const step = Math.min(aiMaxStep, Math.abs(dy) * 0.22);
+ if (dy < -1.5) pr.y -= step;
+ else if (dy > 1.5) pr.y += step;
+
+ pl.y = Math.max(bounds.top + ph + 8, Math.min(bounds.bottom - ph - 8, pl.y));
+ pr.y = Math.max(bounds.top + ph + 8, Math.min(bounds.bottom - ph - 8, pr.y));
+
+ ball.x += ball.vx;
+ ball.y += ball.vy;
+
+ if (ball.y - ball.r < bounds.top) {
+ ball.y = bounds.top + ball.r;
+ ball.vy *= -1;
+ } else if (ball.y + ball.r > bounds.bottom) {
+ ball.y = bounds.bottom - ball.r;
+ ball.vy *= -1;
+ }
+
+ if (ball.vx < 0 && ball.x - ball.r <= pl.x + padW && ball.y >= pl.y - ph && ball.y <= pl.y + ph) {
+ ball.x = pl.x + padW + ball.r;
+ ball.vx *= -1.025;
+ ball.vy += (Math.random() - 0.5) * 2.2;
+ } else if (
+ ball.vx > 0 &&
+ ball.x + ball.r >= pr.x - padW &&
+ ball.y >= pr.y - ph &&
+ ball.y <= pr.y + ph
+ ) {
+ ball.x = pr.x - padW - ball.r;
+ ball.vx *= -1.025;
+ ball.vy += (Math.random() - 0.5) * 2.2;
+ }
+
+ const edgeMargin = 18;
+ if (ball.x - ball.r < bounds.left - edgeMargin) {
+ this._pongScoreAi++;
+ if (this._pongScoreAi >= this._pongWinPoints) {
+ if (this.pongAnimationFrame) {
+ cancelAnimationFrame(this.pongAnimationFrame);
+ this.pongAnimationFrame = null;
+ }
+ ToastUtils.info(this.$t("visualiser.pong_win_ai"));
+ this.enablePong = false;
+ return;
+ }
+ resetBall(bounds, -1);
+ } else if (ball.x + ball.r > bounds.right + edgeMargin) {
+ this._pongScoreYou++;
+ if (this._pongScoreYou >= this._pongWinPoints) {
+ if (this.pongAnimationFrame) {
+ cancelAnimationFrame(this.pongAnimationFrame);
+ this.pongAnimationFrame = null;
+ }
+ ToastUtils.success(this.$t("visualiser.pong_win_you"));
+ this.enablePong = false;
+ return;
+ }
+ resetBall(bounds, 1);
+ }
+
+ const hudLabel = `${this._pongScoreYou} - ${this._pongScoreAi}`;
+
+ this.nodes.update([
+ { id: "__pong_ball", x: ball.x, y: ball.y },
+ { id: "__pong_pad_l", x: pl.x, y: pl.y },
+ { id: "__pong_pad_r", x: pr.x, y: pr.y },
+ { id: "__pong_hud", label: hudLabel, x: (bounds.left + bounds.right) / 2, y: bounds.top + 44 },
+ ]);
+
+ this.pongAnimationFrame = requestAnimationFrame(loop);
+ };
+ this.pongAnimationFrame = requestAnimationFrame(loop);
+ },
+ stopPong(runProcessViz = true) {
+ if (this.pongAnimationFrame) {
+ cancelAnimationFrame(this.pongAnimationFrame);
+ this.pongAnimationFrame = null;
+ }
+ this.detachGameKeyListeners();
+ this._pongBall = null;
+ for (const id of PONG_NODE_IDS) {
+ try {
+ this.nodes.remove(id);
+ } catch {
+ /* node may already be removed */
+ }
+ }
+ const edges = this.edges.get();
+ this.edges.update(edges.map((edge) => ({ id: edge.id, hidden: false })));
+ this.refreshPhysicsEnabled();
+ if (runProcessViz && this.network) {
+ this.processVisualization();
+ }
+ },
+ reconcileSnakeFoodAfterViz() {
+ if (!this._snakeFoodIds || !this._snakeEatenIds) return;
+ const ids = new Set(this.nodes.getIds());
+ this._snakeEatenIds = this._snakeEatenIds.filter((id) => ids.has(id));
+ const eaten = new Set(this._snakeEatenIds);
+ for (const id of [...this._snakeFoodIds]) {
+ if (!ids.has(id)) this._snakeFoodIds.delete(id);
+ }
+ for (const id of ids) {
+ if (id === "me" || eaten.has(id) || PONG_NODE_IDS.includes(id)) continue;
+ this._snakeFoodIds.add(id);
}
},
async init() {
@@ -1013,8 +1479,17 @@ export default {
}
});
+ this.refreshPhysicsEnabled();
+
this.network.on("dragStart", (params) => {
- if ((this.enableBouncingBalls || this.enableOrbit) && params.nodes.length > 0) {
+ if (
+ (this.enableBouncingBalls ||
+ this.enableOrbit ||
+ this.enableFallingSkies ||
+ this.enableSnake ||
+ this.enablePong) &&
+ params.nodes.length > 0
+ ) {
this._draggingNodeId = params.nodes[0];
this.network.setOptions({ physics: { enabled: false } });
}
@@ -1031,9 +1506,7 @@ export default {
node.x = canvasPos.x;
node.y = canvasPos.y;
}
- } else if (this.enableOrbit) {
- // For orbit mode, just update the node position in vis-network DataSet
- // though it might be overwritten by orbit animation loop
+ } else if (this.enableOrbit || this.enableFallingSkies || this.enableSnake || this.enablePong) {
this.nodes.update({ id: this._draggingNodeId, x: canvasPos.x, y: canvasPos.y });
}
}
@@ -1041,6 +1514,7 @@ export default {
this.network.on("dragEnd", () => {
this._draggingNodeId = null;
+ this.refreshPhysicsEnabled();
});
this.network.on("zoom", () => {
@@ -1064,6 +1538,7 @@ export default {
}
},
async onAutoReload() {
+ if (this.enableSnake || this.enablePong) return;
if (!this.autoReload || this.isUpdating || this.isLoading) return;
this.isUpdating = true;
try {
@@ -1073,7 +1548,8 @@ export default {
}
},
updateLOD() {
- if (!this.network) return;
+ if (!this.network || this.enableSnake || this.enablePong) return;
+ if (typeof this.network.getScale !== "function") return;
const scale = this.network.getScale();
let newLOD = "high";
if (scale < 0.2) {
@@ -1163,16 +1639,49 @@ export default {
const processedNodeIds = new Set();
const processedEdgeIds = new Set();
+ const posById = {};
+ const prevIds = new Set(this.lastVizKeys);
+
+ if (!this.enableFallingSkies) {
+ this._fallingById = new Map();
+ if (!this._fallingPendingIds) {
+ this._fallingPendingIds = new Set();
+ } else {
+ this._fallingPendingIds.clear();
+ }
+ } else {
+ if (!this._fallingById) {
+ this._fallingById = new Map();
+ }
+ if (!this._fallingPendingIds) {
+ this._fallingPendingIds = new Set();
+ }
+ }
+
+ const allowAnnounceFall = this.enableFallingSkies && this.vizHadOneLayout;
+
+ const existingNodeIds = this.nodes.getIds();
+ if (this.network) {
+ const snap = this.network.getPositions(existingNodeIds);
+ if (snap) {
+ for (const id of existingNodeIds) {
+ const p = snap[id];
+ if (p && Number.isFinite(p.x) && Number.isFinite(p.y)) {
+ posById[id] = { x: p.x, y: p.y };
+ }
+ }
+ }
+ }
+
const isDarkMode = document.documentElement.classList.contains("dark");
const fontColor = isDarkMode ? "#ffffff" : "#000000";
- // search filter helper
const searchLower = this.searchQuery.toLowerCase();
const matchesSearch = (text) => !this.searchQuery || (text && text.toLowerCase().includes(searchLower));
- // Add me
const meLabel = this.config?.display_name ?? "Local Node";
if (matchesSearch(meLabel) || matchesSearch(this.config?.identity_hash)) {
+ const mp = this.pickStablePosition("me", posById, () => ({ x: 0, y: 0 }));
let meNode = {
id: "me",
group: "me",
@@ -1185,134 +1694,147 @@ export default {
title: `Local Node: ${meLabel}\nIdentity: ${this.config?.identity_hash ?? "Unknown"}`,
color: this.nodeColor("#3b82f6", isDarkMode ? "#1e40af" : "#eff6ff"),
font: { color: fontColor, size: 16, bold: true },
- x: 0,
- y: 0,
+ x: mp.x,
+ y: mp.y,
};
meNode = { ...meNode, ...this.getNodeLODProps(meNode, this.currentLOD) };
this.nodes.update([meNode]);
processedNodeIds.add("me");
}
- // Add interfaces
const interfaceNodes = [];
const interfaceEdges = [];
- const interfaceCount = this.interfaces.length;
- const radius = 400; // Start interfaces at 400px from center
+ const ifaceEntries = [];
+ const radius = 400;
- for (let idx = 0; idx < interfaceCount; idx++) {
+ for (let idx = 0; idx < this.interfaces.length; idx++) {
const entry = this.interfaces[idx];
+ if (!this.showDisabledInterfaces && !entry.status) {
+ continue;
+ }
let label = entry.interface_name ?? entry.name;
if (entry.type === "LocalServerInterface" || entry.parent_interface_name != null) {
label = entry.name;
}
-
if (matchesSearch(label) || matchesSearch(entry.name)) {
- // Distribute interfaces in a circle
- const angle = (idx / interfaceCount) * 2 * Math.PI;
- const initialX = Math.cos(angle) * radius;
- const initialY = Math.sin(angle) * radius;
-
- let interfaceNode = {
- id: entry.name,
- group: "interface",
- label: label,
- title: `${entry.name}\nState: ${entry.status ? "Online" : "Offline"}\nBitrate: ${Utils.formatBitsPerSecond(entry.bitrate)}\nTX: ${Utils.formatBytes(entry.txb)}\nRX: ${Utils.formatBytes(entry.rxb)}`,
- size: 35,
- _originalSize: 35,
- shape: "circularImage",
- _originalShape: "circularImage",
- image: entry.status
- ? "/assets/images/network-visualiser/interface_connected.png"
- : "/assets/images/network-visualiser/interface_disconnected.png",
- color: this.nodeColor(entry.status ? "#10b981" : "#ef4444", isDarkMode ? "#064e3b" : "#ecfdf5"),
- font: { color: fontColor, size: 12, bold: true },
- x: initialX,
- y: initialY,
- };
- interfaceNode = { ...interfaceNode, ...this.getNodeLODProps(interfaceNode, this.currentLOD) };
- interfaceNodes.push(interfaceNode);
- processedNodeIds.add(entry.name);
-
- const edgeId = `me~${entry.name}`;
- interfaceEdges.push({
- id: edgeId,
- from: "me",
- to: entry.name,
- color: entry.status ? (isDarkMode ? "#065f46" : "#10b981") : isDarkMode ? "#7f1d1d" : "#ef4444",
- width: 3,
- length: 200,
- arrows: { to: { enabled: true, scaleFactor: 0.5 } },
- hidden: this.enableOrbit,
- });
- processedEdgeIds.add(edgeId);
+ ifaceEntries.push({ entry, label });
}
}
+
+ const nIface = ifaceEntries.length;
+ for (let j = 0; j < nIface; j++) {
+ const { entry, label } = ifaceEntries[j];
+ const angle = nIface > 0 ? (j / nIface) * 2 * Math.PI : 0;
+ const initialX = Math.cos(angle) * radius;
+ const initialY = Math.sin(angle) * radius;
+ const pos = this.pickStablePosition(entry.name, posById, () => ({ x: initialX, y: initialY }));
+
+ let interfaceNode = {
+ id: entry.name,
+ group: "interface",
+ label: label,
+ title: `${entry.name}\nState: ${entry.status ? "Online" : "Offline"}\nBitrate: ${Utils.formatBitsPerSecond(entry.bitrate)}\nTX: ${Utils.formatBytes(entry.txb)}\nRX: ${Utils.formatBytes(entry.rxb)}`,
+ size: 35,
+ _originalSize: 35,
+ shape: "circularImage",
+ _originalShape: "circularImage",
+ image: entry.status
+ ? "/assets/images/network-visualiser/interface_connected.png"
+ : "/assets/images/network-visualiser/interface_disconnected.png",
+ color: this.nodeColor(entry.status ? "#10b981" : "#ef4444", isDarkMode ? "#064e3b" : "#ecfdf5"),
+ font: { color: fontColor, size: 12, bold: true },
+ x: pos.x,
+ y: pos.y,
+ };
+ interfaceNode = { ...interfaceNode, ...this.getNodeLODProps(interfaceNode, this.currentLOD) };
+ interfaceNodes.push(interfaceNode);
+ processedNodeIds.add(entry.name);
+
+ const edgeId = `me~${entry.name}`;
+ interfaceEdges.push({
+ id: edgeId,
+ from: "me",
+ to: entry.name,
+ color: entry.status ? (isDarkMode ? "#065f46" : "#10b981") : isDarkMode ? "#7f1d1d" : "#ef4444",
+ width: 3,
+ length: 200,
+ arrows: { to: { enabled: true, scaleFactor: 0.5 } },
+ hidden: this.edgesHiddenForOverlayGames(),
+ });
+ processedEdgeIds.add(edgeId);
+ }
if (interfaceNodes.length > 0) this.nodes.update(interfaceNodes);
if (interfaceEdges.length > 0) this.edges.update(interfaceEdges);
const discoveredNodes = [];
const discoveredEdges = [];
- for (const disc of this.discoveredInterfaces) {
- const discId = `discovered~${disc.discovery_hash || disc.name}`;
- const discLabel = disc.name || disc.reachable_on || "Unknown";
- if (
- !matchesSearch(discLabel) &&
- !matchesSearch(disc.reachable_on) &&
- !matchesSearch(disc.transport_id)
- ) {
- continue;
+ if (this.showDiscoveredInterfaces) {
+ for (const disc of this.discoveredInterfaces) {
+ const discId = `discovered~${disc.discovery_hash || disc.name}`;
+ const discLabel = disc.name || disc.reachable_on || "Unknown";
+ if (
+ !matchesSearch(discLabel) &&
+ !matchesSearch(disc.reachable_on) &&
+ !matchesSearch(disc.transport_id)
+ ) {
+ continue;
+ }
+
+ if (this.hopFilterMax != null && disc.hops != null && disc.hops > this.hopFilterMax) {
+ continue;
+ }
+
+ const isConnected = this.discoveredActive.some((a) => {
+ const aHost = a.target_host || a.remote || a.listen_ip;
+ const aPort = a.target_port || a.listen_port;
+ return aHost && aPort && disc.reachable_on === aHost && String(disc.port) === String(aPort);
+ });
+
+ const angle = Math.random() * 2 * Math.PI;
+ const dist = 800 + Math.random() * 200;
+ const dp = this.pickStablePosition(discId, posById, () => ({
+ x: Math.cos(angle) * dist,
+ y: Math.sin(angle) * dist,
+ }));
+ let discNode = {
+ id: discId,
+ group: "discovered",
+ label: discLabel,
+ title: `Discovered: ${discLabel}\nType: ${disc.type || "Unknown"}\nHops: ${disc.hops ?? "?"}\nStatus: ${isConnected ? "Connected" : disc.status || "Available"}${disc.reachable_on ? `\nAddress: ${disc.reachable_on}:${disc.port}` : ""}`,
+ size: 25,
+ _originalSize: 25,
+ shape: "circularImage",
+ _originalShape: "circularImage",
+ image: isConnected
+ ? "/assets/images/network-visualiser/interface_connected.png"
+ : "/assets/images/network-visualiser/interface_disconnected.png",
+ color: this.nodeColor(
+ isConnected ? "#06b6d4" : "#64748b",
+ isDarkMode ? (isConnected ? "#164e63" : "#1e293b") : isConnected ? "#ecfeff" : "#f1f5f9"
+ ),
+ font: { color: fontColor, size: 10 },
+ x: dp.x,
+ y: dp.y,
+ };
+ discNode = { ...discNode, ...this.getNodeLODProps(discNode, this.currentLOD) };
+ discoveredNodes.push(discNode);
+ processedNodeIds.add(discId);
+
+ const edgeId = `me~${discId}`;
+ discoveredEdges.push({
+ id: edgeId,
+ from: "me",
+ to: discId,
+ color: {
+ color: isDarkMode ? "#155e75" : "#06b6d4",
+ opacity: 0.4,
+ },
+ width: 1,
+ dashes: true,
+ hidden: this.edgesHiddenForOverlayGames(),
+ });
+ processedEdgeIds.add(edgeId);
}
-
- if (this.hopFilterMax != null && disc.hops != null && disc.hops > this.hopFilterMax) {
- continue;
- }
-
- const isConnected = this.discoveredActive.some((a) => {
- const aHost = a.target_host || a.remote || a.listen_ip;
- const aPort = a.target_port || a.listen_port;
- return aHost && aPort && disc.reachable_on === aHost && String(disc.port) === String(aPort);
- });
-
- const angle = Math.random() * 2 * Math.PI;
- const dist = 800 + Math.random() * 200;
- let discNode = {
- id: discId,
- group: "discovered",
- label: discLabel,
- title: `Discovered: ${discLabel}\nType: ${disc.type || "Unknown"}\nHops: ${disc.hops ?? "?"}\nStatus: ${isConnected ? "Connected" : disc.status || "Available"}${disc.reachable_on ? `\nAddress: ${disc.reachable_on}:${disc.port}` : ""}`,
- size: 25,
- _originalSize: 25,
- shape: "circularImage",
- _originalShape: "circularImage",
- image: isConnected
- ? "/assets/images/network-visualiser/interface_connected.png"
- : "/assets/images/network-visualiser/interface_disconnected.png",
- color: this.nodeColor(
- isConnected ? "#06b6d4" : "#64748b",
- isDarkMode ? (isConnected ? "#164e63" : "#1e293b") : isConnected ? "#ecfeff" : "#f1f5f9"
- ),
- font: { color: fontColor, size: 10 },
- x: Math.cos(angle) * dist,
- y: Math.sin(angle) * dist,
- };
- discNode = { ...discNode, ...this.getNodeLODProps(discNode, this.currentLOD) };
- discoveredNodes.push(discNode);
- processedNodeIds.add(discId);
-
- const edgeId = `me~${discId}`;
- discoveredEdges.push({
- id: edgeId,
- from: "me",
- to: discId,
- color: {
- color: isDarkMode ? "#155e75" : "#06b6d4",
- opacity: 0.4,
- },
- width: 1,
- dashes: true,
- hidden: this.enableOrbit,
- });
- processedEdgeIds.add(edgeId);
}
if (discoveredNodes.length > 0) this.nodes.update(discoveredNodes);
if (discoveredEdges.length > 0) this.edges.update(discoveredEdges);
@@ -1356,35 +1878,61 @@ export default {
}
const conversation = this.conversations[announce.destination_hash];
- const interfaceNode = this.nodes.get(entry.interface);
- let initX = 0,
- initY = 0;
+ const ip = posById[entry.interface];
+ let initX = 0;
+ let initY = 0;
- if (interfaceNode && interfaceNode.x !== undefined) {
- // Place around their parent interface with some randomness to avoid stacking
+ if (ip && Number.isFinite(ip.x) && Number.isFinite(ip.y)) {
const angle = Math.random() * 2 * Math.PI;
const dist = 150 + Math.random() * 150;
- initX = interfaceNode.x + Math.cos(angle) * dist;
- initY = interfaceNode.y + Math.sin(angle) * dist;
+ initX = ip.x + Math.cos(angle) * dist;
+ initY = ip.y + Math.sin(angle) * dist;
} else {
- // Fallback far from center
const angle = Math.random() * 2 * Math.PI;
const dist = 600 + Math.random() * 200;
initX = Math.cos(angle) * dist;
initY = Math.sin(angle) * dist;
}
+ const targetXY = this.pickStablePosition(entry.hash, posById, () => ({ x: initX, y: initY }));
+ const edgeId = `${entry.interface}~${entry.hash}`;
+ const shouldFall = allowAnnounceFall && !prevIds.has(entry.hash);
+
let node = {
id: entry.hash,
group: "announce",
size: 25,
_originalSize: 25,
_announce: announce,
+ _parentInterface: entry.interface,
font: { color: fontColor, size: 11 },
- x: initX,
- y: initY,
+ x: targetXY.x,
+ y: targetXY.y,
};
+ if (shouldFall) {
+ this._fallingPendingIds.add(entry.hash);
+ let topY = targetXY.y - 1100;
+ const container = document.getElementById("network");
+ if (container && this.network) {
+ const scale = this.network.getScale();
+ const vp = this.network.getViewPosition();
+ const halfH = container.clientHeight / (2 * scale);
+ topY = vp.y - halfH - 60;
+ }
+ node.x = targetXY.x;
+ node.y = topY;
+ posById[entry.hash] = { x: targetXY.x, y: targetXY.y };
+ this._fallingById.set(entry.hash, {
+ tx: targetXY.x,
+ ty: targetXY.y,
+ x: targetXY.x,
+ y: topY,
+ vy: 0,
+ edgeIds: [edgeId],
+ });
+ }
+
node.label = displayName;
node.title = `${displayName}\nAspect: ${announce.aspect}\nHops: ${entry.hops}\nVia: ${entry.interface}\nLast Seen: ${Utils.convertDateTimeToLocalDateTimeString(new Date(announce.updated_at))}`;
@@ -1435,7 +1983,6 @@ export default {
batchNodes.push(node);
processedNodeIds.add(node.id);
- const edgeId = `${entry.interface}~${entry.hash}`;
batchEdges.push({
id: edgeId,
from: entry.interface,
@@ -1453,7 +2000,7 @@ export default {
},
width: entry.hops === 1 ? 2 : 1,
dashes: entry.hops > 1,
- hidden: this.enableOrbit,
+ hidden: this.edgeHiddenForMode(entry.hash),
});
processedEdgeIds.add(edgeId);
}
@@ -1471,6 +2018,12 @@ export default {
if (this.abortController.signal.aborted) return;
}
+ if (this.enablePong) {
+ for (const id of PONG_NODE_IDS) {
+ processedNodeIds.add(id);
+ }
+ }
+
// Cleanup: remove nodes/edges that are no longer in the network
const nodesToRemove = this.nodes.getIds().filter((id) => !processedNodeIds.has(id));
if (nodesToRemove.length > 0) this.nodes.remove(nodesToRemove);
@@ -1483,6 +2036,22 @@ export default {
this.currentBatch = 0;
this.totalBatches = 0;
+ this.lastVizKeys = [...processedNodeIds];
+ this.vizHadOneLayout = true;
+
+ if (this.network && !this.didDisableStabilization) {
+ this.didDisableStabilization = true;
+ this.network.setOptions({ physics: { stabilization: { enabled: false } } });
+ }
+
+ if (this.enableFallingSkies && this._fallingById && this._fallingById.size > 0) {
+ this.scheduleFallingTick();
+ }
+
+ if (this.enableSnake && this._snakeFoodIds) {
+ this.reconcileSnakeFoodAfterViz();
+ }
+
if (this.enableOrbit) {
this.startOrbit();
}
diff --git a/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserLegend.vue b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserLegend.vue
new file mode 100644
index 0000000..e69ad38
--- /dev/null
+++ b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserLegend.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
Discovered ({{ discoveredCount }})
+
+
+
+
+
diff --git a/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserLoadingOverlay.vue b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserLoadingOverlay.vue
new file mode 100644
index 0000000..b50831a
--- /dev/null
+++ b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserLoadingOverlay.vue
@@ -0,0 +1,49 @@
+
+
+
+
+
{{ loadingStatus }}
+
+
+
+ {{ $t("visualiser.batch") }} {{ currentBatch }} / {{ totalBatches }}
+ {{ Math.round((loadedNodesCount / totalNodesToLoad) * 100) }}%
+
+
+
+
+
+
+
diff --git a/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue
new file mode 100644
index 0000000..d72ecbe
--- /dev/null
+++ b/meshchatx/src/frontend/components/network-visualiser/internal/NetworkVisualiserToolbar.vue
@@ -0,0 +1,252 @@
+
+
+
+
+
+ {{
+ $t("visualiser.reticulum_mesh")
+ }}
+ {{ $t("visualiser.network_visualizer") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ hopFilterSlider === 0 ? $t("visualiser.all") : hopFilterSlider }}
+
+
+
+
+
+
+
+ Nodes
+
+
{{ nodeCount }}
+
+
+
+ Links
+
+
{{ edgeCount }}
+
+
+
+
+
+ Interfaces
+
+
+
+
+
{{ onlineInterfaceCount }} Online
+
+
+
+
{{ offlineInterfaceCount }} Offline
+
+
+
+
+
+
+
+
+
+
+