Skip to content

Commit 26065b3

Browse files
themixednutsclaude
andcommitted
Add SVG renderer performance optimizations for 60fps+ panning
- Cache CTM inverse during interaction to avoid repeated matrix inversion - Use CSS transform with will-change for GPU-composited viewport panning - Freeze culling bounds during pan with larger margin to avoid re-culling - Freeze edge label positions during pan (relative positions unchanged) - Disable pointer-events on edges/nodes during pan to skip hit testing - Mutate-and-reassign dragOffsets to avoid spreading every frame Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 8145f23 commit 26065b3

1 file changed

Lines changed: 55 additions & 19 deletions

File tree

codeview-ui/src/lib/components/RelationshipGraph.svelte

Lines changed: 55 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -152,13 +152,23 @@
152152
zoom = Math.max(MIN_ZOOM, zoom / 1.2);
153153
}
154154
155-
/** Convert screen (clientX/Y) to SVG viewBox coordinates using native SVG APIs. */
155+
/** Convert screen (clientX/Y) to SVG viewBox coordinates using native SVG APIs.
156+
* Caches CTM inverse during drag/pan to avoid repeated matrix inversion. */
157+
let cachedCtmInverse: DOMMatrix | null = null;
156158
function screenToSvg(clientX: number, clientY: number): DOMPoint {
157159
if (!svgEl) return new DOMPoint(0, 0);
158-
const ctm = svgEl.getScreenCTM();
159-
if (!ctm) return new DOMPoint(0, 0);
160-
return new DOMPoint(clientX, clientY).matrixTransform(ctm.inverse());
160+
// Use cached inverse during interaction for better perf
161+
if (!cachedCtmInverse || !isInteracting) {
162+
const ctm = svgEl.getScreenCTM();
163+
if (!ctm) return new DOMPoint(0, 0);
164+
cachedCtmInverse = ctm.inverse();
165+
}
166+
return new DOMPoint(clientX, clientY).matrixTransform(cachedCtmInverse);
161167
}
168+
// Invalidate cache when interaction ends
169+
$effect(() => {
170+
if (!isInteracting) cachedCtmInverse = null;
171+
});
162172
163173
/** Convert screen mouse coords to world (scene) coordinates, accounting for pan & zoom. */
164174
function getWorldPoint(e: MouseEvent): { x: number; y: number } {
@@ -198,10 +208,9 @@
198208
const dy = world.y - dragStart.y;
199209
const nextX = dragNodeStart.x + dx;
200210
const nextY = dragNodeStart.y + dy;
201-
dragOffsets = {
202-
...dragOffsets,
203-
[dragNodeId!]: { x: nextX - dragBasePos.x, y: nextY - dragBasePos.y }
204-
};
211+
// Mutate-and-reassign: avoid spreading 100s of entries every frame
212+
dragOffsets[dragNodeId!] = { x: nextX - dragBasePos.x, y: nextY - dragBasePos.y };
213+
dragOffsets = dragOffsets;
205214
});
206215
}
207216
@@ -304,12 +313,31 @@
304313
305314
// Viewport culling — only render nodes/edges visible in the current view
306315
const CULL_MARGIN = 100; // px margin around viewport for labels
307-
let visibleBounds = $derived.by(() => ({
308-
minX: -panX / zoom - CULL_MARGIN,
309-
minY: -panY / zoom - CULL_MARGIN,
310-
maxX: (-panX + WIDTH) / zoom + CULL_MARGIN,
311-
maxY: (-panY + HEIGHT) / zoom + CULL_MARGIN,
312-
}));
316+
const CULL_MARGIN_INTERACTION = 300; // larger margin during pan to avoid popping
317+
318+
function computeBounds(margin: number) {
319+
return {
320+
minX: -panX / zoom - margin,
321+
minY: -panY / zoom - margin,
322+
maxX: (-panX + WIDTH) / zoom + margin,
323+
maxY: (-panY + HEIGHT) / zoom + margin,
324+
};
325+
}
326+
327+
// Freeze culling bounds during pan (not node drag) to avoid re-culling every frame
328+
let frozenBounds: ReturnType<typeof computeBounds> | null = null;
329+
let visibleBounds = $derived.by(() => {
330+
// Only freeze during pan, not node drag — node drag changes positions so culling must stay live
331+
if (isPanning && frozenBounds) return frozenBounds;
332+
return computeBounds(CULL_MARGIN);
333+
});
334+
$effect(() => {
335+
if (isPanning) {
336+
if (!frozenBounds) frozenBounds = computeBounds(CULL_MARGIN_INTERACTION);
337+
} else {
338+
frozenBounds = null;
339+
}
340+
});
313341
314342
function isNodeVisible(node: VisNode): boolean {
315343
const visual = getNodeVisual(node.node.kind, node.isCenter);
@@ -398,15 +426,20 @@
398426
return { href: getNodeUrl(node.id), external: false };
399427
}
400428
401-
// Stage 2: label positions. Recomputes with drag-aware positions (cheap).
429+
// Stage 2: label positions. Recomputes with drag-aware positions.
430+
// Freeze during pan (not node drag) — panning doesn't change relative label positions.
431+
let frozenLabels: ReturnType<typeof computeSceneLabels> | null = null;
402432
let edgeLabelPositions = $derived.by(() => {
403-
return perf.frame('derived', 'edgeLabelPositions', () =>
433+
if (isPanning && frozenLabels) return frozenLabels;
434+
const labels = perf.frame('derived', 'edgeLabelPositions', () =>
404435
computeSceneLabels(
405436
baseScene,
406437
positionedNodeMap,
407438
(kind) => getEdgeLabelMetrics(kind, false)
408439
)
409440
);
441+
frozenLabels = labels;
442+
return labels;
410443
});
411444
</script>
412445

@@ -554,7 +587,10 @@
554587
</defs>
555588

556589
<rect width="100%" height="100%" fill="url(#grid)" />
557-
<g transform="translate({panX}, {panY}) scale({zoom})">
590+
<g
591+
class="scene-content"
592+
style="transform: translate({panX}px, {panY}px) scale({zoom}); will-change: {isInteracting ? 'transform' : 'auto'};"
593+
>
558594
<!-- Edges (culled to viewport, with invisible hit areas for easier hovering) -->
559595
{#each visibleEdges as { edge, index: edgeIndex } (edge.from.node.id + '|' + edge.to.node.id + '|' + edge.kind)}
560596
{@const fromNode = positionedNodeMap.get(edge.from.node.id) ?? edge.from}
@@ -572,7 +608,7 @@
572608
{@const isHighlighted = hoveredNodeId === edge.from.node.id || hoveredNodeId === edge.to.node.id || hoveredEdgeIndex === edgeIndex}
573609
<g
574610
class="edge {isInteracting ? '' : 'transition-opacity duration-150'}"
575-
style="opacity: {hoveredNodeId && !isHighlighted ? 0.3 : 1}"
611+
style="opacity: {hoveredNodeId && !isHighlighted ? 0.3 : 1}; pointer-events: {isInteracting ? 'none' : 'auto'};"
576612
onmouseenter={() => { if (!dragNodeId) hoveredEdgeIndex = edgeIndex; }}
577613
onmouseleave={() => { if (!dragNodeId) hoveredEdgeIndex = null; }}
578614
>
@@ -660,7 +696,7 @@
660696
<g
661697
class="node cursor-grab"
662698
class:cursor-grabbing={isDragging}
663-
style="transform: translate({visNode.x}px, {visNode.y}px) scale({hoverScale}); opacity: {shouldDim ? 0.3 : 1};{isInteracting || isDragging ? '' : ' transition: transform 150ms ease-out, opacity 150ms ease-out;'}"
699+
style="transform: translate({visNode.x}px, {visNode.y}px) scale({hoverScale}); opacity: {shouldDim ? 0.3 : 1}; pointer-events: {isPanning ? 'none' : 'auto'};{isInteracting || isDragging ? '' : ' transition: transform 150ms ease-out, opacity 150ms ease-out;'}"
664700
onmousedown={(e) => startNodeDrag(visNode, e)}
665701
onmouseenter={(e) => { if (!dragNodeId) { hoveredNodeId = visNode.node.id; showTooltip(visNode, e); } }}
666702
onmousemove={(e) => { if (!dragNodeId) showTooltip(visNode, e); }}

0 commit comments

Comments
 (0)