feat(network-visualiser): improve performance with adaptive chunk sizing and icon queue management

This commit is contained in:
Ivan
2026-04-17 15:10:44 -05:00
parent 93dd030e83
commit 2af89dc446
2 changed files with 182 additions and 50 deletions
@@ -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;
}
},
},
};
+21 -26
View File
@@ -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);
});
});