From 5837f0a8732161f37ae3ffbf9f27419cebc57275 Mon Sep 17 00:00:00 2001 From: drkameleon Date: Wed, 25 Feb 2026 14:04:01 +0100 Subject: [PATCH 1/8] added (optional) FPS overlay --- demo/index.html | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/demo/index.html b/demo/index.html index d5092ba..a016b6a 100644 --- a/demo/index.html +++ b/demo/index.html @@ -31,12 +31,22 @@ {{ darkMode ? 'light' : 'dark' }} +
+
+ +
+
{{ fpsDisplay.fps }} fps
+
{{ fpsDisplay.nodes }} nodes · {{ fpsDisplay.edges }} edges
+
+
@@ -181,7 +191,6 @@
-
Physics
From d529644b864e77a4892c1c68f508ed72843f90a0 Mon Sep 17 00:00:00 2001 From: drkameleon Date: Wed, 25 Feb 2026 14:04:19 +0100 Subject: [PATCH 2/8] also add a settings dialog option --- demo/index.html | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/demo/index.html b/demo/index.html index a016b6a..ce85ca6 100644 --- a/demo/index.html +++ b/demo/index.html @@ -192,6 +192,17 @@
+
+ +
+ +
+
+ +
Physics
From ec375beb33683755df130eab1d05af594d1418d3 Mon Sep 17 00:00:00 2001 From: drkameleon Date: Wed, 25 Feb 2026 14:04:38 +0100 Subject: [PATCH 3/8] the backend part --- demo/src/main.ts | 89 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 87 insertions(+), 2 deletions(-) diff --git a/demo/src/main.ts b/demo/src/main.ts index 2d29b31..f2b1209 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -31,6 +31,7 @@ interface SettingsModalState { nodeSize: number; edgeOpacity: number; showLabels: boolean; + showFps: boolean; gravitationalConstant: number; springLength: number; springConstant: number; @@ -52,6 +53,7 @@ const DEFAULT_SETTINGS: Omit = { nodeSize: 8, edgeOpacity: 0.8, showLabels: true, + showFps: false, gravitationalConstant: -0.25, springLength: 0.2, springConstant: 0.06, @@ -114,6 +116,10 @@ createApp({ const legendItems = ref([]); const tagColorCache: Record = {}; + // FPS display + const fpsDisplay = reactive({ fps: 0, nodes: 0, edges: 0 }); + let fpsInterval: number | null = null; + // Computed const hasSelection = computed(() => selectedNode.value !== null); const paletteNames = PALETTE_NAMES; @@ -279,6 +285,19 @@ createApp({ if (key === 'showLabels') { settingsModal.showLabels = !settingsModal.showLabels; g?.setLabelsVisible(settingsModal.showLabels); + } else if (key === 'showFps') { + settingsModal.showFps = !settingsModal.showFps; + if (settingsModal.showFps) { + fpsInterval = window.setInterval(() => { + if (!g) return; + fpsDisplay.fps = g.getFps(); + fpsDisplay.nodes = g.nodeCount; + fpsDisplay.edges = g.edgeCount; + }, 250); + } else if (fpsInterval !== null) { + clearInterval(fpsInterval); + fpsInterval = null; + } } } @@ -288,12 +307,78 @@ createApp({ g.setNodeSize(DEFAULT_SETTINGS.nodeSize); g.setEdgeOpacity(DEFAULT_SETTINGS.edgeOpacity); g.setLabelsVisible(DEFAULT_SETTINGS.showLabels); + if (fpsInterval !== null) { + clearInterval(fpsInterval); + fpsInterval = null; + } if (layoutRunning.value) { g.stopLayout(); g.startLayout(getPhysicsOpts()); } } + // ── Stress Test ── + + function stressTest(): void { + if (!g) return; + + // Stop current layout + if (layoutRunning.value) g.stopLayout(); + + // Clear existing graph + const graph = g.getGraph(); + for (const id of [...graph.activeNodeIds()]) { + g.unput(id); + } + + // Generate random graph: 500 nodes, ~1500 edges + const N = 500; + const tags = ['alpha', 'beta', 'gamma', 'delta', 'epsilon']; + const edgeTags = ['connects', 'links', 'references', 'depends']; + const nodeIds: number[] = []; + + for (let i = 0; i < N; i++) { + const tag = tags[Math.floor(Math.random() * tags.length)]; + const id = g.put(tag, { name: `${tag}-${i}` }); + nodeIds.push(id); + } + + // Each node gets ~3 random edges on average + const edgeCount = Math.floor(N * 3); + for (let i = 0; i < edgeCount; i++) { + const src = nodeIds[Math.floor(Math.random() * N)]; + let tgt = nodeIds[Math.floor(Math.random() * N)]; + if (src === tgt) tgt = nodeIds[(nodeIds.indexOf(src) + 1) % N]; + const tag = edgeTags[Math.floor(Math.random() * edgeTags.length)]; + g.link(src, tag, tgt); + } + + // Pre-stabilize + for (let i = 0; i < 200; i++) { + g.stepLayout(3); + } + g.fitView(0.15); + + // Start layout + g.startLayout(getPhysicsOpts()); + layoutRunning.value = true; + setTimeout(() => g!.fitView(0.15), 800); + + updateCounts(); + refreshLegend(); + + // Auto-enable FPS display + if (!settingsModal.showFps) { + settingsModal.showFps = true; + fpsInterval = window.setInterval(() => { + if (!g) return; + fpsDisplay.fps = g.getFps(); + fpsDisplay.nodes = g.nodeCount; + fpsDisplay.edges = g.edgeCount; + }, 250); + } + } + // ── Init ── onMounted(async () => { @@ -390,7 +475,7 @@ createApp({ return { darkMode, layoutRunning, animatedEnabled, activePalette, - nodeCount, edgeCount, + nodeCount, edgeCount, fpsDisplay, selectedNode, selectedNodeColor, hoveredNode, hoveredNodeColor, hasSelection, tooltipVisible, tooltipStyle, tooltipTag, tooltipName, tooltipProps, tooltipColor, @@ -399,7 +484,7 @@ createApp({ toggleLayout, fitView, resetGraph, toggleAnimated, toggleDarkMode, switchPalette, getPalettePreview, showEditModal, saveEdit, deleteSelected, confirmDelete, - onSettingChange, onToggleSetting, resetSettings, + onSettingChange, onToggleSetting, resetSettings, stressTest, }; }, }).mount('#app'); From 4478638f95b8e0960ac306f42023b51a4c03ca57 Mon Sep 17 00:00:00 2001 From: drkameleon Date: Wed, 25 Feb 2026 14:05:14 +0100 Subject: [PATCH 4/8] added styling for FPS indicator --- demo/src/style.scss | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/demo/src/style.scss b/demo/src/style.scss index a1ca026..b9b77d4 100644 --- a/demo/src/style.scss +++ b/demo/src/style.scss @@ -678,6 +678,35 @@ body { } } +// ============================================================ +// FPS Overlay +// ============================================================ + +.fps-overlay { + position: fixed; + top: calc(#{$toolbar-h} + 10px); + left: 50%; + transform: translateX(-50%); + z-index: 15; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: $radius; + padding: 6px 14px; + box-shadow: var(--shadow); + font-family: $font-mono; + font-size: 11px; + color: var(--text-dim); + display: flex; + gap: 12px; + pointer-events: none; + + b { + color: var(--accent); + font-weight: 600; + font-size: 13px; + } +} + // ============================================================ // Fallback // ============================================================ From 525b72186ef504c972b1ab13ef3c5b438d38174e Mon Sep 17 00:00:00 2001 From: drkameleon Date: Wed, 25 Feb 2026 14:05:33 +0100 Subject: [PATCH 5/8] added new `getFps` helper --- src/core/Renderer.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/core/Renderer.ts b/src/core/Renderer.ts index 601d4ac..1cf80e8 100644 --- a/src/core/Renderer.ts +++ b/src/core/Renderer.ts @@ -587,6 +587,11 @@ export class Renderer { this.labelsVisible = visible; } + /** Get current FPS */ + getFps(): number { + return this.currentFps; + } + /** Set selection state for a node (1.0 = selected, 0.0 = not) */ setSelection(nodeId: number, selected: boolean): void { if (this.selectionData.length <= nodeId) { From b0e68b3570eb8fdbf53d5fd7441a915b474ac805 Mon Sep 17 00:00:00 2001 From: drkameleon Date: Wed, 25 Feb 2026 14:06:04 +0100 Subject: [PATCH 6/8] and let's wire it up here as well... --- src/index.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/index.ts b/src/index.ts index 594195e..4a89f08 100644 --- a/src/index.ts +++ b/src/index.ts @@ -495,6 +495,13 @@ export class GraphGPU { this.renderer.setLabelsVisible(visible); } + /** + * Get current frames per second. + */ + getFps(): number { + return this.renderer.getFps(); + } + // ========================================================= // Selection // ========================================================= From 742ee5f7ddbe9e5264b285e5d41560a603d3fe85 Mon Sep 17 00:00:00 2001 From: drkameleon Date: Wed, 25 Feb 2026 14:09:40 +0100 Subject: [PATCH 7/8] critical fix --- src/layout/QuadTree.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/layout/QuadTree.ts b/src/layout/QuadTree.ts index fcca787..fafba84 100644 --- a/src/layout/QuadTree.ts +++ b/src/layout/QuadTree.ts @@ -228,7 +228,7 @@ export class QuadTree { return idx; } - private insert(nodeIdx: number, bodyId: number, bx: number, by: number): void { + private insert(nodeIdx: number, bodyId: number, bx: number, by: number, depth: number = 0): void { const base = nodeIdx * NODE_SIZE; const existingBody = this.pool[base + BODY_ID]; const hasChildren = @@ -244,6 +244,13 @@ export class QuadTree { return; } + // Max depth guard: if two bodies share (nearly) the same position, + // stop subdividing and accumulate mass into this node. + if (depth >= 40) { + this.pool[base + MASS] += 1; + return; + } + // Case 2: Leaf with existing body — subdivide if (existingBody >= 0) { const oldId = existingBody; @@ -255,11 +262,11 @@ export class QuadTree { this.pool[base + MASS] = 0; // Re-insert the old body - this.insertIntoChild(nodeIdx, oldId, oldX, oldY); + this.insertIntoChild(nodeIdx, oldId, oldX, oldY, depth); } // Case 3: Internal node — insert into correct child - this.insertIntoChild(nodeIdx, bodyId, bx, by); + this.insertIntoChild(nodeIdx, bodyId, bx, by, depth); } private insertIntoChild( @@ -267,6 +274,7 @@ export class QuadTree { bodyId: number, bx: number, by: number, + depth: number = 0, ): void { const base = parentIdx * NODE_SIZE; const cx = this.pool[base + CX]; @@ -305,7 +313,7 @@ export class QuadTree { this.pool[base + quadrant] = childIdx; } - this.insert(childIdx, bodyId, bx, by); + this.insert(childIdx, bodyId, bx, by, depth + 1); } /** From cf1bf3fc0031e086465bbe3cfa59664c59368580 Mon Sep 17 00:00:00 2001 From: drkameleon Date: Wed, 25 Feb 2026 14:15:24 +0100 Subject: [PATCH 8/8] misc. fixes --- demo/src/main.ts | 22 +++++-- src/core/Renderer.ts | 153 ++++++++++++++++++++++--------------------- 2 files changed, 93 insertions(+), 82 deletions(-) diff --git a/demo/src/main.ts b/demo/src/main.ts index f2b1209..5896a74 100644 --- a/demo/src/main.ts +++ b/demo/src/main.ts @@ -331,6 +331,10 @@ createApp({ g.unput(id); } + // Use smaller nodes for stress test + g.setNodeSize(4); + settingsModal.nodeSize = 4; + // Generate random graph: 500 nodes, ~1500 edges const N = 500; const tags = ['alpha', 'beta', 'gamma', 'delta', 'epsilon']; @@ -344,8 +348,8 @@ createApp({ } // Each node gets ~3 random edges on average - const edgeCount = Math.floor(N * 3); - for (let i = 0; i < edgeCount; i++) { + const numEdges = Math.floor(N * 3); + for (let i = 0; i < numEdges; i++) { const src = nodeIds[Math.floor(Math.random() * N)]; let tgt = nodeIds[Math.floor(Math.random() * N)]; if (src === tgt) tgt = nodeIds[(nodeIds.indexOf(src) + 1) % N]; @@ -353,16 +357,20 @@ createApp({ g.link(src, tag, tgt); } - // Pre-stabilize - for (let i = 0; i < 200; i++) { + // Scatter positions randomly before layout + g.resetPositions(); + + // Light pre-stabilization (don't block for too long) + for (let i = 0; i < 50; i++) { g.stepLayout(3); } g.fitView(0.15); - // Start layout + // Start layout and let it settle live g.startLayout(getPhysicsOpts()); layoutRunning.value = true; - setTimeout(() => g!.fitView(0.15), 800); + setTimeout(() => g!.fitView(0.15), 500); + setTimeout(() => g!.fitView(0.15), 2000); updateCounts(); refreshLegend(); @@ -596,4 +604,4 @@ function populateGraph(g: GraphGPU): void { g.link(mystic, 'basedOn', mysticB); g.link(wach1, 'sibling', wach2); g.link(cruise, 'married', kidman); -} +} \ No newline at end of file diff --git a/src/core/Renderer.ts b/src/core/Renderer.ts index 1cf80e8..8b513c9 100644 --- a/src/core/Renderer.ts +++ b/src/core/Renderer.ts @@ -704,84 +704,87 @@ export class Renderer { const edgeIndices = this.graph.edgeIndices; const pos = this.graph.positions; - - for (const eid of this.graph.activeEdgeIds()) { - const src = edgeIndices[eid * 2]; - const tgt = edgeIndices[eid * 2 + 1]; - if (!this.graph.isNodeActive(src) || !this.graph.isNodeActive(tgt)) continue; - - const edge = this.graph.getEdge(eid); - if (!edge || !edge.tag) continue; - - // Use the average screen radius of src and tgt nodes. - // This is the CORRECT nodeScreenR (includes sizes[id] * nodeScale). - const srcR = nodeScreenData[src]?.r ?? 0; - const tgtR = nodeScreenData[tgt]?.r ?? 0; - const avgNodeR = (srcR + tgtR) * 0.5; - - // Edge font = 65% of what the node label would be WITHOUT the 22px cap. - // No cap on edge labels — they scale with zoom just like everything else. - const edgeFontSize = Math.max(7, avgNodeR * 0.28 * 0.65); - - lctx.font = `500 ${edgeFontSize}px -apple-system,"Segoe UI",Helvetica,Arial,sans-serif`; - lctx.fillStyle = 'rgba(160,155,175,0.85)'; - - const labelOffset = edgeWidthPx * 0.5 + edgeFontSize * 0.55 + 3; - - // Midpoint in world coords - const mx = (pos[src * 2] + pos[tgt * 2]) * 0.5; - const my = (pos[src * 2 + 1] + pos[tgt * 2 + 1]) * 0.5; - const [sx, sy] = this.worldToCSS(mx, my); - - if (sx < -80 || sx > cw + 80 || sy < -40 || sy > ch + 40) continue; - - // Compute edge angle for rotated text - const [sx1, sy1] = this.worldToCSS(pos[src * 2], pos[src * 2 + 1]); - const [sx2, sy2] = this.worldToCSS(pos[tgt * 2], pos[tgt * 2 + 1]); - let angle = Math.atan2(sy2 - sy1, sx2 - sx1); - - if (angle > Math.PI / 2) angle -= Math.PI; - if (angle < -Math.PI / 2) angle += Math.PI; - - const edgeLen = Math.hypot(sx2 - sx1, sy2 - sy1); - if (edgeLen < 40) continue; - - // Skip if any part of the label overlaps any node circle. - // Test the label's bounding box corners (rotated) against node circles. - const textW = lctx.measureText(edge.tag).width * 0.5 + 4; - const textH = edgeFontSize * 0.6 + 4; - // Four corners of the label bbox relative to (sx, sy), rotated by angle - const cosA = Math.cos(angle); - const sinA = Math.sin(angle); - const offY = -labelOffset; - const corners = [ - { x: sx + cosA * (-textW) - sinA * (offY - textH), y: sy + sinA * (-textW) + cosA * (offY - textH) }, - { x: sx + cosA * ( textW) - sinA * (offY - textH), y: sy + sinA * ( textW) + cosA * (offY - textH) }, - { x: sx + cosA * ( textW) - sinA * (offY + textH), y: sy + sinA * ( textW) + cosA * (offY + textH) }, - { x: sx + cosA * (-textW) - sinA * (offY + textH), y: sy + sinA * (-textW) + cosA * (offY + textH) }, - ]; - let overlapsNode = false; - for (const nid of this.graph.activeNodeIds()) { - const nd = nodeScreenData[nid]; - if (!nd) continue; - const rSq = nd.r * nd.r; - for (const c of corners) { - const dx = c.x - nd.sx; - const dy = c.y - nd.sy; - if (dx * dx + dy * dy < rSq) { - overlapsNode = true; - break; + const numEdges = this.graph.numEdges; + + // Skip edge labels for large graphs — they'd be unreadable clutter + // and the overlap check + text rendering is expensive + if (numEdges <= 300) { + for (const eid of this.graph.activeEdgeIds()) { + const src = edgeIndices[eid * 2]; + const tgt = edgeIndices[eid * 2 + 1]; + if (!this.graph.isNodeActive(src) || !this.graph.isNodeActive(tgt)) continue; + + const edge = this.graph.getEdge(eid); + if (!edge || !edge.tag) continue; + + // Use the average screen radius of src and tgt nodes. + const srcR = nodeScreenData[src]?.r ?? 0; + const tgtR = nodeScreenData[tgt]?.r ?? 0; + const avgNodeR = (srcR + tgtR) * 0.5; + + // Edge font = 65% of what the node label would be WITHOUT the 22px cap. + const edgeFontSize = Math.max(7, avgNodeR * 0.28 * 0.65); + + lctx.font = `500 ${edgeFontSize}px -apple-system,"Segoe UI",Helvetica,Arial,sans-serif`; + lctx.fillStyle = 'rgba(160,155,175,0.85)'; + + const labelOffset = edgeWidthPx * 0.5 + edgeFontSize * 0.55 + 3; + + // Midpoint in world coords + const mx = (pos[src * 2] + pos[tgt * 2]) * 0.5; + const my = (pos[src * 2 + 1] + pos[tgt * 2 + 1]) * 0.5; + const [sx, sy] = this.worldToCSS(mx, my); + + if (sx < -80 || sx > cw + 80 || sy < -40 || sy > ch + 40) continue; + + // Compute edge angle for rotated text + const [sx1, sy1] = this.worldToCSS(pos[src * 2], pos[src * 2 + 1]); + const [sx2, sy2] = this.worldToCSS(pos[tgt * 2], pos[tgt * 2 + 1]); + let angle = Math.atan2(sy2 - sy1, sx2 - sx1); + + if (angle > Math.PI / 2) angle -= Math.PI; + if (angle < -Math.PI / 2) angle += Math.PI; + + const edgeLen = Math.hypot(sx2 - sx1, sy2 - sy1); + if (edgeLen < 40) continue; + + // Check overlap against src and tgt nodes only (O(1) per edge) + const textW = lctx.measureText(edge.tag).width * 0.5 + 4; + const textH = edgeFontSize * 0.6 + 4; + const cosA = Math.cos(angle); + const sinA = Math.sin(angle); + const offY = -labelOffset; + const corners = [ + { x: sx + cosA * (-textW) - sinA * (offY - textH), y: sy + sinA * (-textW) + cosA * (offY - textH) }, + { x: sx + cosA * ( textW) - sinA * (offY - textH), y: sy + sinA * ( textW) + cosA * (offY - textH) }, + { x: sx + cosA * ( textW) - sinA * (offY + textH), y: sy + sinA * ( textW) + cosA * (offY + textH) }, + { x: sx + cosA * (-textW) - sinA * (offY + textH), y: sy + sinA * (-textW) + cosA * (offY + textH) }, + ]; + let overlapsNode = false; + // Only check the two connected nodes — they're the only + // ones whose circles can realistically overlap the midpoint label + for (const nid of [src, tgt]) { + const nd = nodeScreenData[nid]; + if (!nd) continue; + const rSq = nd.r * nd.r; + for (const c of corners) { + const dx = c.x - nd.sx; + const dy = c.y - nd.sy; + if (dx * dx + dy * dy < rSq) { + overlapsNode = true; + break; + } } + if (overlapsNode) break; } - if (overlapsNode) break; - } - if (overlapsNode) continue; + if (overlapsNode) continue; - lctx.save(); - lctx.translate(sx, sy); - lctx.rotate(angle); - lctx.fillText(edge.tag, 0, -labelOffset); - lctx.restore(); + lctx.save(); + lctx.translate(sx, sy); + lctx.rotate(angle); + lctx.fillText(edge.tag, 0, -labelOffset); + lctx.restore(); + } } }