mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
feat: visualiser improvements and 3D view and decrease collapased sidebar width a bit.
This commit is contained in:
parent
42496edcea
commit
14fd2bcfa8
42 changed files with 1511 additions and 82 deletions
BIN
meshchatx.rsm
BIN
meshchatx.rsm
Binary file not shown.
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
613
meshchatx/src/frontend/js/networkVisualiserPlanet.js
Normal file
613
meshchatx/src/frontend/js/networkVisualiserPlanet.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Интерфейсы",
|
||||
|
|
|
|||
|
|
@ -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": "接口",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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*\{\}/);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
|
||||
|
|
|
|||
160
tests/frontend/networkVisualiserPlanet.test.js
Normal file
160
tests/frontend/networkVisualiserPlanet.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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]]);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue