feat: visualiser improvements and 3D view and decrease collapased sidebar width a bit.

This commit is contained in:
Ivan 2026-08-13 18:24:52 -05:00
parent 42496edcea
commit 14fd2bcfa8
No known key found for this signature in database
42 changed files with 1511 additions and 82 deletions

Binary file not shown.

View file

@ -66,7 +66,7 @@
<MaterialDesignIcon :icon-name="isSidebarOpen ? 'close' : 'menu'" class="size-6" />
</button>
<div class="flex min-w-0 flex-1 items-center gap-2 sm:flex-initial sm:gap-3">
<div class="hidden shrink-0 justify-start sm:flex sm:w-16 sm:justify-center">
<div class="hidden shrink-0 justify-start sm:flex sm:w-14 sm:justify-center">
<div
class="flex h-10 w-10 cursor-pointer items-center justify-center overflow-hidden rounded-xl sm:h-14 sm:w-14"
@click="onAppNameClick"
@ -218,7 +218,7 @@
class="absolute inset-y-0 left-0 z-70 transform transition-all duration-300 ease-in-out sm:relative sm:inset-auto sm:z-0 sm:flex sm:translate-x-0"
:class="[
isSidebarOpen ? 'translate-x-0' : '-translate-x-full',
isSidebarCollapsed ? 'w-16' : 'w-80 md:max-lg:w-64 lg:w-80',
isSidebarCollapsed ? 'w-14' : 'w-80 md:max-lg:w-64 lg:w-80',
]"
>
<div

View file

@ -42,6 +42,7 @@
:search-query="searchQuery"
:preferred-renderer="preferredRenderer"
:engine-mode="engineMode"
:view-mode="viewMode"
:fps="fps"
@update:is-showing-controls="isShowingControls = $event"
@update:auto-reload="autoReload = $event"
@ -49,6 +50,7 @@
@update:hop-max-filter="onUserHopMaxFilterChange"
@update:search-query="searchQuery = $event"
@update:preferred-renderer="onPreferredRendererChange"
@update:view-mode="onViewModeChange"
@manual-update="manualUpdate"
/>
<NetworkVisualiserLegend
@ -79,6 +81,7 @@ import {
dedupeIconQueueEntries,
lodLevelFromScale,
pathHashesWithinHopFilter,
layoutSpringLength,
pickAdaptiveFetchConcurrency,
settleLayout,
warmVisualiserWasm,
@ -97,6 +100,7 @@ import {
persistVisualiserAutoReload,
persistVisualiserLiveLayout,
persistVisualiserRenderer,
persistVisualiserViewMode,
VISUALISER_DISPLAY_PREFS_CHANGED,
} from "../../js/settings/settingsVisualiserPrefs.js";
import ToastUtils from "../../js/ToastUtils";
@ -192,6 +196,7 @@ export default {
isLoading: false,
enablePhysics: displayPrefs.enablePhysics,
preferredRenderer: displayPrefs.renderer || "auto",
viewMode: displayPrefs.viewMode === "planet" ? "planet" : "flat",
showDisabledInterfaces: displayPrefs.showDisabledInterfaces,
showDiscoveredInterfaces: displayPrefs.showDiscoveredInterfaces,
loadingStatus: "Initializing...",
@ -326,6 +331,9 @@ export default {
if (this._visualiserPrefsHandler) {
GlobalEmitter.off(VISUALISER_DISPLAY_PREFS_CHANGED, this._visualiserPrefsHandler);
}
if (this._identitySwitchedHandler) {
GlobalEmitter.off("identity-switched", this._identitySwitchedHandler);
}
if (this._batterySaverPrefsHandler) {
GlobalEmitter.off(BATTERY_SAVER_CHANGED_EVENT, this._batterySaverPrefsHandler);
}
@ -384,18 +392,28 @@ export default {
this._visualiserPrefsHandler = async () => {
const prevRenderer = this.preferredRenderer;
const prevDisabled = this.showDisabledInterfaces;
const prevDiscovered = this.showDiscoveredInterfaces;
this.loadVisualiserDisplayPrefs();
this.applyBatterySaverVisualiserPrefs();
this.webglEngine?.setViewMode?.(this.viewMode);
if (this.preferredRenderer !== prevRenderer) {
await this.reinitRenderer();
return;
}
if (this.hasRenderer) {
const filtersChanged =
this.showDisabledInterfaces !== prevDisabled || this.showDiscoveredInterfaces !== prevDiscovered;
if (filtersChanged && this.hasRenderer) {
this.processVisualization();
}
};
GlobalEmitter.on(VISUALISER_DISPLAY_PREFS_CHANGED, this._visualiserPrefsHandler);
this._identitySwitchedHandler = () => {
this.onIdentitySwitched();
};
GlobalEmitter.on("identity-switched", this._identitySwitchedHandler);
this._batterySaverPrefsHandler = (prefs) => {
this.batterySaverPrefs = prefs || loadBatterySaverPrefs();
this.applyBatterySaverVisualiserPrefs();
@ -543,17 +561,52 @@ export default {
async persistVisualiserCache() {
const identityHash = this.config?.identity_hash;
if (!identityHash) return;
const positions = this.snapshotNodePositions();
this.cachedPositions = { ...this.cachedPositions, ...positions };
const pathTable = this.pathTable;
const announces = this.announces;
const positions = { ...this.cachedPositions, ...this.snapshotNodePositions() };
this.cachedPositions = positions;
await saveVisualiserCache({
identityHash,
pathTable: this.pathTable,
announces: this.announces,
positions: this.cachedPositions,
pathTable,
announces,
positions,
pathSoftCap: VIZ_PATH_TABLE_SOFT_CAP,
announceSoftCap: VIZ_ANNOUNCE_SOFT_CAP,
});
},
onIdentitySwitched() {
this.vizRunGeneration += 1;
this.iconQueueGeneration += 1;
this.iconQueue = [];
if (this.abortController) {
this.abortController.abort();
}
this.abortController = new AbortController();
this.cachedPositions = {};
this.pathTable = [];
this.announces = {};
this.conversations = {};
this.interfaces = [];
this.discoveredInterfaces = [];
this.discoveredActive = [];
this.graphNodeCount = 0;
this.graphEdgeCount = 0;
this.config = null;
if (this.webglEngine) {
try {
this.webglEngine.setGraph([], []);
} catch {
/* engine may already be torn down */
}
}
try {
this.nodes.clear();
this.edges.clear();
} catch {
/* DataSet may already be destroyed */
}
void this.update({ silent: false });
},
onUserHopMaxFilterChange(v) {
this.hopMaxFilter = v;
writeStoredHopMaxFilter(v);
@ -851,6 +904,7 @@ export default {
this.enablePhysics = p.enablePhysics;
this.autoReload = p.autoReload;
this.preferredRenderer = p.renderer || "auto";
this.viewMode = p.viewMode === "planet" ? "planet" : "flat";
},
async onPreferredRendererChange(next) {
const normalized = next === "webgl" || next === "vis" || next === "auto" ? next : "auto";
@ -859,6 +913,13 @@ export default {
persistVisualiserRenderer(normalized, { emit: false });
await this.reinitRenderer();
},
onViewModeChange(next) {
const normalized = next === "planet" ? "planet" : "flat";
if (normalized === this.viewMode) return;
this.viewMode = normalized;
persistVisualiserViewMode(normalized, { emit: false });
this.webglEngine?.setViewMode?.(normalized);
},
destroyActiveRenderer() {
this.hoverTooltip = null;
if (this.webglEngine) {
@ -893,6 +954,7 @@ export default {
onNodeActivate: (id, meta) => this.onWebGLNodeActivate(id, meta),
onHover: (id, meta, x, y) => this.onWebGLHover(id, meta, x, y),
});
this.webglEngine.setViewMode(this.viewMode);
this.rendererMode = "webgl";
this.engineMode = "webgl";
return true;
@ -965,7 +1027,7 @@ export default {
const layoutEdges = this.edges.get().map((e) => ({
from: e.from,
to: e.to,
length: e.width >= 2.5 ? 440 : 500,
length: layoutSpringLength(e.width),
}));
const settled = settleLayout({ nodes: layoutNodes, edges: layoutEdges, iterations: 0 });
const positions = settled?.positions || {};
@ -1682,7 +1744,7 @@ export default {
graph.layout_edges = graphEdges.map((e) => ({
from: e.from,
to: e.to,
length: e.width >= 2.5 ? 440 : 500,
length: layoutSpringLength(e.width),
}));
}

View file

@ -151,6 +151,46 @@
/>
</div>
<div v-if="engineMode === 'webgl'" class="flex items-center justify-between gap-2">
<span class="text-sm font-semibold text-gray-700 dark:text-zinc-300">{{
$t("visualiser.view_mode")
}}</span>
<div
class="inline-flex shrink-0 rounded-lg border border-gray-200 bg-gray-50 p-0.5 dark:border-zinc-700 dark:bg-zinc-800"
role="group"
:aria-label="$t('visualiser.view_mode')"
>
<button
id="visualiser-view-flat"
type="button"
class="rounded-md px-2.5 py-1 text-[11px] font-bold"
:class="
viewMode === 'flat'
? 'bg-white text-blue-600 shadow-xs dark:bg-zinc-700 dark:text-blue-300'
: 'text-gray-500 dark:text-zinc-400'
"
:aria-pressed="viewMode === 'flat' ? 'true' : 'false'"
@click="$emit('update:viewMode', 'flat')"
>
{{ $t("visualiser.view_mode_flat") }}
</button>
<button
id="visualiser-view-planet"
type="button"
class="rounded-md px-2.5 py-1 text-[11px] font-bold"
:class="
viewMode === 'planet'
? 'bg-white text-blue-600 shadow-xs dark:bg-zinc-700 dark:text-blue-300'
: 'text-gray-500 dark:text-zinc-400'
"
:aria-pressed="viewMode === 'planet' ? 'true' : 'false'"
@click="$emit('update:viewMode', 'planet')"
>
{{ $t("visualiser.view_mode_planet") }}
</button>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between gap-2">
<label
@ -303,6 +343,13 @@ export default {
return ["checking", "wasm", "fallback", "webgl"].includes(v);
},
},
viewMode: {
type: String,
default: "flat",
validator(v) {
return v === "flat" || v === "planet";
},
},
fps: { type: Number, default: 0 },
},
emits: [
@ -312,6 +359,7 @@ export default {
"update:hopMaxFilter",
"update:searchQuery",
"update:preferredRenderer",
"update:viewMode",
"manual-update",
],
data() {

View file

@ -1034,6 +1034,7 @@
<VisualiserSettingsSection
:visible="showSection('visualiser')"
:renderer="visualiserRenderer"
:view-mode="visualiserViewMode"
:show-disabled-interfaces="visualiserShowDisabledInterfaces"
:show-discovered-interfaces="visualiserShowDiscoveredInterfaces"
@renderer-change="
@ -1042,6 +1043,12 @@
onVisualiserRendererChange();
}
"
@view-mode-change="
(v) => {
visualiserViewMode = v;
onVisualiserViewModeChange();
}
"
@show-disabled-change="onVisualiserShowDisabledChange"
@show-discovered-change="onVisualiserShowDiscoveredChange"
/>
@ -2925,6 +2932,7 @@ import {
persistVisualiserShowDisabled,
persistVisualiserShowDiscovered,
persistVisualiserRenderer,
persistVisualiserViewMode,
} from "../../js/settings/settingsVisualiserPrefs";
import { loadBatterySaverPrefs, saveBatterySaverPrefs } from "../../js/settings/batterySaverPrefs.js";
import {
@ -3141,6 +3149,7 @@ export default {
visualiserShowDisabledInterfaces: false,
visualiserShowDiscoveredInterfaces: false,
visualiserRenderer: "auto",
visualiserViewMode: "flat",
batterySaver: loadBatterySaverPrefs(),
batteryInterfaceRows: [],
batteryBitrateBusy: false,
@ -3815,6 +3824,7 @@ export default {
this.visualiserShowDisabledInterfaces = p.showDisabledInterfaces;
this.visualiserShowDiscoveredInterfaces = p.showDiscoveredInterfaces;
this.visualiserRenderer = p.renderer || "auto";
this.visualiserViewMode = p.viewMode === "planet" ? "planet" : "flat";
},
onVisualiserShowDisabledChange(val) {
this.visualiserShowDisabledInterfaces = val;
@ -3827,6 +3837,9 @@ export default {
onVisualiserRendererChange() {
persistVisualiserRenderer(this.visualiserRenderer);
},
onVisualiserViewModeChange() {
persistVisualiserViewMode(this.visualiserViewMode);
},
async getTrustedTelemetryPeers() {
try {
const response = await window.api.get("/api/v1/telemetry/trusted-peers");

View file

@ -28,6 +28,23 @@
<option value="vis">{{ $t("visualiser.renderer_option_vis") }}</option>
</select>
</div>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("visualiser.view_mode") }}
</div>
<p class="text-xs text-gray-600 dark:text-gray-400">
{{ $t("visualiser.view_mode_desc") }}
</p>
<select
id="settings-visualiser-view-mode"
:value="viewMode"
class="input-field"
@change="$emit('view-mode-change', $event.target.value)"
>
<option value="flat">{{ $t("visualiser.view_mode_flat_full") }}</option>
<option value="planet">{{ $t("visualiser.view_mode_planet_full") }}</option>
</select>
</div>
<label class="setting-toggle">
<Toggle
id="settings-visualiser-offline"
@ -69,6 +86,10 @@ export default {
type: String,
default: "auto",
},
viewMode: {
type: String,
default: "flat",
},
showDisabledInterfaces: {
type: Boolean,
default: false,
@ -78,6 +99,6 @@ export default {
default: false,
},
},
emits: ["renderer-change", "show-disabled-change", "show-discovered-change"],
emits: ["renderer-change", "view-mode-change", "show-disabled-change", "show-discovered-change"],
};
</script>

View file

@ -9,7 +9,9 @@
const DB_NAME = "meshchatx_visualiser_cache";
const DB_VERSION = 1;
const STORE_NAME = "snapshots";
const CACHE_VERSION = 1;
// Bump when layout scale changes so stored x/y from an older spring length
// are not reused as the new compact seed.
const CACHE_VERSION = 2;
const MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
let dbPromise = null;

View file

@ -11,6 +11,21 @@ export const VIZ_ANNOUNCE_ASPECTS = ["lxmf.delivery", "nomadnetwork.node"];
export const ANNOUNCE_HASH_CHUNK_SIZE = 500;
/** Must match visualiser-wasm layout.DefaultHubSpringLen. */
export const VIZ_HUB_SPRING_LEN = 200;
/** Must match visualiser-wasm layout.DefaultSpringLen. */
export const VIZ_PEER_SPRING_LEN = 240;
/**
* vis-style edge width to WASM spring rest length.
* @param {number} width
* @returns {number}
*/
export function layoutSpringLength(width) {
return Number(width) >= 2.5 ? VIZ_HUB_SPRING_LEN : VIZ_PEER_SPRING_LEN;
}
/** Soft cap for client-side path table rows kept in the visualiser. */
export const VIZ_PATH_TABLE_SOFT_CAP = 20_000;
@ -231,11 +246,11 @@ export function buildPathGraphJs(req) {
const ip = positions[entry.interface];
const angle = hashAngle(entry.hash);
if (ip && Number.isFinite(ip.x) && Number.isFinite(ip.y)) {
const dist = 240 + hash01(entry.hash, "r") * 220;
const dist = 140 + hash01(entry.hash, "r") * 90;
x = ip.x + Math.cos(angle) * dist;
y = ip.y + Math.sin(angle) * dist;
} else {
const dist = 720 + hash01(entry.hash, "r") * 280;
const dist = 400 + hash01(entry.hash, "r") * 160;
x = Math.cos(angle) * dist;
y = Math.sin(angle) * dist;
}
@ -382,7 +397,7 @@ export function buildFullGraph(req) {
layout_edges: (path.edges || []).map((e) => ({
from: e.from,
to: e.to,
length: e.width >= 2.5 ? 440 : 500,
length: layoutSpringLength(e.width),
})),
};
}

View file

@ -0,0 +1,613 @@
// SPDX-License-Identifier: 0BSD
/**
* Planet view for the WebGL network visualiser.
* Maps the 2D force layout onto a unit sphere (local node at the front)
* and projects with an orbit camera. Pixel draw stays in the 2D WebGL path.
*/
import { EDGE_STRIDE, NODE_STRIDE } from "./networkVisualiserWebGL.js";
export const PLANET_VIEW = "planet";
export const FLAT_VIEW = "flat";
export const PLANET_FOV_Y = (50 * Math.PI) / 180;
export const PLANET_NEAR = 0.12;
export const PLANET_FAR = 24;
export const PLANET_DIST_MIN = 1.65;
export const PLANET_DIST_MAX = 8.5;
export const PLANET_PITCH_LIMIT = 1.18;
export const DEFAULT_ORBIT_YAW = 0.55;
export const DEFAULT_ORBIT_PITCH = 0.32;
export const DEFAULT_ORBIT_DIST = 3.05;
export const LAYOUT_SCALE_FLOOR = 160;
/** Scale above max layout radius so the farthest node stays on the back hemisphere. */
export const LAYOUT_SCALE_FIT = 1.08;
const meridians = 18;
const parallels = 8;
const gridSegs = 24;
/** @type {{x1:number,y1:number,z1:number,x2:number,y2:number,z2:number}[]|null} */
let globeGridCache = null;
let nodeScratch = new Float32Array(0);
let edgeScratch = new Float32Array(0);
let depthScratch = new Float32Array(0);
/**
* @param {unknown} raw
* @returns {"flat"|"planet"}
*/
export function normalizeVisualiserViewMode(raw) {
return raw === PLANET_VIEW ? PLANET_VIEW : FLAT_VIEW;
}
/**
* @param {number} yaw
* @param {number} pitch
* @param {number} dist
*/
export function clampOrbit(yaw, pitch, dist) {
let y = Number.isFinite(yaw) ? yaw : DEFAULT_ORBIT_YAW;
let p = Number.isFinite(pitch) ? pitch : DEFAULT_ORBIT_PITCH;
if (p < -PLANET_PITCH_LIMIT) p = -PLANET_PITCH_LIMIT;
if (p > PLANET_PITCH_LIMIT) p = PLANET_PITCH_LIMIT;
let d = Number.isFinite(dist) && dist > 0 ? dist : DEFAULT_ORBIT_DIST;
if (d < PLANET_DIST_MIN) d = PLANET_DIST_MIN;
if (d > PLANET_DIST_MAX) d = PLANET_DIST_MAX;
return { yaw: y, pitch: p, dist: d };
}
/**
* Max layout radius used to wrap the graph onto the sphere.
* @param {Float32Array|number[]|null|undefined} nodes NODE_STRIDE or SCENE stride with x,y at 0,1
* @param {number} stride
*/
export function computeLayoutScale(nodes, stride = NODE_STRIDE) {
const step = stride > 0 ? stride : NODE_STRIDE;
const n = nodes && nodes.length ? Math.floor(nodes.length / step) : 0;
let maxR = 0;
for (let i = 0; i < n; i++) {
const o = i * step;
const r = Math.hypot(nodes[o] || 0, nodes[o + 1] || 0);
if (r > maxR) maxR = r;
}
return Math.max(maxR * LAYOUT_SCALE_FIT, LAYOUT_SCALE_FLOOR);
}
/**
* Local node (origin) sits on the front of the globe. Distance wraps toward the back.
* @param {number} x
* @param {number} y
* @param {number} layoutScale
* @returns {{x:number,y:number,z:number,theta:number,phi:number}}
*/
export function layoutToSphere(x, y, layoutScale) {
const scale = layoutScale > 1e-6 ? layoutScale : LAYOUT_SCALE_FLOOR;
const lx = Number.isFinite(x) ? x : 0;
const ly = Number.isFinite(y) ? y : 0;
const theta = Math.atan2(ly, lx);
const r = Math.hypot(lx, ly) / scale;
const phi = Math.min(r, 1) * Math.PI;
const sp = Math.sin(phi);
return {
x: sp * Math.cos(theta),
y: sp * Math.sin(theta),
z: Math.cos(phi),
theta,
phi,
};
}
/**
* Inverse of layoutToSphere. Point should lie on the unit sphere.
* @param {number} x
* @param {number} y
* @param {number} z
* @param {number} layoutScale
*/
export function sphereToLayout(x, y, z, layoutScale) {
const scale = layoutScale > 1e-6 ? layoutScale : LAYOUT_SCALE_FLOOR;
const len = Math.hypot(x, y, z);
if (len < 1e-8) return { x: 0, y: 0 };
const nx = x / len;
const ny = y / len;
const nz = z / len;
const phi = Math.acos(Math.max(-1, Math.min(1, nz)));
const theta = Math.atan2(ny, nx);
const r = (phi / Math.PI) * scale;
return { x: r * Math.cos(theta), y: r * Math.sin(theta) };
}
/**
* Camera position looking at the origin.
* @param {number} yaw
* @param {number} pitch
* @param {number} dist
*/
export function orbitEye(yaw, pitch, dist) {
const cp = Math.cos(pitch);
return {
x: dist * cp * Math.sin(yaw),
y: dist * Math.sin(pitch),
z: dist * cp * Math.cos(yaw),
};
}
/**
* @param {{x:number,y:number,z:number}} eye
* @returns {{forward:{x:number,y:number,z:number}, right:{x:number,y:number,z:number}, up:{x:number,y:number,z:number}}}
*/
export function cameraBasis(eye) {
const fl = Math.hypot(eye.x, eye.y, eye.z) || 1;
const forward = { x: -eye.x / fl, y: -eye.y / fl, z: -eye.z / fl };
let cx = forward.y * 0 - forward.z * 1;
let cy = forward.z * 0 - forward.x * 0;
let cz = forward.x * 1 - forward.y * 0;
let rl = Math.hypot(cx, cy, cz);
if (rl < 1e-6) {
cx = 1;
cy = 0;
cz = 0;
rl = 1;
}
const right = { x: cx / rl, y: cy / rl, z: cz / rl };
const up = {
x: right.y * forward.z - right.z * forward.y,
y: right.z * forward.x - right.x * forward.z,
z: right.x * forward.y - right.y * forward.x,
};
return { forward, right, up };
}
/**
* @param {{x:number,y:number,z:number}} eye
* @returns {Float32Array} column-major 4x4 view matrix
*/
export function lookAtOrigin(eye) {
const { forward, right, up } = cameraBasis(eye);
const out = new Float32Array(16);
out[0] = right.x;
out[1] = up.x;
out[2] = -forward.x;
out[3] = 0;
out[4] = right.y;
out[5] = up.y;
out[6] = -forward.y;
out[7] = 0;
out[8] = right.z;
out[9] = up.z;
out[10] = -forward.z;
out[11] = 0;
out[12] = -(right.x * eye.x + right.y * eye.y + right.z * eye.z);
out[13] = -(up.x * eye.x + up.y * eye.y + up.z * eye.z);
out[14] = forward.x * eye.x + forward.y * eye.y + forward.z * eye.z;
out[15] = 1;
return out;
}
/**
* @param {number} fovY
* @param {number} aspect
* @param {number} near
* @param {number} far
* @returns {Float32Array} column-major 4x4
*/
export function perspective(fovY, aspect, near, far) {
const out = new Float32Array(16);
const f = 1 / Math.tan(fovY * 0.5);
const a = aspect > 1e-6 ? aspect : 1;
const nf = 1 / (near - far);
out[0] = f / a;
out[5] = f;
out[10] = (far + near) * nf;
out[11] = -1;
out[14] = 2 * far * near * nf;
return out;
}
/**
* @param {Float32Array} a
* @param {Float32Array} b
*/
export function mat4Multiply(a, b) {
const out = new Float32Array(16);
for (let col = 0; col < 4; col++) {
const b0 = b[col * 4];
const b1 = b[col * 4 + 1];
const b2 = b[col * 4 + 2];
const b3 = b[col * 4 + 3];
out[col * 4] = a[0] * b0 + a[4] * b1 + a[8] * b2 + a[12] * b3;
out[col * 4 + 1] = a[1] * b0 + a[5] * b1 + a[9] * b2 + a[13] * b3;
out[col * 4 + 2] = a[2] * b0 + a[6] * b1 + a[10] * b2 + a[14] * b3;
out[col * 4 + 3] = a[3] * b0 + a[7] * b1 + a[11] * b2 + a[15] * b3;
}
return out;
}
/**
* @param {Float32Array} m
* @param {number} x
* @param {number} y
* @param {number} z
*/
export function transformPoint(m, x, y, z) {
return {
x: m[0] * x + m[4] * y + m[8] * z + m[12],
y: m[1] * x + m[5] * y + m[9] * z + m[13],
z: m[2] * x + m[6] * y + m[10] * z + m[14],
w: m[3] * x + m[7] * y + m[11] * z + m[15],
};
}
/**
* Front of the globe faces the camera when dot(p, eye) > 1 on the unit sphere.
* @param {number} px
* @param {number} py
* @param {number} pz
* @param {{x:number,y:number,z:number}} eye
*/
export function sphereFacing(px, py, pz, eye) {
return px * eye.x + py * eye.y + pz * eye.z - 1;
}
/**
* @param {number} clipX
* @param {number} clipY
* @param {number} clipW
* @param {number} width
* @param {number} height
*/
export function clipToScreen(clipX, clipY, clipW, width, height) {
if (!(Math.abs(clipW) > 1e-8)) {
return { x: width * 0.5, y: height * 0.5, ok: false };
}
const ndcX = clipX / clipW;
const ndcY = clipY / clipW;
return {
x: (ndcX * 0.5 + 0.5) * width,
y: (1 - (ndcY * 0.5 + 0.5)) * height,
ok: clipW > 0,
};
}
/**
* World (origin-centered, y-down like the 2D shader) from CSS pixels at zoom 1 cam 0.
* @param {number} sx
* @param {number} sy
* @param {number} width
* @param {number} height
*/
export function screenToDrawWorld(sx, sy, width, height) {
return { x: sx - width * 0.5, y: sy - height * 0.5 };
}
/**
* Pixel radius of the unit sphere silhouette.
* @param {number} dist
* @param {number} fovY
* @param {number} height
*/
export function projectedSphereRadiusPx(dist, fovY, height) {
const d = Math.max(dist, 1.001);
const ang = Math.asin(Math.min(0.999, 1 / d));
const half = Math.tan(fovY * 0.5);
if (!(half > 0)) return height * 0.25;
return (Math.tan(ang) / half) * (height * 0.5);
}
/**
* Zoom stand-in for 2D LOD bands. Default distance maps to zoom 1 (high).
* @param {number} dist
*/
export function planetLodZoom(dist) {
const d = dist > 0.2 ? dist : DEFAULT_ORBIT_DIST;
return DEFAULT_ORBIT_DIST / d;
}
function sph(lon, lat) {
const cl = Math.cos(lat);
return { x: cl * Math.cos(lon), y: Math.sin(lat), z: cl * Math.sin(lon) };
}
/**
* Lat/lon line segments on the unit sphere (Y up).
*/
export function buildGlobeGrid() {
if (globeGridCache) return globeGridCache;
const lines = [];
for (let i = 0; i < meridians; i++) {
const lon = (i / meridians) * Math.PI * 2;
let prev = null;
for (let s = 0; s <= gridSegs; s++) {
const lat = Math.PI / 2 - (s / gridSegs) * Math.PI;
const p = sph(lon, lat);
if (prev) {
lines.push({ x1: prev.x, y1: prev.y, z1: prev.z, x2: p.x, y2: p.y, z2: p.z });
}
prev = p;
}
}
for (let j = 1; j <= parallels; j++) {
const lat = Math.PI / 2 - (j / (parallels + 1)) * Math.PI;
let prev = null;
const first = sph(0, lat);
for (let s = 1; s <= gridSegs; s++) {
const lon = (s / gridSegs) * Math.PI * 2;
const p = s === gridSegs ? first : sph(lon, lat);
if (prev) {
lines.push({ x1: prev.x, y1: prev.y, z1: prev.z, x2: p.x, y2: p.y, z2: p.z });
}
prev = p;
}
}
globeGridCache = lines;
return lines;
}
/**
* Camera ray from a CSS pixel.
* @param {number} cssX
* @param {number} cssY
* @param {number} width
* @param {number} height
* @param {{x:number,y:number,z:number}} eye
*/
export function screenRay(cssX, cssY, width, height, eye) {
const { forward, right, up } = cameraBasis(eye);
const aspect = height > 0 ? width / height : 1;
const tanHalf = Math.tan(PLANET_FOV_Y * 0.5);
const nx = (cssX / Math.max(width, 1)) * 2 - 1;
const ny = 1 - (cssY / Math.max(height, 1)) * 2;
const vx = right.x * nx * tanHalf * aspect + up.x * ny * tanHalf + forward.x;
const vy = right.y * nx * tanHalf * aspect + up.y * ny * tanHalf + forward.y;
const vz = right.z * nx * tanHalf * aspect + up.z * ny * tanHalf + forward.z;
const len = Math.hypot(vx, vy, vz) || 1;
return {
origin: eye,
dir: { x: vx / len, y: vy / len, z: vz / len },
};
}
/**
* Nearest hit of a ray with the unit sphere at the origin, or null.
* @param {{x:number,y:number,z:number}} origin
* @param {{x:number,y:number,z:number}} dir
* @param {number} [radius]
*/
export function raySphere(origin, dir, radius = 1) {
const r2 = radius * radius;
const ox = origin.x;
const oy = origin.y;
const oz = origin.z;
const dx = dir.x;
const dy = dir.y;
const dz = dir.z;
const b = ox * dx + oy * dy + oz * dz;
const c = ox * ox + oy * oy + oz * oz - r2;
const disc = b * b - c;
if (disc < 0) return null;
const s = Math.sqrt(disc);
const t0 = -b - s;
const t1 = -b + s;
const t = t0 > 1e-4 ? t0 : t1 > 1e-4 ? t1 : null;
if (t == null) return null;
return { x: ox + dx * t, y: oy + dy * t, z: oz + dz * t, t };
}
/**
* 2D layout under a planet-mode pointer, or null if the ray misses the globe.
*/
export function pointerToLayout(cssX, cssY, width, height, eye, layoutScale) {
const ray = screenRay(cssX, cssY, width, height, eye);
const hit = raySphere(ray.origin, ray.dir, 1);
if (!hit) return null;
return sphereToLayout(hit.x, hit.y, hit.z, layoutScale);
}
/**
* WASM DragTo screen point for a 2D layout coordinate.
* @param {number} lx
* @param {number} ly
* @param {number} width
* @param {number} height
* @param {{x?:number,y?:number,zoom?:number}|null} [cam]
*/
export function layoutToWasmScreen(lx, ly, width, height, cam = null) {
const zoom = cam?.zoom > 0 ? cam.zoom : 1;
return {
x: (lx - (cam?.x || 0)) * zoom + width * 0.5,
y: (ly - (cam?.y || 0)) * zoom + height * 0.5,
};
}
/**
* Project the 2D graph onto the globe and into the 2D draw buffers.
*
* @param {{
* nodes: Float32Array,
* edges: Float32Array|null|undefined,
* width: number,
* height: number,
* yaw: number,
* pitch: number,
* dist: number,
* dark: boolean,
* idByIndex?: (string|null|undefined)[],
* }} opts
* @returns {{
* nodes: Float32Array,
* edges: Float32Array,
* pick: {id:string,sx:number,sy:number,size:number}[],
* projected: {sx:number,sy:number,size:number,facing:number,front:boolean}[],
* layoutScale: number,
* camera: {x:number,y:number,zoom:number},
* }}
*/
export function projectPlanetScene(opts) {
const width = Math.max(1, opts?.width || 1);
const height = Math.max(1, opts?.height || 1);
const orbit = clampOrbit(opts?.yaw ?? DEFAULT_ORBIT_YAW, opts?.pitch ?? DEFAULT_ORBIT_PITCH, opts?.dist ?? DEFAULT_ORBIT_DIST);
const eye = orbitEye(orbit.yaw, orbit.pitch, orbit.dist);
const view = lookAtOrigin(eye);
const proj = perspective(PLANET_FOV_Y, width / height, PLANET_NEAR, PLANET_FAR);
const viewProj = mat4Multiply(proj, view);
const srcNodes = opts?.nodes;
const srcCount = srcNodes && srcNodes.length ? Math.floor(srcNodes.length / NODE_STRIDE) : 0;
const layoutScale = computeLayoutScale(srcNodes, NODE_STRIDE);
const idByIndex = opts?.idByIndex || [];
const dark = opts?.dark === true;
const grid = buildGlobeGrid();
const srcEdges = opts?.edges;
const srcEdgeCount = srcEdges && srcEdges.length ? Math.floor(srcEdges.length / EDGE_STRIDE) : 0;
const globeR = projectedSphereRadiusPx(orbit.dist, PLANET_FOV_Y, height);
const originClip = transformPoint(viewProj, 0, 0, 0);
const originScreen = clipToScreen(originClip.x, originClip.y, originClip.w, width, height);
const originDraw = screenToDrawWorld(originScreen.x, originScreen.y, width, height);
const outNodeCount = srcCount + 1;
const nodeNeed = outNodeCount * NODE_STRIDE;
if (nodeScratch.length < nodeNeed) {
nodeScratch = new Float32Array(nodeNeed);
}
const fill = dark ? [0.06, 0.14, 0.26, 0.92] : [0.76, 0.88, 0.97, 0.94];
nodeScratch[0] = originDraw.x;
nodeScratch[1] = originDraw.y;
nodeScratch[2] = Math.max(24, globeR);
nodeScratch[3] = fill[0];
nodeScratch[4] = fill[1];
nodeScratch[5] = fill[2];
nodeScratch[6] = fill[3];
nodeScratch[7] = 0;
nodeScratch[8] = 0;
nodeScratch[9] = 0;
const projected = [];
const pick = [];
const refDist = Math.hypot(eye.x, eye.y, eye.z - 1) || orbit.dist;
for (let i = 0; i < srcCount; i++) {
const o = i * NODE_STRIDE;
const sphP = layoutToSphere(srcNodes[o], srcNodes[o + 1], layoutScale);
const facing = sphereFacing(sphP.x, sphP.y, sphP.z, eye);
const clip = transformPoint(viewProj, sphP.x, sphP.y, sphP.z);
const screen = clipToScreen(clip.x, clip.y, clip.w, width, height);
const viewZ = Math.hypot(sphP.x - eye.x, sphP.y - eye.y, sphP.z - eye.z);
const persp = Math.max(0.35, Math.min(2.4, refDist / Math.max(viewZ, 0.2)));
const size = Math.max(6, (srcNodes[o + 2] || 18) * persp);
const front = facing > 0 && screen.ok;
const draw = screenToDrawWorld(screen.x, screen.y, width, height);
const d = (i + 1) * NODE_STRIDE;
const a = front ? srcNodes[o + 6] || 1 : Math.max(0.08, (srcNodes[o + 6] || 1) * 0.18);
nodeScratch[d] = draw.x;
nodeScratch[d + 1] = draw.y;
nodeScratch[d + 2] = front ? size : size * 0.55;
nodeScratch[d + 3] = srcNodes[o + 3];
nodeScratch[d + 4] = srcNodes[o + 4];
nodeScratch[d + 5] = srcNodes[o + 5];
nodeScratch[d + 6] = a;
nodeScratch[d + 7] = front ? srcNodes[o + 7] : 0;
nodeScratch[d + 8] = srcNodes[o + 8];
nodeScratch[d + 9] = srcNodes[o + 9];
const rec = { sx: screen.x, sy: screen.y, size, facing, front };
projected.push(rec);
const id = idByIndex[i];
if (front && id) {
pick.push({ id: String(id), sx: screen.x, sy: screen.y, size });
}
}
if (srcCount > 1) {
const order = new Array(srcCount);
for (let i = 0; i < srcCount; i++) order[i] = i;
order.sort((a, b) => projected[a].facing - projected[b].facing);
const bodyNeed = srcCount * NODE_STRIDE;
if (depthScratch.length < bodyNeed) {
depthScratch = new Float32Array(bodyNeed);
}
depthScratch.set(nodeScratch.subarray(NODE_STRIDE, NODE_STRIDE + bodyNeed));
for (let s = 0; s < srcCount; s++) {
const i = order[s];
nodeScratch.set(
depthScratch.subarray(i * NODE_STRIDE, (i + 1) * NODE_STRIDE),
(s + 1) * NODE_STRIDE
);
}
}
const gridColor = dark ? [0.28, 0.62, 0.82, 0.38] : [0.32, 0.52, 0.72, 0.42];
const edgeNeed = (grid.length + srcEdgeCount) * EDGE_STRIDE;
if (edgeScratch.length < edgeNeed) {
edgeScratch = new Float32Array(Math.max(edgeNeed, 64));
}
let w = 0;
const writeSeg = (x1, y1, z1, x2, y2, z2, r, g, b, a, requireFront) => {
const f1 = sphereFacing(x1, y1, z1, eye);
const f2 = sphereFacing(x2, y2, z2, eye);
if (requireFront && f1 <= 0 && f2 <= 0) return;
const c1 = transformPoint(viewProj, x1, y1, z1);
const c2 = transformPoint(viewProj, x2, y2, z2);
const s1 = clipToScreen(c1.x, c1.y, c1.w, width, height);
const s2 = clipToScreen(c2.x, c2.y, c2.w, width, height);
if (!s1.ok && !s2.ok) return;
const d1 = screenToDrawWorld(s1.x, s1.y, width, height);
const d2 = screenToDrawWorld(s2.x, s2.y, width, height);
const o = w * EDGE_STRIDE;
const fade = f1 > 0 || f2 > 0 ? 1 : 0.2;
edgeScratch[o] = d1.x;
edgeScratch[o + 1] = d1.y;
edgeScratch[o + 2] = d2.x;
edgeScratch[o + 3] = d2.y;
edgeScratch[o + 4] = r;
edgeScratch[o + 5] = g;
edgeScratch[o + 6] = b;
edgeScratch[o + 7] = a * fade;
w += 1;
};
for (let i = 0; i < grid.length; i++) {
const ln = grid[i];
writeSeg(ln.x1, ln.y1, ln.z1, ln.x2, ln.y2, ln.z2, gridColor[0], gridColor[1], gridColor[2], gridColor[3], true);
}
for (let i = 0; i < srcEdgeCount; i++) {
const o = i * EDGE_STRIDE;
const a = layoutToSphere(srcEdges[o], srcEdges[o + 1], layoutScale);
const b = layoutToSphere(srcEdges[o + 2], srcEdges[o + 3], layoutScale);
writeSeg(a.x, a.y, a.z, b.x, b.y, b.z, srcEdges[o + 4], srcEdges[o + 5], srcEdges[o + 6], srcEdges[o + 7] || 0.45, false);
}
return {
nodes: nodeScratch.subarray(0, nodeNeed),
edges: edgeScratch.subarray(0, w * EDGE_STRIDE),
pick,
projected,
layoutScale,
camera: { x: 0, y: 0, zoom: 1 },
};
}
/**
* Nearest front-facing node under a CSS point.
* @param {{id:string,sx:number,sy:number,size:number}[]} pick
* @param {number} cssX
* @param {number} cssY
* @param {number} [pad]
*/
export function pickPlanetNode(pick, cssX, cssY, pad = 10) {
if (!Array.isArray(pick) || !pick.length) return null;
let best = null;
let bestD = Infinity;
for (let i = 0; i < pick.length; i++) {
const n = pick[i];
const d = Math.hypot((n.sx || 0) - cssX, (n.sy || 0) - cssY);
const hit = Math.max(n.size || 10, pad);
if (d <= hit && d < bestD) {
bestD = d;
best = n.id;
}
}
return best;
}

View file

@ -12,6 +12,22 @@ import {
SCENE_NODE_STRIDE,
tryCreateWebGL2Context,
} from "./networkVisualiserWebGL.js";
import {
DEFAULT_ORBIT_DIST,
DEFAULT_ORBIT_PITCH,
DEFAULT_ORBIT_YAW,
FLAT_VIEW,
PLANET_VIEW,
clampOrbit,
layoutToWasmScreen,
normalizeVisualiserViewMode,
orbitEye,
pickPlanetNode,
planetLodZoom,
pointerToLayout,
projectPlanetScene,
screenToDrawWorld,
} from "./networkVisualiserPlanet.js";
export { isVisualiserWebGLSceneReady };
@ -304,6 +320,15 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) {
const pointers = new Map();
let pinchLastDist = 0;
let iconLoadGen = 0;
let viewMode = FLAT_VIEW;
let orbitYaw = DEFAULT_ORBIT_YAW;
let orbitPitch = DEFAULT_ORBIT_PITCH;
let orbitDist = DEFAULT_ORBIT_DIST;
let planetLayoutScale = 400;
/** @type {{id:string,sx:number,sy:number,size:number}[]} */
let planetPick = [];
/** @type {{sx:number,sy:number,size:number,facing:number,front:boolean}[]} */
let planetProjected = [];
function cssPoint(ev) {
const rect = canvas.getBoundingClientRect();
@ -439,41 +464,124 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) {
dirty = true;
}
function setViewMode(mode) {
const next = normalizeVisualiserViewMode(mode);
if (next === viewMode) {
dirty = true;
return;
}
viewMode = next;
dirty = true;
}
function isPlanet() {
return viewMode === PLANET_VIEW;
}
function pickNodeAt(cssX, cssY, pad) {
if (isPlanet()) {
return pickPlanetNode(planetPick, cssX, cssY, pad || 14);
}
return callScene("meshchatxVisualiserScenePick", cssX, cssY, pad || 16) || null;
}
function applyPlanetOrbit(nextYaw, nextPitch, nextDist) {
const c = clampOrbit(nextYaw, nextPitch, nextDist);
orbitYaw = c.yaw;
orbitPitch = c.pitch;
orbitDist = c.dist;
dirty = true;
}
function frame() {
if (!running) return;
rafId = requestAnimationFrame(frame);
const live = typeof hooks.getLiveLayout === "function" ? hooks.getLiveLayout() : false;
if (live && pointerMode !== "drag") {
callScene("meshchatxVisualiserSceneTick", 1);
dirty = true;
const moved = callScene("meshchatxVisualiserSceneTick", 1);
// false means the WASM solver is asleep. null/undefined is an older
// wasm or stub, keep drawing.
if (moved !== false) dirty = true;
}
if (!dirty && !live) return;
if (!dirty) return;
const dark = typeof hooks.isDark === "function" ? hooks.isDark() : false;
const buf = callScene("meshchatxVisualiserSceneGetDrawBuffers");
if (!buf || buf.ok === false) {
renderer.clearBackground(dark);
return;
}
const camera = {
x: buf.camX || 0,
y: buf.camY || 0,
zoom: buf.zoom > 0 ? buf.zoom : 1,
};
const sceneCount = buf.nodes && buf.nodes.length ? Math.floor(buf.nodes.length / SCENE_NODE_STRIDE) : 0;
const need = sceneCount * NODE_STRIDE;
if (drawNodeScratch.length < need) {
drawNodeScratch = new Float32Array(need);
}
const drawNodes = mergeSceneNodesWithTextures(buf.nodes, texMeta, drawNodeScratch);
const labels = collectWebGLLabels({
zoom: camera.zoom,
sceneCount,
nodes: buf.nodes,
labelByIndex,
idByIndex,
hoverId,
});
const size = renderer.draw(drawNodes, buf.edges, camera, dark, labels);
let camera = {
x: buf.camX || 0,
y: buf.camY || 0,
zoom: buf.zoom > 0 ? buf.zoom : 1,
};
let drawEdges = buf.edges;
let paintNodes = drawNodes;
const css = renderer.getCssSize();
if (isPlanet()) {
const planet = projectPlanetScene({
nodes: drawNodes,
edges: buf.edges,
width: css.width,
height: css.height,
yaw: orbitYaw,
pitch: orbitPitch,
dist: orbitDist,
dark,
idByIndex,
});
paintNodes = planet.nodes;
drawEdges = planet.edges;
camera = planet.camera;
planetPick = planet.pick;
planetProjected = planet.projected;
planetLayoutScale = planet.layoutScale;
} else {
planetPick = [];
planetProjected = [];
}
const labelZoom = isPlanet() ? planetLodZoom(orbitDist) : camera.zoom;
let paintLabels;
if (isPlanet()) {
paintLabels = [];
const lod = lodLevelFromScale(labelZoom);
if (lod !== "low") {
for (let i = 0; i < planetProjected.length; i++) {
const rec = planetProjected[i];
if (!rec?.front) continue;
const id = idByIndex[i] != null ? String(idByIndex[i]) : null;
const isMe = id === "me";
const isHover = hoverId != null && id === hoverId;
if (lod === "medium" && !isMe && !isHover) continue;
const text = truncateWebGLLabel(labelByIndex[i]);
if (!text) continue;
const draw = screenToDrawWorld(rec.sx, rec.sy, css.width, css.height);
paintLabels.push({
x: draw.x,
y: draw.y,
size: rec.size,
text,
fontSize: isMe ? 16 : 11,
});
}
}
} else {
paintLabels = collectWebGLLabels({
zoom: labelZoom,
sceneCount,
nodes: buf.nodes,
labelByIndex,
idByIndex,
hoverId,
});
}
const size = renderer.draw(paintNodes, drawEdges, camera, dark, paintLabels);
callScene("meshchatxVisualiserSceneResize", size.width, size.height);
nodeCount = buf.nodeCount || nodeCount;
edgeCount = buf.edgeCount || edgeCount;
@ -503,7 +611,7 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) {
}
lastX = p.x;
lastY = p.y;
const id = callScene("meshchatxVisualiserScenePick", p.x, p.y, 16);
const id = pickNodeAt(p.x, p.y, 16);
if (id) {
callScene("meshchatxVisualiserSceneDragStart", id);
pointerMode = "drag";
@ -525,27 +633,53 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) {
if (dist <= 0) return;
const factor = dist / pinchLastDist;
if (Math.abs(factor - 1) > 0.001) {
const mid = pointerMidpoint(pair.a, pair.b);
callScene("meshchatxVisualiserSceneZoomAt", mid.x, mid.y, factor);
if (isPlanet()) {
applyPlanetOrbit(orbitYaw, orbitPitch, orbitDist / factor);
} else {
const mid = pointerMidpoint(pair.a, pair.b);
callScene("meshchatxVisualiserSceneZoomAt", mid.x, mid.y, factor);
dirty = true;
}
pinchLastDist = dist;
dirty = true;
}
return;
}
if (pointerMode === "drag") {
callScene("meshchatxVisualiserSceneDragTo", p.x, p.y);
dirty = true;
if (isPlanet()) {
const css = renderer.getCssSize();
const eye = orbitEye(orbitYaw, orbitPitch, orbitDist);
const layout = pointerToLayout(p.x, p.y, css.width, css.height, eye, planetLayoutScale);
if (layout) {
const zoomBuf = callScene("meshchatxVisualiserSceneGetDrawBuffers");
const screen = layoutToWasmScreen(layout.x, layout.y, css.width, css.height, {
x: zoomBuf?.camX || 0,
y: zoomBuf?.camY || 0,
zoom: zoomBuf?.zoom > 0 ? zoomBuf.zoom : 1,
});
callScene("meshchatxVisualiserSceneDragTo", screen.x, screen.y);
dirty = true;
}
} else {
callScene("meshchatxVisualiserSceneDragTo", p.x, p.y);
dirty = true;
}
} else if (pointerMode === "pan") {
const zoomBuf = callScene("meshchatxVisualiserSceneGetDrawBuffers");
const zoom = zoomBuf?.zoom > 0 ? zoomBuf.zoom : 1;
const dx = (lastX - p.x) / zoom;
const dy = (lastY - p.y) / zoom;
callScene("meshchatxVisualiserScenePanBy", dx, dy);
lastX = p.x;
lastY = p.y;
dirty = true;
if (isPlanet()) {
applyPlanetOrbit(orbitYaw - (p.x - lastX) * 0.008, orbitPitch + (p.y - lastY) * 0.008, orbitDist);
lastX = p.x;
lastY = p.y;
} else {
const zoomBuf = callScene("meshchatxVisualiserSceneGetDrawBuffers");
const zoom = zoomBuf?.zoom > 0 ? zoomBuf.zoom : 1;
const dx = (lastX - p.x) / zoom;
const dy = (lastY - p.y) / zoom;
callScene("meshchatxVisualiserScenePanBy", dx, dy);
lastX = p.x;
lastY = p.y;
dirty = true;
}
} else {
const id = callScene("meshchatxVisualiserScenePick", p.x, p.y, 14) || null;
const id = pickNodeAt(p.x, p.y, 14);
if (id !== hoverId) {
hoverId = id;
dirty = true;
@ -589,7 +723,7 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) {
function onDblClick(ev) {
const p = cssPoint(ev);
const id = callScene("meshchatxVisualiserScenePick", p.x, p.y, 16);
const id = pickNodeAt(p.x, p.y, 16);
if (!id) return;
if (typeof hooks.onNodeActivate === "function") {
hooks.onNodeActivate(id, metaById.get(id) || null);
@ -598,8 +732,12 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) {
function onWheel(ev) {
ev.preventDefault();
const p = cssPoint(ev);
const factor = ev.deltaY < 0 ? 1.12 : 1 / 1.12;
if (isPlanet()) {
applyPlanetOrbit(orbitYaw, orbitPitch, orbitDist / factor);
return;
}
const p = cssPoint(ev);
callScene("meshchatxVisualiserSceneZoomAt", p.x, p.y, factor);
dirty = true;
}
@ -650,6 +788,7 @@ export function createVisualiserWebGLEngine(canvas, hooks = {}) {
getPositions,
getCounts,
setLiveLayout,
setViewMode,
destroy,
requestRedraw: () => {
dirty = true;

View file

@ -32,6 +32,11 @@ export const CORE_SETTINGS_SECTION_KEYWORDS = {
"mesh",
"visualiser.show_disabled_interfaces",
"visualiser.show_discovered_interfaces",
"visualiser.renderer_title",
"visualiser.view_mode",
"visualiser.view_mode_planet",
"planet",
"globe",
"offline",
"discovered",
],

View file

@ -7,12 +7,15 @@ const KEY_DISCOVERED = "meshchatx.visualiser.showDiscoveredInterfaces";
const KEY_LIVE_LAYOUT = "meshchatx.visualiser.enablePhysics";
const KEY_AUTO_RELOAD = "meshchatx.visualiser.autoReload";
const KEY_RENDERER = "meshchatx.visualiser.renderer";
const KEY_VIEW_MODE = "meshchatx.visualiser.viewMode";
export const VISUALISER_DISPLAY_PREFS_CHANGED = "visualiser-display-prefs-changed";
/** @typedef {"auto" | "webgl" | "vis"} VisualiserRendererPref */
/** @typedef {"flat" | "planet"} VisualiserViewModePref */
export const VISUALISER_RENDERER_OPTIONS = ["auto", "webgl", "vis"];
export const VISUALISER_VIEW_MODE_OPTIONS = ["flat", "planet"];
/**
* @param {string} key
@ -58,6 +61,28 @@ export function normalizeVisualiserRenderer(raw) {
return "auto";
}
/**
* @param {unknown} raw
* @returns {VisualiserViewModePref}
*/
export function normalizeVisualiserViewMode(raw) {
return raw === "planet" ? "planet" : "flat";
}
/**
* @returns {VisualiserViewModePref}
*/
function readViewMode() {
try {
if (typeof localStorage === "undefined") {
return "flat";
}
return normalizeVisualiserViewMode(localStorage.getItem(KEY_VIEW_MODE));
} catch {
return "flat";
}
}
/**
* @returns {VisualiserRendererPref}
*/
@ -79,6 +104,7 @@ function readRenderer() {
* enablePhysics: boolean,
* autoReload: boolean,
* renderer: VisualiserRendererPref,
* viewMode: VisualiserViewModePref,
* }}
*/
export function loadVisualiserDisplayPrefs() {
@ -89,6 +115,7 @@ export function loadVisualiserDisplayPrefs() {
enablePhysics: readBool(KEY_LIVE_LAYOUT, true),
autoReload: readBool(KEY_AUTO_RELOAD, false),
renderer: readRenderer(),
viewMode: readViewMode(),
};
}
@ -147,3 +174,21 @@ export function persistVisualiserRenderer(val, opts = {}) {
GlobalEmitter.emit(VISUALISER_DISPLAY_PREFS_CHANGED);
}
}
/**
* @param {unknown} val
* @param {{ emit?: boolean }} [opts]
*/
export function persistVisualiserViewMode(val, opts = {}) {
const next = normalizeVisualiserViewMode(val);
try {
if (typeof localStorage !== "undefined") {
localStorage.setItem(KEY_VIEW_MODE, next);
}
} catch {
/* ignore */
}
if (opts.emit !== false) {
GlobalEmitter.emit(VISUALISER_DISPLAY_PREFS_CHANGED);
}
}

View file

@ -3877,6 +3877,12 @@
"fps": "FPS",
"auto_update": "Auto-Update",
"live_layout": "Live-Layout",
"view_mode": "Ansicht",
"view_mode_flat": "Flach",
"view_mode_planet": "Planet",
"view_mode_flat_full": "Flach (2D)",
"view_mode_planet_full": "Planet (3D-Globus)",
"view_mode_desc": "Planet legt das Netz auf einen Globus. Ziehen dreht, Scrollen zoomt. Nur WebGL.",
"nodes": "Knoten",
"links": "Links",
"interfaces": "Schnittstellen",

View file

@ -2257,6 +2257,12 @@
"fps": "FPS",
"auto_update": "Auto Update",
"live_layout": "Live Layout",
"view_mode": "View",
"view_mode_flat": "Flat",
"view_mode_planet": "Planet",
"view_mode_flat_full": "Flat (2D)",
"view_mode_planet_full": "Planet (3D globe)",
"view_mode_desc": "Planet wraps the mesh onto a globe. Drag to orbit, scroll to zoom. WebGL only.",
"nodes": "Nodes",
"links": "Links",
"interfaces": "Interfaces",

View file

@ -2246,6 +2246,12 @@
"fps": "FPS",
"auto_update": "Actualizacion auto",
"live_layout": "Diseno en vivo",
"view_mode": "Vista",
"view_mode_flat": "Plano",
"view_mode_planet": "Planeta",
"view_mode_flat_full": "Plano (2D)",
"view_mode_planet_full": "Planeta (globo 3D)",
"view_mode_desc": "Planeta envuelve la malla en un globo. Arrastra para orbitar, rueda para zoom. Solo WebGL.",
"nodes": "Nodos",
"links": "Enlaces",
"interfaces": "Interfaces",

View file

@ -2246,6 +2246,12 @@
"fps": "FPS",
"auto_update": "Automaattipäivitys",
"live_layout": "Elävä asettelu",
"view_mode": "Näkymä",
"view_mode_flat": "Tasainen",
"view_mode_planet": "Planeetta",
"view_mode_flat_full": "Tasainen (2D)",
"view_mode_planet_full": "Planeetta (3D-pallo)",
"view_mode_desc": "Planeetta käärii verkon pallolle. Vedä kiertääksesi, rullaa zoomataksesi. Vain WebGL.",
"nodes": "Solmut",
"links": "Linkit",
"interfaces": "Liitännät",

View file

@ -2246,6 +2246,12 @@
"fps": "FPS",
"auto_update": "MAJ auto",
"live_layout": "Disposition live",
"view_mode": "Vue",
"view_mode_flat": "Plat",
"view_mode_planet": "Planète",
"view_mode_flat_full": "Plat (2D)",
"view_mode_planet_full": "Planète (globe 3D)",
"view_mode_desc": "Planète enroule le maillage sur un globe. Glisser oriente, molette zoome. WebGL uniquement.",
"nodes": "Noeuds",
"links": "Liens",
"interfaces": "Interfaces",

View file

@ -2299,6 +2299,12 @@
"fps": "FPS",
"auto_update": "Aggiornamento auto",
"live_layout": "Layout live",
"view_mode": "Vista",
"view_mode_flat": "Piatto",
"view_mode_planet": "Pianeta",
"view_mode_flat_full": "Piatto (2D)",
"view_mode_planet_full": "Pianeta (globo 3D)",
"view_mode_desc": "Pianeta avvolge la mesh su un globo. Trascina per orbitare, rotella per zoom. Solo WebGL.",
"nodes": "Nodi",
"links": "Collegamenti",
"interfaces": "Interfacce",

View file

@ -2246,6 +2246,12 @@
"fps": "FPS",
"auto_update": "Auto-update",
"live_layout": "Live-layout",
"view_mode": "Weergave",
"view_mode_flat": "Plat",
"view_mode_planet": "Planeet",
"view_mode_flat_full": "Plat (2D)",
"view_mode_planet_full": "Planeet (3D-globe)",
"view_mode_desc": "Planeet wikkelt het mesh op een globe. Sleep om te draaien, scroll om te zoomen. Alleen WebGL.",
"nodes": "Knooppunten",
"links": "Links",
"interfaces": "Interfaces",

View file

@ -3877,6 +3877,12 @@
"fps": "FPS",
"auto_update": "Автообновление",
"live_layout": "Живая раскладка",
"view_mode": "Вид",
"view_mode_flat": "Плоский",
"view_mode_planet": "Планета",
"view_mode_flat_full": "Плоский (2D)",
"view_mode_planet_full": "Планета (3D-глобус)",
"view_mode_desc": "Планета оборачивает сеть на глобус. Перетаскивание вращает, колесо зумит. Только WebGL.",
"nodes": "Узлы",
"links": "Связи",
"interfaces": "Интерфейсы",

View file

@ -2246,6 +2246,12 @@
"fps": "帧率",
"auto_update": "自动更新",
"live_layout": "实时布局",
"view_mode": "视图",
"view_mode_flat": "平面",
"view_mode_planet": "行星",
"view_mode_flat_full": "平面 (2D)",
"view_mode_planet_full": "行星 (3D 地球)",
"view_mode_desc": "行星视图把网格铺到球面上。拖动环绕,滚轮缩放。仅 WebGL。",
"nodes": "节点",
"links": "链路",
"interfaces": "接口",

View file

@ -1,6 +1,6 @@
{
"version": "1.2.0",
"wasm": "sha384-k7EQZOGUNE4HS1CxHvYLnhLWb4nsg6akEILUEMF+s3hHGKqYE1YFIZ/lkIxsRn2B",
"wasm": "sha384-HA4B8bhbYqbb9SOM0TpA7ggJmcf6NkVYhiflWmV1MCnwHfzZ59YFE45uI//aPTq3",
"wasmExec": "sha384-PWCs+V4BDf9yY1yjkD/p+9xNEs4iEbuvq+HezAOJiY3XL5GI6VyJXMsvnjiwNbce",
"wasmExecSource": "/usr/lib/go/lib/wasm/wasm_exec.js"
}

View file

@ -66,7 +66,7 @@ test.describe("Keyboard shortcuts (global)", () => {
const el = ul && ul.closest(".fixed");
return el ? el.className : "";
});
expect(classAfterCollapse).toContain("w-16");
expect(classAfterCollapse).toContain("w-14");
await page.keyboard.press("Control+b");
const classAfterExpand = await page.evaluate(() => {

View file

@ -12,7 +12,7 @@ test.describe("Shell: sidebar, theme, call, search", () => {
const toggleButton = sidebar.locator("div.hidden.sm\\:flex button").first();
await expect(sidebar).toHaveClass(/w-80/);
await toggleButton.click();
await expect(sidebar).toHaveClass(/w-16/);
await expect(sidebar).toHaveClass(/w-14/);
await toggleButton.click();
await expect(sidebar).toHaveClass(/w-80/);
});

View file

@ -645,4 +645,23 @@ describe("NetworkVisualiser.vue", () => {
expect(processSpy).toHaveBeenCalledTimes(1);
expect(processSpy).toHaveBeenCalledWith({ silent: true });
});
it("clears identity-scoped graph state on identity switch", async () => {
vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {});
const wrapper = mountVisualiser();
wrapper.vm.update = vi.fn().mockResolvedValue();
wrapper.vm.cachedPositions = { oldpeer: { x: 9, y: 8 } };
wrapper.vm.pathTable = [{ hash: "oldpeer" }];
wrapper.vm.announces = { oldpeer: { destination_hash: "oldpeer" } };
wrapper.vm.config = { identity_hash: "aaaa" };
wrapper.vm.webglEngine = { setGraph: vi.fn(), destroy: vi.fn() };
wrapper.vm.onIdentitySwitched();
expect(wrapper.vm.cachedPositions).toEqual({});
expect(wrapper.vm.pathTable).toEqual([]);
expect(wrapper.vm.announces).toEqual({});
expect(wrapper.vm.config).toBeNull();
expect(wrapper.vm.webglEngine.setGraph).toHaveBeenCalledWith([], []);
expect(wrapper.vm.update).toHaveBeenCalledWith({ silent: false });
wrapper.unmount();
});
});

View file

@ -111,4 +111,27 @@ describe("NetworkVisualiserToolbar", () => {
const manualIcons = manualBusy.findAll(".mdi-stub").map((n) => n.attributes("data-icon"));
expect(manualIcons).toContain("loading");
});
it("hides planet view buttons unless the engine is WebGL", () => {
const wasm = mountToolbar({ engineMode: "wasm" });
expect(wasm.find("#visualiser-view-planet").exists()).toBe(false);
expect(wasm.find("#visualiser-view-flat").exists()).toBe(false);
wasm.unmount();
const vis = mountToolbar({ engineMode: "fallback" });
expect(vis.find("#visualiser-view-planet").exists()).toBe(false);
vis.unmount();
const webgl = mountToolbar({ engineMode: "webgl", viewMode: "flat" });
expect(webgl.find("#visualiser-view-flat").exists()).toBe(true);
expect(webgl.find("#visualiser-view-planet").exists()).toBe(true);
expect(webgl.find("#visualiser-view-flat").attributes("aria-pressed")).toBe("true");
expect(webgl.find("#visualiser-view-planet").attributes("aria-pressed")).toBe("false");
});
it("emits planet view mode from the WebGL toolbar", async () => {
const w = mountToolbar({ engineMode: "webgl", viewMode: "flat" });
await w.find("#visualiser-view-planet").trigger("click");
expect(w.emitted("update:viewMode")?.[0]).toEqual(["planet"]);
});
});

View file

@ -399,9 +399,16 @@ describe("behavior contracts: network visualiser performance", () => {
expect(webgl).not.toContain("zoom >= 0.45");
const prefs = readSource("meshchatx/src/frontend/js/settings/settingsVisualiserPrefs.js");
expect(prefs).toContain("persistVisualiserRenderer");
expect(prefs).toContain("persistVisualiserViewMode");
expect(prefs).toContain('"auto"');
expect(prefs).toContain('"webgl"');
expect(prefs).toContain('"vis"');
expect(prefs).toContain('"planet"');
const planet = readSource("meshchatx/src/frontend/js/networkVisualiserPlanet.js");
expect(planet).toContain("projectPlanetScene");
expect(planet).toContain("layoutToSphere");
expect(engine).toContain("setViewMode");
expect(engine).toContain("projectPlanetScene");
});
});

View file

@ -170,4 +170,13 @@ describe("identity-switch surface contracts", () => {
expect(src).toContain('GlobalEmitter.on("identity-switched"');
expect(src).toMatch(/this\.tabs\s*=\s*\[\]/);
});
it("NetworkVisualiser listens for identity-switched and clears cached graph state", () => {
const src = readFrontend("components/network-visualiser/NetworkVisualiser.vue");
expect(src).toContain('GlobalEmitter.on("identity-switched"');
expect(src).toMatch(/onIdentitySwitched/);
expect(src).toMatch(/this\.cachedPositions\s*=\s*\{\}/);
expect(src).toMatch(/this\.pathTable\s*=\s*\[\]/);
expect(src).toMatch(/this\.announces\s*=\s*\{\}/);
});
});

View file

@ -10,6 +10,7 @@ import {
lodLevelFromScale,
pathHashesWithinHopFilter,
pathHashesWithinHopFilterJs,
layoutSpringLength,
pickAdaptiveFetchConcurrency,
} from "@/js/networkVisualiserPerf.js";
@ -28,6 +29,8 @@ describe("networkVisualiserPerf", () => {
it("exports visualiser constants", () => {
expect(VIZ_ANNOUNCE_ASPECTS).toEqual(["lxmf.delivery", "nomadnetwork.node"]);
expect(ANNOUNCE_HASH_CHUNK_SIZE).toBe(500);
expect(layoutSpringLength(3)).toBe(200);
expect(layoutSpringLength(1)).toBe(240);
});
it("pathHashesWithinHopFilter respects hop max", () => {
@ -88,7 +91,7 @@ describe("networkVisualiserPerf", () => {
expect(res.edges[0].width).toBe(2.5);
const dx = res.nodes[0].x - 10;
const dy = res.nodes[0].y - 20;
expect(Math.hypot(dx, dy)).toBeGreaterThanOrEqual(240);
expect(Math.hypot(dx, dy)).toBeGreaterThanOrEqual(140);
expect(buildPathGraph({ path_table: [], announces: {} }).nodes).toEqual([]);
});

View file

@ -0,0 +1,160 @@
import { describe, it, expect } from "vitest";
import {
FLAT_VIEW,
PLANET_VIEW,
DEFAULT_ORBIT_DIST,
LAYOUT_SCALE_FLOOR,
clampOrbit,
computeLayoutScale,
layoutToSphere,
sphereToLayout,
orbitEye,
planetLodZoom,
pickPlanetNode,
pointerToLayout,
projectPlanetScene,
raySphere,
normalizeVisualiserViewMode,
} from "@/js/networkVisualiserPlanet.js";
import { NODE_STRIDE as DRAW_STRIDE } from "@/js/networkVisualiserWebGL.js";
describe("networkVisualiserPlanet", () => {
it("normalizes view mode to flat or planet", () => {
expect(normalizeVisualiserViewMode("planet")).toBe(PLANET_VIEW);
expect(normalizeVisualiserViewMode("flat")).toBe(FLAT_VIEW);
expect(normalizeVisualiserViewMode("nope")).toBe(FLAT_VIEW);
expect(normalizeVisualiserViewMode(null)).toBe(FLAT_VIEW);
});
it("maps the origin to the front of the globe and inverts", () => {
const p = layoutToSphere(0, 0, 400);
expect(p.x).toBeCloseTo(0, 5);
expect(p.y).toBeCloseTo(0, 5);
expect(p.z).toBeCloseTo(1, 5);
const back = layoutToSphere(400, 0, 400);
expect(back.z).toBeCloseTo(-1, 5);
const round = sphereToLayout(p.x, p.y, p.z, 400);
expect(round.x).toBeCloseTo(0, 5);
expect(round.y).toBeCloseTo(0, 5);
const q = layoutToSphere(120, -40, 400);
const inv = sphereToLayout(q.x, q.y, q.z, 400);
expect(inv.x).toBeCloseTo(120, 4);
expect(inv.y).toBeCloseTo(-40, 4);
});
it("keeps layout scale at least the floor and grows with spread", () => {
expect(computeLayoutScale(new Float32Array(DRAW_STRIDE), DRAW_STRIDE)).toBe(LAYOUT_SCALE_FLOOR);
const nodes = new Float32Array(DRAW_STRIDE * 2);
nodes[0] = 0;
nodes[1] = 0;
nodes[DRAW_STRIDE] = 1000;
nodes[DRAW_STRIDE + 1] = 0;
expect(computeLayoutScale(nodes, DRAW_STRIDE)).toBeGreaterThan(LAYOUT_SCALE_FLOOR);
expect(1000 / computeLayoutScale(nodes, DRAW_STRIDE)).toBeLessThan(1);
});
it("keeps layout points on the front-to-back hemisphere", () => {
const scale = 400;
const overshoot = layoutToSphere(scale * 2.5, 0, scale);
expect(overshoot.phi).toBeLessThanOrEqual(Math.PI);
expect(overshoot.z).toBeCloseTo(-1, 5);
const atRim = layoutToSphere(scale, 0, scale);
expect(atRim.z).toBeCloseTo(-1, 5);
const mid = layoutToSphere(scale * 0.5, 0, scale);
expect(mid.phi).toBeCloseTo(Math.PI * 0.5, 5);
expect(mid.z).toBeCloseTo(0, 5);
});
it("clamps orbit pitch and distance", () => {
const c = clampOrbit(1, 9, 0.2);
expect(c.pitch).toBeLessThan(1.2);
expect(c.dist).toBeGreaterThan(1.5);
const far = clampOrbit(0, 0, 99);
expect(far.dist).toBeLessThan(9);
const bad = clampOrbit(Number.NaN, Number.NaN, Number.NaN);
expect(Number.isFinite(bad.yaw)).toBe(true);
expect(Number.isFinite(bad.pitch)).toBe(true);
expect(Number.isFinite(bad.dist)).toBe(true);
});
it("places the default eye in front of the origin", () => {
const eye = orbitEye(0, 0, DEFAULT_ORBIT_DIST);
expect(eye.x).toBeCloseTo(0, 5);
expect(eye.y).toBeCloseTo(0, 5);
expect(eye.z).toBeCloseTo(DEFAULT_ORBIT_DIST, 5);
});
it("hits the unit sphere from the default camera", () => {
const eye = orbitEye(0, 0, DEFAULT_ORBIT_DIST);
const hit = raySphere(eye, { x: 0, y: 0, z: -1 }, 1);
expect(hit).not.toBeNull();
expect(hit.z).toBeCloseTo(1, 4);
});
it("maps a center click back to the layout origin", () => {
const eye = orbitEye(0, 0, DEFAULT_ORBIT_DIST);
const got = pointerToLayout(400, 300, 800, 600, eye, 400);
expect(got).not.toBeNull();
expect(Math.hypot(got.x, got.y)).toBeLessThan(8);
});
it("projects me onto the globe disc and keeps a pick target", () => {
const nodes = new Float32Array(DRAW_STRIDE);
nodes[0] = 0;
nodes[1] = 0;
nodes[2] = 32;
nodes[3] = 0.2;
nodes[4] = 0.5;
nodes[6] = 1;
const edges = new Float32Array(0);
const out = projectPlanetScene({
nodes,
edges,
width: 800,
height: 600,
yaw: 0,
pitch: 0,
dist: DEFAULT_ORBIT_DIST,
dark: true,
idByIndex: ["me"],
});
expect(out.nodes.length).toBe(DRAW_STRIDE * 2);
expect(out.edges.length).toBeGreaterThan(0);
expect(out.pick.some((p) => p.id === "me")).toBe(true);
expect(pickPlanetNode(out.pick, out.pick[0].sx, out.pick[0].sy, 20)).toBe("me");
expect(pickPlanetNode(out.pick, -400, -400, 8)).toBeNull();
});
it("draws back-facing nodes before front-facing nodes", () => {
const nodes = new Float32Array(DRAW_STRIDE * 2);
nodes[0] = 0;
nodes[1] = 0;
nodes[2] = 24;
nodes[6] = 1;
nodes[DRAW_STRIDE] = 400;
nodes[DRAW_STRIDE + 1] = 0;
nodes[DRAW_STRIDE + 2] = 24;
nodes[DRAW_STRIDE + 6] = 1;
const out = projectPlanetScene({
nodes,
edges: new Float32Array(0),
width: 800,
height: 600,
yaw: 0,
pitch: 0,
dist: DEFAULT_ORBIT_DIST,
dark: true,
idByIndex: ["me", "peer"],
});
const frontAlpha = out.nodes[DRAW_STRIDE * 2 + 6];
const backAlpha = out.nodes[DRAW_STRIDE + 6];
expect(backAlpha).toBeLessThan(frontAlpha);
expect(out.pick.some((p) => p.id === "me")).toBe(true);
expect(out.pick.some((p) => p.id === "peer")).toBe(false);
});
it("maps closer orbit to a higher LOD zoom", () => {
expect(planetLodZoom(DEFAULT_ORBIT_DIST)).toBeCloseTo(1, 5);
expect(planetLodZoom(DEFAULT_ORBIT_DIST * 2)).toBeCloseTo(0.5, 5);
});
});

View file

@ -461,6 +461,19 @@ describe("createVisualiserWebGLEngine interactions", () => {
expect(globalThis.meshchatxVisualiserScenePanBy).not.toHaveBeenCalled();
});
it("planet pinch dollies orbit instead of SceneZoomAt", () => {
engine.setViewMode("planet");
const fire = (type, props) => {
const ev = new Event(type, { bubbles: true });
Object.assign(ev, props);
canvas.dispatchEvent(ev);
};
fire("pointerdown", { pointerId: 1, pointerType: "touch", button: 0, clientX: 110, clientY: 120 });
fire("pointerdown", { pointerId: 2, pointerType: "touch", button: 0, clientX: 210, clientY: 120 });
fire("pointermove", { pointerId: 2, pointerType: "touch", button: 0, clientX: 250, clientY: 120 });
expect(zoomAt).not.toHaveBeenCalled();
});
it("wheel zoom calls SceneZoomAt", () => {
const ev = new Event("wheel", { bubbles: true, cancelable: true });
Object.assign(ev, { clientX: 60, clientY: 80, deltaY: -100 });
@ -469,6 +482,14 @@ describe("createVisualiserWebGLEngine interactions", () => {
expect(zoomAt).toHaveBeenCalledWith(50, 60, 1.12);
});
it("planet wheel does not call SceneZoomAt", () => {
engine.setViewMode("planet");
const ev = new Event("wheel", { bubbles: true, cancelable: true });
Object.assign(ev, { clientX: 60, clientY: 80, deltaY: -100 });
canvas.dispatchEvent(ev);
expect(zoomAt).not.toHaveBeenCalled();
});
it("clears light background when WASM draw buffers are not ready", async () => {
engine.destroy();
engine = null;

View file

@ -251,11 +251,12 @@ describe("BatterySettingsSection", () => {
});
describe("VisualiserSettingsSection", () => {
it("emits renderer and visibility changes", async () => {
it("emits renderer, view mode, and visibility changes", async () => {
const wrapper = mount(VisualiserSettingsSection, {
props: {
visible: true,
renderer: "auto",
viewMode: "flat",
showDisabledInterfaces: false,
showDiscoveredInterfaces: true,
},
@ -264,8 +265,10 @@ describe("VisualiserSettingsSection", () => {
},
});
expect(wrapper.text()).toContain("visualiser.title");
await wrapper.find("select").setValue("webgl");
await wrapper.find("#settings-visualiser-renderer").setValue("webgl");
expect(wrapper.emitted("renderer-change")?.at(-1)).toEqual(["webgl"]);
await wrapper.find("#settings-visualiser-view-mode").setValue("planet");
expect(wrapper.emitted("view-mode-change")?.at(-1)).toEqual(["planet"]);
const toggles = wrapper.findAllComponents({ name: "Toggle" });
await toggles[0].vm.$emit("update:modelValue", true);
expect(wrapper.emitted("show-disabled-change")).toEqual([[true]]);

View file

@ -6,7 +6,9 @@ import {
persistVisualiserShowDisabled,
persistVisualiserShowDiscovered,
persistVisualiserRenderer,
persistVisualiserViewMode,
normalizeVisualiserRenderer,
normalizeVisualiserViewMode,
} from "@/js/settings/settingsVisualiserPrefs.js";
describe("settingsVisualiserPrefs", () => {
@ -14,13 +16,14 @@ describe("settingsVisualiserPrefs", () => {
localStorage.clear();
});
it("defaults live layout on, auto-reload off, renderer auto", () => {
it("defaults live layout on, auto-reload off, renderer auto, view flat", () => {
expect(loadVisualiserDisplayPrefs()).toEqual({
showDisabledInterfaces: false,
showDiscoveredInterfaces: false,
enablePhysics: true,
autoReload: false,
renderer: "auto",
viewMode: "flat",
});
});
@ -35,6 +38,7 @@ describe("settingsVisualiserPrefs", () => {
enablePhysics: false,
autoReload: true,
renderer: "auto",
viewMode: "flat",
});
persistVisualiserLiveLayout(true);
expect(loadVisualiserDisplayPrefs().enablePhysics).toBe(true);
@ -54,4 +58,12 @@ describe("settingsVisualiserPrefs", () => {
persistVisualiserRenderer("vis", { emit: false });
expect(loadVisualiserDisplayPrefs().renderer).toBe("vis");
});
it("normalizes and persists planet view mode", () => {
expect(normalizeVisualiserViewMode("nope")).toBe("flat");
persistVisualiserViewMode("planet");
expect(loadVisualiserDisplayPrefs().viewMode).toBe("planet");
persistVisualiserViewMode("flat", { emit: false });
expect(loadVisualiserDisplayPrefs().viewMode).toBe("flat");
});
});

View file

@ -52,6 +52,20 @@ describe("vite-dx Vue DevTools gate", () => {
expect(vite).toContain("clearScreen: false");
});
it("Docker frontend stages copy every vite.config.js scripts/ import", () => {
const vite = readFileSync(resolve(ROOT, "vite.config.js"), "utf8");
const imports = [...vite.matchAll(/from\s+"(\.\/scripts\/[^"]+)"/g)].map((m) => m[1].replace(/^\.\//, ""));
expect(imports.length).toBeGreaterThan(0);
expect(imports).toContain("scripts/vite-dx.mjs");
for (const dockerfile of ["Dockerfile", "Dockerfile.hardened"]) {
const body = readFileSync(resolve(ROOT, dockerfile), "utf8");
const frontendStage = body.split(/^FROM /m)[1] || "";
for (const rel of imports) {
expect(frontendStage, `${dockerfile} missing COPY ${rel}`).toContain(`COPY ${rel} ${rel}`);
}
}
});
it("e2e Vite stack disables Vue DevTools", () => {
const e2e = readFileSync(resolve(ROOT, "scripts/e2e/start-e2e-stack.sh"), "utf8");
expect(e2e).toContain("MESHCHAT_VUE_DEVTOOLS=0");

View file

@ -178,8 +178,7 @@ func sceneTickHandler(_ js.Value, args []js.Value) any {
if len(args) > 0 && args[0].Type() == js.TypeNumber {
steps = int(args[0].Int())
}
visualiserScene.Tick(steps)
return nil
return visualiserScene.Tick(steps)
}
func sceneResizeHandler(_ js.Value, args []js.Value) any {

View file

@ -244,11 +244,11 @@ func resolvePosition(hash, iface string, pos map[string]XY) (float64, float64) {
return prev.X, prev.Y
}
if ip, ok := pos[iface]; ok {
x, y := hashpos.Around(hash, ip.X, ip.Y, 240, 220)
x, y := hashpos.Around(hash, ip.X, ip.Y, 140, 90)
pos[hash] = XY{X: x, Y: y}
return x, y
}
x, y := hashpos.XY(hash, 720, 280)
x, y := hashpos.XY(hash, 400, 160)
pos[hash] = XY{X: x, Y: y}
return x, y
}

View file

@ -95,7 +95,7 @@ func TestBuildPathGraphSeedsPeersAwayFromInterface(t *testing.T) {
dx := res.Nodes[0].X - 100
dy := res.Nodes[0].Y - 200
dist := dx*dx + dy*dy
if dist < 240*240 {
if dist < 140*140 {
t.Fatalf("peer seeded too close to interface: (%v,%v)", res.Nodes[0].X, res.Nodes[0].Y)
}
}

View file

@ -144,7 +144,7 @@ func BuildFullGraph(req FullRequest) FullResult {
addNode(me, 4, true)
}
radius := 520.0
radius := 210.0
ifaceN := len(req.Interfaces)
for j, entry := range req.Interfaces {
if !filter.MatchesSearch(searchLower, entry.Label) && !filter.MatchesSearch(searchLower, entry.Name) {
@ -228,7 +228,7 @@ func BuildFullGraph(req FullRequest) FullResult {
if !filter.MatchesSearch(searchLower, disc.Label) {
continue
}
x, y := hashpos.XY(disc.ID, 900, 280)
x, y := hashpos.XY(disc.ID, 480, 160)
p := resolveOr(pos, disc.ID, x, y)
node := NodeOut{
ID: disc.ID,

View file

@ -11,25 +11,37 @@ import (
const (
// DefaultRepulsion is 1/r^2 strength when Request.Repulsion is 0.
DefaultRepulsion = 5200.0
// vis-network barnesHut uses gravitationalConstant -3500. Keep this near
// that scale so WASM settle does not inflate the graph.
DefaultRepulsion = 1800.0
// DefaultSpringK is hooke stiffness when Request.SpringK is 0.
DefaultSpringK = 0.028
DefaultSpringK = 0.032
// DefaultSpringLen is rest length when an edge omits Length.
DefaultSpringLen = 500.0
// vis-network barnesHut springLength is 200. Peers sit a bit further out.
DefaultSpringLen = 240.0
// DefaultHubSpringLen is rest length for thick hub edges (me to interface).
DefaultHubSpringLen = 440.0
DefaultHubSpringLen = 200.0
// DefaultCellSize is the repulsion grid bucket in world units.
DefaultCellSize = 400.0
DefaultCellSize = 180.0
// DefaultMinSep is used when a body has no Radius.
DefaultMinSep = 96.0
DefaultMinSep = 48.0
// CollisionPad is extra gap beyond the two node radii.
CollisionPad = 48.0
CollisionPad = 16.0
// CollisionK is extra push when two discs overlap the min gap.
CollisionK = 3.0
// LiveRepulsion is WebGL live-tick repulsion (softer springs, same spacing).
LiveRepulsion = 5600.0
// Keep this below 1 so stacked nodes unstick without launching.
CollisionK = 0.85
// LiveRepulsion is WebGL live-tick repulsion.
LiveRepulsion = 1800.0
// LiveSpringK is WebGL live-tick spring stiffness.
LiveSpringK = 0.012
LiveSpringK = 0.016
// LiveDamping is WebGL live-tick velocity keep fraction.
LiveDamping = 0.78
// LiveMaxSpeed caps WebGL live-tick motion per step.
LiveMaxSpeed = 4.0
// LiveRestSpeed zeros live velocity below this length.
LiveRestSpeed = 0.25
// LiveSleepShift is the max per-tick move that still counts as rest.
LiveSleepShift = 0.15
)
// SpringLength returns rest length from vis-style edge width.

View file

@ -103,6 +103,35 @@ func TestSettlePreservesAndUpdatesVelocity(t *testing.T) {
}
}
func TestSettleStaysCompact(t *testing.T) {
res := layout.Settle(layout.Request{
Nodes: []layout.Node{
{ID: "me", X: 0, Y: 0, Mass: 4, Fixed: true, Radius: 32},
{ID: "iface", X: 210, Y: 0, Mass: 2.5, Radius: 24},
{ID: "a", X: 350, Y: 20, Mass: 1, Radius: 22},
{ID: "b", X: 350, Y: -20, Mass: 1, Radius: 22},
},
Edges: []layout.Edge{
{From: "me", To: "iface", Length: layout.DefaultHubSpringLen},
{From: "iface", To: "a", Length: layout.DefaultSpringLen},
{From: "iface", To: "b", Length: layout.DefaultSpringLen},
},
Iterations: 140,
})
iface := res.Positions["iface"]
hubDist := math.Hypot(iface.X, iface.Y)
if hubDist < 80 || hubDist > 360 {
t.Fatalf("interface should stay near hub rest length, got dist=%v pos=%#v", hubDist, iface)
}
for _, id := range []string{"a", "b"} {
p := res.Positions[id]
d := math.Hypot(p.X-iface.X, p.Y-iface.Y)
if d > 420 {
t.Fatalf("%s exploded away from interface: dist=%v p=%#v iface=%#v", id, d, p, iface)
}
}
}
func BenchmarkSettle500(b *testing.B) {
nodes := make([]layout.Node, 500)
edges := make([]layout.Edge, 0, 500)

View file

@ -82,6 +82,9 @@ type Scene struct {
camY float64
zoom float64
dragIdx int
// sleeping is true when live ticks have damped below LiveRestSpeed.
// Stops per-frame force jitter once the graph has settled.
sleeping bool
}
// New returns an empty scene centred on the origin.
@ -97,6 +100,9 @@ func New() *Scene {
// Set replaces nodes and edges. Preserves camera unless zoom is > 0 in req.
func (s *Scene) Set(req SetRequest) {
oldVx := s.vx
oldVy := s.vy
oldIndex := s.index
s.nodes = append([]Node(nil), req.Nodes...)
s.edges = append([]Edge(nil), req.Edges...)
s.index = make(map[string]int, len(s.nodes))
@ -132,8 +138,21 @@ func (s *Scene) Set(req SetRequest) {
s.camY = req.CamY
}
s.dragIdx = -1
s.sleeping = false
s.vx = make([]float64, len(s.nodes))
s.vy = make([]float64, len(s.nodes))
// Carry momentum for ids that survived the rebuild so auto-refresh
// does not kick a settling graph back into motion from v=0.
if oldIndex != nil && len(oldVx) == len(oldVy) {
for i := range s.nodes {
j, ok := oldIndex[s.nodes[i].ID]
if !ok || j < 0 || j >= len(oldVx) {
continue
}
s.vx[i] = oldVx[j]
s.vy[i] = oldVy[j]
}
}
}
func defaultSize(kind int) float64 {
@ -226,9 +245,13 @@ func (s *Scene) screenToWorld(sx, sy float64) (float64, float64) {
}
// Tick runs a few force iterations when live layout is on.
func (s *Scene) Tick(steps int) {
// Returns true when any unfixed node moved this call.
func (s *Scene) Tick(steps int) bool {
if len(s.nodes) == 0 {
return
return false
}
if s.sleeping && s.dragIdx < 0 {
return false
}
if steps <= 0 {
steps = 1
@ -276,25 +299,45 @@ func (s *Scene) Tick(steps int) {
Gravity: -1,
Repulsion: layout.LiveRepulsion,
SpringK: layout.LiveSpringK,
Damping: 0.58,
MaxSpeed: 6,
Damping: layout.LiveDamping,
MaxSpeed: layout.LiveMaxSpeed,
})
const restSpeed = 0.12
moved := false
maxSpeed := 0.0
maxShift := 0.0
for i := range s.nodes {
if s.dragIdx == i {
s.vx[i] = 0
s.vy[i] = 0
continue
}
dx := layoutNodes[i].X - s.nodes[i].X
dy := layoutNodes[i].Y - s.nodes[i].Y
s.nodes[i].X = layoutNodes[i].X
s.nodes[i].Y = layoutNodes[i].Y
s.vx[i] = layoutNodes[i].Vx
s.vy[i] = layoutNodes[i].Vy
if math.Hypot(s.vx[i], s.vy[i]) < restSpeed {
speed := math.Hypot(s.vx[i], s.vy[i])
if speed < layout.LiveRestSpeed {
s.vx[i] = 0
s.vy[i] = 0
speed = 0
}
if speed > maxSpeed {
maxSpeed = speed
}
shift := math.Hypot(dx, dy)
if shift > maxShift {
maxShift = shift
}
if shift > layout.LiveSleepShift {
moved = true
}
}
if s.dragIdx < 0 && maxSpeed < layout.LiveRestSpeed && maxShift < layout.LiveSleepShift {
s.sleeping = true
}
return moved
}
// PositionsMap returns id -> xy for caching.
@ -341,6 +384,7 @@ func (s *Scene) DragStart(id string) bool {
return false
}
s.dragIdx = i
s.sleeping = false
return true
}
@ -352,6 +396,7 @@ func (s *Scene) DragTo(screenX, screenY float64) {
wx, wy := s.screenToWorld(screenX, screenY)
s.nodes[s.dragIdx].X = wx
s.nodes[s.dragIdx].Y = wy
s.sleeping = false
}
// DragEnd clears the drag target.

View file

@ -5,6 +5,8 @@ package scene
import (
"math"
"testing"
"github.com/Quad4-Software/MeshChatX/visualiser-wasm/internal/layout"
)
func TestSetPackAndPick(t *testing.T) {
@ -109,8 +111,8 @@ func TestTickPersistsVelocityAndSettles(t *testing.T) {
Nodes: []Node{
{ID: "me", X: 0, Y: 0, Kind: KindMe, Fixed: true, Mass: 4},
// Start near hub spring rest length so live ticks should calm quickly.
{ID: "a", X: 440, Y: 0, Kind: KindPeer, Mass: 1, Size: 22},
{ID: "b", X: -440, Y: 0, Kind: KindPeer, Mass: 1, Size: 22},
{ID: "a", X: layout.DefaultHubSpringLen, Y: 0, Kind: KindPeer, Mass: 1, Size: 22},
{ID: "b", X: -layout.DefaultHubSpringLen, Y: 0, Kind: KindPeer, Mass: 1, Size: 22},
},
Edges: []Edge{
{From: "me", To: "a", Width: 3},
@ -156,3 +158,57 @@ func TestTickSeparatesStackedPeers(t *testing.T) {
t.Fatalf("stacked peers should spread, dist=%v a=(%v,%v) b=(%v,%v)", dist, s.nodes[1].X, s.nodes[1].Y, s.nodes[2].X, s.nodes[2].Y)
}
}
func TestTickSleepsWhenSettled(t *testing.T) {
s := New()
s.Set(SetRequest{
Nodes: []Node{
{ID: "me", X: 0, Y: 0, Kind: KindMe, Fixed: true, Mass: 4, Size: 32},
{ID: "a", X: layout.DefaultHubSpringLen, Y: 0, Kind: KindPeer, Mass: 1, Size: 22},
},
Edges: []Edge{{From: "me", To: "a", Width: 3}},
})
for i := 0; i < 120; i++ {
s.Tick(1)
}
x := s.nodes[1].X
y := s.nodes[1].Y
moved := s.Tick(1)
if moved {
t.Fatalf("settled live layout should sleep, still moving to (%v,%v) from (%v,%v)", s.nodes[1].X, s.nodes[1].Y, x, y)
}
if s.nodes[1].X != x || s.nodes[1].Y != y {
t.Fatalf("sleeping tick moved node from (%v,%v) to (%v,%v)", x, y, s.nodes[1].X, s.nodes[1].Y)
}
}
func TestTickDoesNotExplode(t *testing.T) {
s := New()
s.Set(SetRequest{
Nodes: []Node{
{ID: "me", X: 0, Y: 0, Kind: KindMe, Fixed: true, Mass: 4, Size: 32},
{ID: "iface", X: 210, Y: 0, Kind: KindIfaceOn, Mass: 2.5, Size: 24},
{ID: "a", X: 350, Y: 16, Kind: KindPeer, Mass: 1, Size: 22},
{ID: "b", X: 350, Y: -16, Kind: KindPeer, Mass: 1, Size: 22},
},
Edges: []Edge{
{From: "me", To: "iface", Width: 3},
{From: "iface", To: "a", Width: 1},
{From: "iface", To: "b", Width: 1},
},
})
for i := 0; i < 90; i++ {
s.Tick(1)
}
iface := s.nodes[1]
hubDist := math.Hypot(iface.X, iface.Y)
if hubDist > 360 {
t.Fatalf("live layout inflated hub distance to %v", hubDist)
}
for i := 2; i < 4; i++ {
d := math.Hypot(s.nodes[i].X-iface.X, s.nodes[i].Y-iface.Y)
if d > 420 {
t.Fatalf("live layout exploded peer %s dist=%v", s.nodes[i].ID, d)
}
}
}