diff --git a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue index a56faf0..9b7fd13 100644 --- a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue +++ b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue @@ -54,6 +54,41 @@ import NetworkVisualiserLoadingOverlay from "./internal/NetworkVisualiserLoading import NetworkVisualiserToolbar from "./internal/NetworkVisualiserToolbar.vue"; import NetworkVisualiserLegend from "./internal/NetworkVisualiserLegend.vue"; +/* + * Yields control back to the browser so it can paint, dispatch input events, + * and run other tasks. Prefers the prioritized task scheduler when available + * (Chromium 94+ / Electron) and falls back to a zero-delay timer everywhere + * else. setTimeout(0) is intentionally used over Promise.resolve() because + * microtasks do not give the renderer a chance to repaint. + */ +function yieldToMain() { + if (typeof window !== "undefined" && window.scheduler) { + if (typeof window.scheduler.yield === "function") { + return window.scheduler.yield(); + } + if (typeof window.scheduler.postTask === "function") { + return new Promise((resolve) => { + window.scheduler.postTask(resolve, { priority: "user-blocking" }); + }); + } + } + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +/* + * Pick a visualisation chunk size that scales down on weak hardware. ARM SBCs + * commonly report 4 logical cores; phones/SoCs frequently report 2. We keep + * desktop throughput (larger chunks => fewer yields) but drop hard for low + * core-count devices so the main thread is not pinned for tens of ms per chunk. + */ +function pickAdaptiveChunkSize() { + const cores = (typeof navigator !== "undefined" && navigator.hardwareConcurrency) || 4; + if (cores <= 2) return 40; + if (cores <= 4) return 80; + if (cores <= 6) return 150; + return 250; +} + export default { name: "NetworkVisualiser", components: { @@ -110,6 +145,10 @@ export default { lastVizKeys: [], vizHadOneLayout: false, didDisableStabilization: false, + vizChunkSize: pickAdaptiveChunkSize(), + iconQueue: [], + iconQueueRunning: false, + iconQueueGeneration: 0, }; }, computed: { @@ -234,6 +273,8 @@ export default { if (this.abortController) { this.abortController.abort(); } + this.iconQueue = []; + this.iconQueueGeneration += 1; if (this._toggleOrbitHandler) { GlobalEmitter.off("toggle-orbit", this._toggleOrbitHandler); } @@ -1438,19 +1479,17 @@ export default { strokeWidth: 4, strokeColor: isDarkMode ? "rgba(9, 9, 11, 0.95)" : "rgba(255, 255, 255, 0.95)", }, - shadow: { - enabled: true, - color: "rgba(0,0,0,0.24)", - size: 10, - x: 0, - y: 4, - }, + // Canvas shadows are by far the most expensive per-node + // operation in vis-network. Disable globally; the borders + // and circular-image rendering remain visually distinct. + shadow: false, }, edges: { - smooth: { - type: "continuous", - roundness: 0.5, - }, + // "continuous" computes bezier curves on every frame and + // is noticeably heavier than straight edges on slow ARM + // CPUs once you have a few hundred edges. Straight edges + // still look clean against the dotted background. + smooth: false, selectionWidth: 3, hoverWidth: 2, color: { @@ -1638,6 +1677,27 @@ export default { this.loadingStatus = "Processing visualization..."; + /* + * Invalidate any in-flight icon-generation work. Each call to + * processVisualization gets a new generation token; queued items + * carrying an older token are dropped when consumed so we do not + * paint canvases for nodes that no longer exist. + */ + this.iconQueueGeneration += 1; + this.iconQueue = []; + + /* + * Pause physics for the duration of the bulk update. Running the + * force-directed solver between chunks just churns the layout for + * a partial graph and pegs the main thread on slow CPUs. We + * restore the user's physics preference at the end so the final + * layout still settles naturally. + */ + const physicsWasOn = this.network && this.enablePhysics; + if (physicsWasOn) { + this.network.setOptions({ physics: { enabled: false } }); + } + const processedNodeIds = new Set(); const processedEdgeIds = new Set(); @@ -1850,8 +1910,15 @@ export default { const aspectsToShow = ["lxmf.delivery", "nomadnetwork.node"]; - // Process in larger chunks for speed, but keep UI responsive - const chunkSize = 250; + /* + * Chunk size is adaptive to hardwareConcurrency. Smaller chunks + * on weak hardware mean more frequent yields, which keeps the + * loading overlay animating and keeps input responsive at the + * cost of slightly higher total work due to extra event-loop + * round-trips. The trade-off massively favours smoothness on + * ARM SBCs. + */ + const chunkSize = this.vizChunkSize; this.totalBatches = Math.ceil(this.pathTable.length / chunkSize); this.currentBatch = 0; @@ -1946,14 +2013,30 @@ export default { if (this.iconCache[cacheKey]) { node.image = this.iconCache[cacheKey]; } else { - node.image = await this.createIconImage( - conversation.lxmf_user_icon.icon_name, - conversation.lxmf_user_icon.foreground_colour, - conversation.lxmf_user_icon.background_colour, - 64 - ); + /* + * Defer custom-icon generation. Painting the + * canvas + decoding the SVG inline used to + * serialise every chunk and was the dominant + * cause of the visualiser freezing on slow ARM + * CPUs. Use a sensible placeholder (the same + * default user image we use for icon-less lxmf + * nodes) and queue the real icon for async + * generation once all chunks are processed. + */ + node.image = + entry.hops === 1 + ? "/assets/images/network-visualiser/user_1hop.png" + : "/assets/images/network-visualiser/user.png"; + this.iconQueue.push({ + nodeId: node.id, + cacheKey, + iconName: conversation.lxmf_user_icon.icon_name, + fg: conversation.lxmf_user_icon.foreground_colour, + bg: conversation.lxmf_user_icon.background_colour, + size: 64, + generation: this.iconQueueGeneration, + }); } - if (this.abortController.signal.aborted) return; node.size = 30; node._originalSize = 30; } else { @@ -2007,15 +2090,18 @@ export default { processedEdgeIds.add(edgeId); } - // Update DataSet incrementally if (batchNodes.length > 0) this.nodes.update(batchNodes); if (batchEdges.length > 0) this.edges.update(batchEdges); - // Allow UI to breathe and show progress this.loadingStatus = `Processing Batch ${this.currentBatch} / ${this.totalBatches}...`; - // Use nextTick for responsiveness - await this.$nextTick(); + /* + * Yield to the event loop using the prioritized scheduler + * (or setTimeout fallback). $nextTick is a microtask and does + * not let the renderer paint or process input between chunks, + * which is what was making the app feel frozen. + */ + await yieldToMain(); if (this.abortController.signal.aborted) return; } @@ -2057,6 +2143,57 @@ export default { if (this.enableOrbit) { this.startOrbit(); } + + /* + * Re-enable physics now that all nodes/edges are in place. The + * solver runs once on the final graph instead of repeatedly on + * partial states, which is dramatically cheaper. + */ + if (physicsWasOn && this.network) { + this.network.setOptions({ physics: { enabled: this.enablePhysics } }); + } + + this.runIconQueue(); + }, + /* + * Drains the deferred lxmf custom-icon queue. Runs sequentially with + * a yield between each icon so painting many icons cannot pin the + * main thread the way the old inline-await version did. Items tagged + * with a stale generation (a newer processVisualization started while + * we were running) are skipped, as are nodes that no longer exist. + */ + async runIconQueue() { + if (this.iconQueueRunning) return; + this.iconQueueRunning = true; + try { + while (this.iconQueue.length > 0) { + if (this.abortController.signal.aborted) return; + const item = this.iconQueue.shift(); + if (item.generation !== this.iconQueueGeneration) { + continue; + } + if (!this.nodes.get(item.nodeId)) { + continue; + } + /* + * Queue items can collapse onto a single cached icon: if + * a previous iteration already painted this cacheKey we + * can short-circuit instead of re-invoking createIconImage + * (which would also redo the canvas+SVG decode work). + */ + let url = this.iconCache[item.cacheKey]; + if (!url) { + url = await this.createIconImage(item.iconName, item.fg, item.bg, item.size); + if (this.abortController.signal.aborted) return; + } + if (url && this.nodes.get(item.nodeId)) { + this.nodes.update({ id: item.nodeId, image: url }); + } + await yieldToMain(); + } + } finally { + this.iconQueueRunning = false; + } }, }, }; diff --git a/tests/frontend/VisualizerOptimization.test.js b/tests/frontend/VisualizerOptimization.test.js index 0a0618f..9b36fe3 100644 --- a/tests/frontend/VisualizerOptimization.test.js +++ b/tests/frontend/VisualizerOptimization.test.js @@ -106,49 +106,38 @@ describe("NetworkVisualiser Optimization and Abort", () => { expect(signal.aborted).toBe(true); }); - it("stops processing visualization batches when aborted", async () => { + it("stops the deferred icon queue when aborted", async () => { vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {}); const wrapper = mountVisualiser(); - // Prepare large data - wrapper.vm.pathTable = Array.from({ length: 1000 }, (_, i) => ({ hash: `h${i}`, interface: "eth0", hops: 1 })); + wrapper.vm.pathTable = Array.from({ length: 200 }, (_, i) => ({ hash: `h${i}`, interface: "eth0", hops: 1 })); + const iconInfo = { icon_name: "test", foreground_colour: "#000", background_colour: "#fff" }; wrapper.vm.announces = wrapper.vm.pathTable.reduce((acc, cur) => { acc[cur.hash] = { destination_hash: cur.hash, aspect: "lxmf.delivery", display_name: "node", + lxmf_user_icon: { ...iconInfo, icon_name: `t${cur.hash}` }, }; return acc; }, {}); + wrapper.vm.conversations = wrapper.vm.pathTable.reduce((acc, cur) => { + acc[cur.hash] = { lxmf_user_icon: { ...iconInfo, icon_name: `t${cur.hash}` } }; + return acc; + }, {}); - // Add lxmf_user_icon to trigger await in createIconImage and slow it down - const firstHash = wrapper.vm.pathTable[0].hash; - wrapper.vm.announces[firstHash].lxmf_user_icon = { - icon_name: "test", - foreground_colour: "#000", - background_colour: "#fff", - }; - wrapper.vm.conversations[firstHash] = { lxmf_user_icon: wrapper.vm.announces[firstHash].lxmf_user_icon }; + wrapper.vm.createIconImage = vi.fn().mockImplementation(() => new Promise((r) => setTimeout(() => r("blob:icon"), 50))); - // Mock createIconImage to be slow - wrapper.vm.createIconImage = vi.fn().mockImplementation(() => new Promise((r) => setTimeout(r, 100))); + await wrapper.vm.processVisualization(); - const processPromise = wrapper.vm.processVisualization(); + const callsBeforeAbort = wrapper.vm.createIconImage.mock.calls.length; + expect(wrapper.vm.iconQueue.length + callsBeforeAbort).toBeGreaterThan(0); - // Give it some time to start first batch and hit the await - await new Promise((r) => setTimeout(r, 50)); - - // It should be in batch 1 and stuck on createIconImage - expect(wrapper.vm.currentBatch).toBe(1); - - // Abort wrapper.vm.abortController.abort(); + await new Promise((r) => setTimeout(r, 200)); - await processPromise; - - // Should have aborted and not reached the end where it resets currentBatch to 0 - // (Wait, actually if it returns early it stays 1) - expect(wrapper.vm.currentBatch).toBe(1); + const callsAfterAbort = wrapper.vm.createIconImage.mock.calls.length; + expect(callsAfterAbort - callsBeforeAbort).toBeLessThanOrEqual(1); }); it("parallelizes batch fetching", async () => { @@ -278,9 +267,15 @@ describe("NetworkVisualiser Optimization and Abort", () => { }); await wrapper.vm.processVisualization(); + while (wrapper.vm.iconQueueRunning || wrapper.vm.iconQueue.length > 0) { + await new Promise((r) => setTimeout(r, 5)); + } expect(wrapper.vm.createIconImage).toHaveBeenCalledTimes(1); await wrapper.vm.processVisualization(); + while (wrapper.vm.iconQueueRunning || wrapper.vm.iconQueue.length > 0) { + await new Promise((r) => setTimeout(r, 5)); + } expect(wrapper.vm.createIconImage).toHaveBeenCalledTimes(1); }); });