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!
This commit is contained in:
Sudo-Ivan
2026-01-23 09:24:22 -06:00
parent e8a303fd6f
commit fc0a2444ad
3 changed files with 238 additions and 22 deletions
@@ -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);
+4 -3
View File
@@ -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);
@@ -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);
});
});