diff --git a/demo/index.html b/demo/index.html
index d5092ba..ce85ca6 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,6 +191,16 @@
+
+
+
+
+
+
+
diff --git a/demo/src/main.ts b/demo/src/main.ts
index 2d29b31..5896a74 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,86 @@ 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);
+ }
+
+ // 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'];
+ 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 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];
+ const tag = edgeTags[Math.floor(Math.random() * edgeTags.length)];
+ g.link(src, tag, tgt);
+ }
+
+ // 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 and let it settle live
+ g.startLayout(getPhysicsOpts());
+ layoutRunning.value = true;
+ setTimeout(() => g!.fitView(0.15), 500);
+ setTimeout(() => g!.fitView(0.15), 2000);
+
+ 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 +483,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 +492,7 @@ createApp({
toggleLayout, fitView, resetGraph, toggleAnimated, toggleDarkMode,
switchPalette, getPalettePreview,
showEditModal, saveEdit, deleteSelected, confirmDelete,
- onSettingChange, onToggleSetting, resetSettings,
+ onSettingChange, onToggleSetting, resetSettings, stressTest,
};
},
}).mount('#app');
@@ -511,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/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
// ============================================================
diff --git a/src/core/Renderer.ts b/src/core/Renderer.ts
index 601d4ac..8b513c9 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) {
@@ -699,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();
+ }
}
}
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
// =========================================================
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);
}
/**