From fc0a2444ad64c2e3f39c272d65e32fb79af4c65f Mon Sep 17 00:00:00 2001 From: Sudo-Ivan Date: Fri, 23 Jan 2026 09:24:22 -0600 Subject: [PATCH] Update NetworkVisualiser with Level of Detail (LOD) management and icon cache optimization - Introduced dynamic LOD updates based on network scale, improving performance and visual clarity. - Implemented blob URL management for icon caching to optimize memory usage and prevent leaks. - Updated tests to validate LOD functionality and icon cache behavior, ensuring robustness and performance efficiency. Thanks to L for suggestion! --- .../network-visualiser/NetworkVisualiser.vue | 131 +++++++++++++++--- tests/frontend/NetworkVisualiser.test.js | 7 +- tests/frontend/VisualizerOptimization.test.js | 122 ++++++++++++++++ 3 files changed, 238 insertions(+), 22 deletions(-) diff --git a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue index d3c90a2..6b87249 100644 --- a/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue +++ b/meshchatx/src/frontend/components/network-visualiser/NetworkVisualiser.vue @@ -303,6 +303,7 @@ export default { pageSize: 1000, searchQuery: "", abortController: new AbortController(), + currentLOD: "high", }; }, computed: { @@ -362,7 +363,14 @@ export default { this.network.destroy(); } // Clear icon cache to free memory - for (const key in this.iconCache) { + const revokedUrls = new Set(); + const keys = Object.keys(this.iconCache); + for (const key of keys) { + const url = this.iconCache[key]; + if (url && url.startsWith("blob:") && !revokedUrls.has(url)) { + URL.revokeObjectURL(url); + revokedUrls.add(url); + } delete this.iconCache[key]; } this.iconCache = {}; @@ -516,14 +524,25 @@ export default { const cacheKeys = Object.keys(this.iconCache); if (cacheKeys.length >= 500) { // simple FIFO eviction - delete this.iconCache[cacheKeys[0]]; + const oldKey = cacheKeys[0]; + const oldUrl = this.iconCache[oldKey]; + if (oldUrl && oldUrl.startsWith("blob:")) { + // Check if any other keys use this URL before revoking + const stillUsed = Object.values(this.iconCache).some( + (u, i) => u === oldUrl && Object.keys(this.iconCache)[i] !== oldKey + ); + if (!stillUsed) { + URL.revokeObjectURL(oldUrl); + } + } + delete this.iconCache[oldKey]; } return new Promise((resolve) => { const canvas = document.createElement("canvas"); canvas.width = size; canvas.height = size; - const ctx = canvas.getContext("2d"); + const ctx = canvas.getContext("2d", { alpha: true }); // draw background circle with subtle gradient const gradient = ctx.createLinearGradient(0, 0, 0, size); @@ -589,9 +608,12 @@ export default { ctx.shadowOffsetY = 0; URL.revokeObjectURL(url); - const dataUrl = canvas.toDataURL(); - this.iconCache[cacheKey] = dataUrl; - resolve(dataUrl); + + canvas.toBlob((blob) => { + const blobUrl = URL.createObjectURL(blob); + this.iconCache[cacheKey] = blobUrl; + resolve(blobUrl); + }, "image/png"); }; img.onerror = () => { if (this.abortController.signal.aborted) { @@ -600,9 +622,11 @@ export default { return; } URL.revokeObjectURL(url); - const dataUrl = canvas.toDataURL(); - this.iconCache[cacheKey] = dataUrl; - resolve(dataUrl); + canvas.toBlob((blob) => { + const blobUrl = URL.createObjectURL(blob); + this.iconCache[cacheKey] = blobUrl; + resolve(blobUrl); + }, "image/png"); }; img.src = url; }); @@ -948,6 +972,10 @@ export default { this._draggingNodeId = null; }); + this.network.on("zoom", () => { + this.updateLOD(); + }); + await this.manualUpdate(); // auto reload @@ -973,6 +1001,53 @@ export default { this.isUpdating = false; } }, + updateLOD() { + if (!this.network) return; + const scale = this.network.getScale(); + let newLOD = "high"; + if (scale < 0.2) { + newLOD = "low"; + } else if (scale < 0.5) { + newLOD = "medium"; + } + + if (this.currentLOD === newLOD) return; + this.currentLOD = newLOD; + + const allNodes = this.nodes.get(); + const updates = allNodes.map((node) => { + return this.getNodeLODProps(node, newLOD); + }); + this.nodes.update(updates); + }, + getNodeLODProps(node, lod) { + const isDarkMode = document.documentElement.classList.contains("dark"); + const fontColor = isDarkMode ? "#ffffff" : "#000000"; + + if (lod === "low") { + return { + id: node.id, + shape: "dot", + size: node.id === "me" ? 15 : 10, + font: { size: 0 }, // hide labels + }; + } else if (lod === "medium") { + return { + id: node.id, + shape: node._originalShape || "circularImage", + size: node._originalSize || (node.id === "me" ? 50 : 25), + font: { size: 0 }, // hide labels + }; + } else { + // high + return { + id: node.id, + shape: node._originalShape || "circularImage", + size: node._originalSize || (node.id === "me" ? 50 : 25), + font: { size: node.id === "me" ? 16 : 11, color: fontColor }, + }; + } + }, async update() { this.loadingStatus = "Fetching basic info..."; this.currentBatch = 0; @@ -1003,11 +1078,13 @@ export default { // Add me const meLabel = this.config?.display_name ?? "Local Node"; if (matchesSearch(meLabel) || matchesSearch(this.config?.identity_hash)) { - const meNode = { + let meNode = { id: "me", group: "me", size: 50, + _originalSize: 50, shape: "circularImage", + _originalShape: "circularImage", image: this.reticulumLogoPath, label: meLabel, title: `Local Node: ${meLabel}\nIdentity: ${this.config?.identity_hash ?? "Unknown"}`, @@ -1016,6 +1093,7 @@ export default { x: 0, y: 0, }; + meNode = { ...meNode, ...this.getNodeLODProps(meNode, this.currentLOD) }; this.nodes.update([meNode]); processedNodeIds.add("me"); } @@ -1039,13 +1117,15 @@ export default { const initialX = Math.cos(angle) * radius; const initialY = Math.sin(angle) * radius; - interfaceNodes.push({ + let interfaceNode = { id: entry.name, group: "interface", label: label, title: `${entry.name}\nState: ${entry.status ? "Online" : "Offline"}\nBitrate: ${Utils.formatBitsPerSecond(entry.bitrate)}\nTX: ${Utils.formatBytes(entry.txb)}\nRX: ${Utils.formatBytes(entry.rxb)}`, size: 35, + _originalSize: 35, shape: "circularImage", + _originalShape: "circularImage", image: entry.status ? "/assets/images/network-visualiser/interface_connected.png" : "/assets/images/network-visualiser/interface_disconnected.png", @@ -1056,7 +1136,9 @@ export default { font: { color: fontColor, size: 12, bold: true }, x: initialX, y: initialY, - }); + }; + interfaceNode = { ...interfaceNode, ...this.getNodeLODProps(interfaceNode, this.currentLOD) }; + interfaceNodes.push(interfaceNode); processedNodeIds.add(entry.name); const edgeId = `me~${entry.name}`; @@ -1129,10 +1211,11 @@ export default { initY = Math.sin(angle) * dist; } - const node = { + let node = { id: entry.hash, group: "announce", size: 25, + _originalSize: 25, _announce: announce, font: { color: fontColor, size: 11 }, x: initX, @@ -1145,16 +1228,24 @@ export default { if (announce.aspect === "lxmf.delivery") { if (conversation?.lxmf_user_icon) { node.shape = "circularImage"; - node.image = await this.createIconImage( - conversation.lxmf_user_icon.icon_name, - conversation.lxmf_user_icon.foreground_colour, - conversation.lxmf_user_icon.background_colour, - 64 - ); + node._originalShape = "circularImage"; + const cacheKey = `${conversation.lxmf_user_icon.icon_name}-${conversation.lxmf_user_icon.foreground_colour}-${conversation.lxmf_user_icon.background_colour}-64`; + 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 + ); + } if (this.abortController.signal.aborted) return; node.size = 30; + node._originalSize = 30; } else { node.shape = "circularImage"; + node._originalShape = "circularImage"; node.image = entry.hops === 1 ? "/assets/images/network-visualiser/user_1hop.png" @@ -1173,6 +1264,7 @@ export default { }; } else if (announce.aspect === "nomadnetwork.node") { node.shape = "circularImage"; + node._originalShape = "circularImage"; node.image = entry.hops === 1 ? "/assets/images/network-visualiser/server_1hop.png" @@ -1190,6 +1282,7 @@ export default { }; } + node = { ...node, ...this.getNodeLODProps(node, this.currentLOD) }; batchNodes.push(node); processedNodeIds.add(node.id); diff --git a/tests/frontend/NetworkVisualiser.test.js b/tests/frontend/NetworkVisualiser.test.js index e4fa5c0..3c492a3 100644 --- a/tests/frontend/NetworkVisualiser.test.js +++ b/tests/frontend/NetworkVisualiser.test.js @@ -327,9 +327,10 @@ describe("NetworkVisualiser.vue", () => { const originalCreateIconImage = wrapper.vm.createIconImage; wrapper.vm.createIconImage = vi.fn().mockImplementation(async (iconName, fg, bg, size) => { const cacheKey = `${iconName}-${fg}-${bg}-${size}`; - const mockDataUrl = `data:image/png;base64,${iconName}`; - wrapper.vm.iconCache[cacheKey] = mockDataUrl; - return mockDataUrl; + // Use blob: prefix so it gets cleared on unmount + const mockBlobUrl = `blob:image/png/${iconName}`; + wrapper.vm.iconCache[cacheKey] = mockBlobUrl; + return mockBlobUrl; }); const getMemory = () => process.memoryUsage().heapUsed / (1024 * 1024); diff --git a/tests/frontend/VisualizerOptimization.test.js b/tests/frontend/VisualizerOptimization.test.js index b64c1df..44243f8 100644 --- a/tests/frontend/VisualizerOptimization.test.js +++ b/tests/frontend/VisualizerOptimization.test.js @@ -60,6 +60,10 @@ describe("NetworkVisualiser Optimization and Abort", () => { isCancel: vi.fn().mockImplementation((e) => e && e.name === "AbortError"), }; window.axios = axiosMock; + + // Mock URL methods + global.URL.createObjectURL = vi.fn().mockReturnValue("blob:mock"); + global.URL.revokeObjectURL = vi.fn(); }); afterEach(() => { @@ -165,4 +169,122 @@ describe("NetworkVisualiser Optimization and Abort", () => { // Total 5 calls expect(axiosMock.get).toHaveBeenCalledTimes(5); }); + + it("applies LOD based on scale", async () => { + vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {}); + const wrapper = mountVisualiser(); + wrapper.vm.network = { + getScale: vi.fn(), + }; + + const testNode = { id: "test", label: "Test Label", _originalSize: 25, _originalShape: "circularImage" }; + wrapper.vm.nodes.add(testNode); + + // Test Low LOD + wrapper.vm.network.getScale.mockReturnValue(0.1); + wrapper.vm.updateLOD(); + expect(wrapper.vm.currentLOD).toBe("low"); + let updatedNode = wrapper.vm.nodes.get("test"); + expect(updatedNode.shape).toBe("dot"); + expect(updatedNode.font.size).toBe(0); + + // Test Medium LOD + wrapper.vm.network.getScale.mockReturnValue(0.3); + wrapper.vm.updateLOD(); + expect(wrapper.vm.currentLOD).toBe("medium"); + updatedNode = wrapper.vm.nodes.get("test"); + expect(updatedNode.shape).toBe("circularImage"); + expect(updatedNode.font.size).toBe(0); + + // Test High LOD + wrapper.vm.network.getScale.mockReturnValue(0.7); + wrapper.vm.updateLOD(); + expect(wrapper.vm.currentLOD).toBe("high"); + updatedNode = wrapper.vm.nodes.get("test"); + expect(updatedNode.shape).toBe("circularImage"); + expect(updatedNode.font.size).toBe(11); + }); + + it("clears Blob URLs from icon cache on unmount", async () => { + vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {}); + const wrapper = mountVisualiser(); + + const mockBlobUrl = "blob:mock-url-1"; + wrapper.vm.iconCache["test-key"] = mockBlobUrl; + + const revokeSpy = vi.spyOn(URL, "revokeObjectURL"); + + wrapper.unmount(); + + expect(revokeSpy).toHaveBeenCalledWith(mockBlobUrl); + expect(Object.keys(wrapper.vm.iconCache).length).toBe(0); + }); + + it("performance: LOD update time for 2000 nodes", async () => { + vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {}); + const wrapper = mountVisualiser(); + wrapper.vm.network = { getScale: vi.fn() }; + + const nodeCount = 2000; + const nodes = Array.from({ length: nodeCount }, (_, i) => ({ + id: `n${i}`, + label: `Node ${i}`, + _originalSize: 25, + _originalShape: "circularImage", + })); + wrapper.vm.nodes.add(nodes); + + const start = performance.now(); + wrapper.vm.network.getScale.mockReturnValue(0.1); // Switch to low LOD + wrapper.vm.updateLOD(); + const end = performance.now(); + + console.log(`LOD update for ${nodeCount} nodes took ${(end - start).toFixed(2)}ms`); + expect(end - start).toBeLessThan(100); // Should be very fast + }); + + it("performance: icon cache hit vs miss for 500 nodes", async () => { + vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {}); + const wrapper = mountVisualiser(); + + // Setup 500 nodes with the same icon + const iconInfo = { icon_name: "test", foreground_colour: "#000", background_colour: "#fff" }; + wrapper.vm.pathTable = Array.from({ length: 500 }, (_, i) => ({ hash: `h${i}`, interface: "eth0", hops: 1 })); + 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, + }; + return acc; + }, {}); + wrapper.vm.conversations = wrapper.vm.pathTable.reduce((acc, cur) => { + acc[cur.hash] = { lxmf_user_icon: iconInfo }; + return acc; + }, {}); + + // Mock createIconImage to have some delay + wrapper.vm.createIconImage = vi.fn().mockImplementation(async () => { + return "blob:mock-icon"; + }); + + const startMiss = performance.now(); + await wrapper.vm.processVisualization(); + const endMiss = performance.now(); + const missTime = endMiss - startMiss; + + // Second run should hit cache + const startHit = performance.now(); + await wrapper.vm.processVisualization(); + const endHit = performance.now(); + const hitTime = endHit - startHit; + + console.log(`Icon cache MISS for 500 nodes: ${missTime.toFixed(2)}ms`); + console.log(`Icon cache HIT for 500 nodes: ${hitTime.toFixed(2)}ms`); + + // Cache hit should be significantly faster because it avoids 500 async calls (even if resolved instantly) + // and doesn't re-create images. + expect(hitTime).toBeLessThan(missTime); + }); });