|
16 | 16 | computeSceneLabels, |
17 | 17 | } from '$lib/renderers/graph'; |
18 | 18 | import { perf } from '$lib/perf'; |
| 19 | + import { SvelteMap } from 'svelte/reactivity'; |
19 | 20 |
|
20 | 21 | let { |
21 | 22 | graph, |
|
62 | 63 | let containerEl = $state<HTMLDivElement | null>(null); |
63 | 64 | let svgEl = $state<SVGSVGElement | null>(null); |
64 | 65 | let dragNodeId = $state<string | null>(null); |
65 | | - let dragOffsets = $state.raw<Record<string, DragOffset>>({}); |
| 66 | + // SvelteMap provides granular reactivity: only the dragged node's offset triggers updates, |
| 67 | + // avoiding re-evaluation of all derived computations when a single entry changes. |
| 68 | + let dragOffsets = new SvelteMap<string, DragOffset>(); |
66 | 69 | let dragStart = { x: 0, y: 0 }; |
67 | 70 | let dragStartScreen = { x: 0, y: 0 }; |
68 | 71 | let dragNodeStart = { x: 0, y: 0 }; |
|
130 | 133 | } |
131 | 134 |
|
132 | 135 | function handleMouseLeave() { |
133 | | - isPanning = false; |
134 | | - if (!dragNodeId) { |
| 136 | + // Don't stop panning/dragging on leave — let global handlers track outside the viewport |
| 137 | + // Only stop if not actively interacting |
| 138 | + if (!dragNodeId && !isPanning) { |
135 | 139 | isInteracting = false; |
136 | 140 | } |
137 | 141 | tooltipNode = null; |
|
141 | 145 | zoom = 1; |
142 | 146 | panX = 0; |
143 | 147 | panY = 0; |
144 | | - dragOffsets = {}; |
| 148 | + dragOffsets.clear(); |
145 | 149 | } |
146 | 150 |
|
147 | 151 | function zoomIn() { |
|
191 | 195 | dragStart = getWorldPoint(e); |
192 | 196 | dragStartScreen = { x: e.clientX, y: e.clientY }; |
193 | 197 | dragNodeStart = { x: visNode.x, y: visNode.y }; |
194 | | - const offset = dragOffsets[visNode.node.id] ?? { x: 0, y: 0 }; |
| 198 | + const offset = dragOffsets.get(visNode.node.id) ?? { x: 0, y: 0 }; |
195 | 199 | dragBasePos = { x: visNode.x - offset.x, y: visNode.y - offset.y }; |
196 | 200 | } |
197 | 201 |
|
|
208 | 212 | const dy = world.y - dragStart.y; |
209 | 213 | const nextX = dragNodeStart.x + dx; |
210 | 214 | const nextY = dragNodeStart.y + dy; |
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; |
| 215 | + perf.frame('interact', 'dragOffset.set', () => { |
| 216 | + dragOffsets.set(dragNodeId!, { x: nextX - dragBasePos.x, y: nextY - dragBasePos.y }); |
| 217 | + }); |
214 | 218 | }); |
215 | 219 | } |
216 | 220 |
|
|
223 | 227 | isInteracting = false; |
224 | 228 | } |
225 | 229 |
|
| 230 | + // Global handlers allow drag/pan to continue when the cursor moves outside the SVG container. |
226 | 231 | function handleGlobalMouseMove(e: MouseEvent) { |
227 | 232 | if (dragNodeId) { |
228 | 233 | updateNodeDrag(e); |
| 234 | + } else if (isPanning) { |
| 235 | + const cur = screenToSvg(e.clientX, e.clientY); |
| 236 | + const start = screenToSvg(panStartX, panStartY); |
| 237 | + panX = panStartPanX + (cur.x - start.x); |
| 238 | + panY = panStartPanY + (cur.y - start.y); |
229 | 239 | } |
230 | 240 | } |
231 | 241 |
|
232 | 242 | function handleGlobalMouseUp() { |
233 | 243 | if (dragNodeId) { |
234 | 244 | endNodeDrag(); |
235 | 245 | } |
| 246 | + if (isPanning) { |
| 247 | + isPanning = false; |
| 248 | + isInteracting = false; |
| 249 | + } |
236 | 250 | } |
237 | 251 |
|
238 | 252 | function showTooltip(visNode: VisNode, e: MouseEvent) { |
|
302 | 316 | let positionedNodes = $derived.by(() => { |
303 | 317 | return perf.frame('derived', 'positionedNodes', () => |
304 | 318 | baseScene.nodes.map((node) => { |
305 | | - const offset = dragOffsets[node.node.id]; |
| 319 | + const offset = dragOffsets.get(node.node.id); |
306 | 320 | if (!offset) return node; |
307 | 321 | return { ...node, x: node.x + offset.x, y: node.y + offset.y }; |
308 | 322 | }) |
|
0 commit comments