|
152 | 152 | zoom = Math.max(MIN_ZOOM, zoom / 1.2); |
153 | 153 | } |
154 | 154 |
|
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; |
156 | 158 | function screenToSvg(clientX: number, clientY: number): DOMPoint { |
157 | 159 | 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); |
161 | 167 | } |
| 168 | + // Invalidate cache when interaction ends |
| 169 | + $effect(() => { |
| 170 | + if (!isInteracting) cachedCtmInverse = null; |
| 171 | + }); |
162 | 172 |
|
163 | 173 | /** Convert screen mouse coords to world (scene) coordinates, accounting for pan & zoom. */ |
164 | 174 | function getWorldPoint(e: MouseEvent): { x: number; y: number } { |
|
198 | 208 | const dy = world.y - dragStart.y; |
199 | 209 | const nextX = dragNodeStart.x + dx; |
200 | 210 | 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; |
205 | 214 | }); |
206 | 215 | } |
207 | 216 |
|
|
304 | 313 |
|
305 | 314 | // Viewport culling — only render nodes/edges visible in the current view |
306 | 315 | 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 | + }); |
313 | 341 |
|
314 | 342 | function isNodeVisible(node: VisNode): boolean { |
315 | 343 | const visual = getNodeVisual(node.node.kind, node.isCenter); |
|
398 | 426 | return { href: getNodeUrl(node.id), external: false }; |
399 | 427 | } |
400 | 428 |
|
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; |
402 | 432 | let edgeLabelPositions = $derived.by(() => { |
403 | | - return perf.frame('derived', 'edgeLabelPositions', () => |
| 433 | + if (isPanning && frozenLabels) return frozenLabels; |
| 434 | + const labels = perf.frame('derived', 'edgeLabelPositions', () => |
404 | 435 | computeSceneLabels( |
405 | 436 | baseScene, |
406 | 437 | positionedNodeMap, |
407 | 438 | (kind) => getEdgeLabelMetrics(kind, false) |
408 | 439 | ) |
409 | 440 | ); |
| 441 | + frozenLabels = labels; |
| 442 | + return labels; |
410 | 443 | }); |
411 | 444 | </script> |
412 | 445 |
|
|
554 | 587 | </defs> |
555 | 588 |
|
556 | 589 | <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 | + > |
558 | 594 | <!-- Edges (culled to viewport, with invisible hit areas for easier hovering) --> |
559 | 595 | {#each visibleEdges as { edge, index: edgeIndex } (edge.from.node.id + '|' + edge.to.node.id + '|' + edge.kind)} |
560 | 596 | {@const fromNode = positionedNodeMap.get(edge.from.node.id) ?? edge.from} |
|
572 | 608 | {@const isHighlighted = hoveredNodeId === edge.from.node.id || hoveredNodeId === edge.to.node.id || hoveredEdgeIndex === edgeIndex} |
573 | 609 | <g |
574 | 610 | 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'};" |
576 | 612 | onmouseenter={() => { if (!dragNodeId) hoveredEdgeIndex = edgeIndex; }} |
577 | 613 | onmouseleave={() => { if (!dragNodeId) hoveredEdgeIndex = null; }} |
578 | 614 | > |
|
660 | 696 | <g |
661 | 697 | class="node cursor-grab" |
662 | 698 | 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;'}" |
664 | 700 | onmousedown={(e) => startNodeDrag(visNode, e)} |
665 | 701 | onmouseenter={(e) => { if (!dragNodeId) { hoveredNodeId = visNode.node.id; showTooltip(visNode, e); } }} |
666 | 702 | onmousemove={(e) => { if (!dragNodeId) showTooltip(visNode, e); }} |
|
0 commit comments