backpack-viewer 0.2.20 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/canvas.js CHANGED
@@ -1,5 +1,7 @@
1
- import { createLayout, extractSubgraph, tick } from "./layout";
1
+ import { createLayout, extractSubgraph, tick, getLayoutParams } from "./layout";
2
2
  import { getColor } from "./colors";
3
+ import { SpatialHash } from "./spatial-hash";
4
+ import { drawCachedLabel, clearLabelCache } from "./label-cache";
3
5
  /** Read a CSS custom property from :root. */
4
6
  function cssVar(name) {
5
7
  return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
@@ -7,7 +9,7 @@ function cssVar(name) {
7
9
  const NODE_RADIUS = 20;
8
10
  const ALPHA_MIN = 0.001;
9
11
  // Defaults — overridden per-instance via config
10
- const LOD_DEFAULTS = { hideBadges: 0.4, hideLabels: 0.25, hideEdgeLabels: 0.35, smallNodes: 0.2, hideArrows: 0.15 };
12
+ const LOD_DEFAULTS = { hideBadges: 0.4, hideLabels: 0.25, hideEdgeLabels: 0.35, smallNodes: 0.2, hideArrows: 0.15, dotNodes: 0.1, hullsOnly: 0.05 };
11
13
  const NAV_DEFAULTS = { zoomFactor: 1.3, zoomMin: 0.05, zoomMax: 10, panAnimationMs: 300 };
12
14
  /** Check if a point is within the visible viewport (with padding). */
13
15
  function isInViewport(x, y, camera, canvasW, canvasH, pad = 100) {
@@ -18,6 +20,7 @@ function isInViewport(x, y, camera, canvasW, canvasH, pad = 100) {
18
20
  export function initCanvas(container, onNodeClick, onFocusChange, config) {
19
21
  const lod = { ...LOD_DEFAULTS, ...(config?.lod ?? {}) };
20
22
  const nav = { ...NAV_DEFAULTS, ...(config?.navigation ?? {}) };
23
+ const walkCfg = { pulseSpeed: 0.02, ...(config?.walk ?? {}) };
21
24
  const canvas = container.querySelector("canvas");
22
25
  const ctx = canvas.getContext("2d");
23
26
  const dpr = window.devicePixelRatio || 1;
@@ -37,6 +40,79 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
37
40
  let focusHops = 1;
38
41
  let savedFullState = null;
39
42
  let savedFullCamera = null;
43
+ // Highlighted path state (for path finding visualization)
44
+ let highlightedPath = null;
45
+ // Walk mode state
46
+ let walkMode = false;
47
+ let walkTrail = []; // node IDs visited during walk, most recent last
48
+ let pulsePhase = 0; // animation counter for pulse effect
49
+ // Entrance animation state
50
+ let loadTime = 0;
51
+ const ENTRANCE_DURATION = 400; // ms
52
+ // Spatial hash for O(1) hit testing — cell size = 2× node radius
53
+ const nodeHash = new SpatialHash(NODE_RADIUS * 2);
54
+ // Render coalescing — multiple requestRedraw() calls per frame result in one render()
55
+ let renderPending = 0;
56
+ function requestRedraw() {
57
+ invalidateSceneCache();
58
+ if (!renderPending)
59
+ renderPending = requestAnimationFrame(() => { renderPending = 0; render(); });
60
+ }
61
+ // Layout Web Worker — offloads physics simulation from the main thread.
62
+ // For small graphs (< WORKER_THRESHOLD nodes), runs on main thread to avoid overhead.
63
+ const WORKER_THRESHOLD = 150;
64
+ let layoutWorker = null;
65
+ let useWorker = false;
66
+ function getWorker() {
67
+ if (!layoutWorker) {
68
+ try {
69
+ layoutWorker = new Worker(new URL("./layout-worker.js", import.meta.url), { type: "module" });
70
+ layoutWorker.onmessage = onWorkerMessage;
71
+ layoutWorker.onerror = () => {
72
+ // Worker failed to load — fall back to main-thread layout
73
+ useWorker = false;
74
+ layoutWorker = null;
75
+ simulate();
76
+ };
77
+ }
78
+ catch {
79
+ useWorker = false;
80
+ return null;
81
+ }
82
+ }
83
+ return layoutWorker;
84
+ }
85
+ function onWorkerMessage(e) {
86
+ const msg = e.data;
87
+ if (msg.type === "tick" && state) {
88
+ const positions = msg.positions;
89
+ const nodes = state.nodes;
90
+ for (let i = 0; i < nodes.length; i++) {
91
+ nodes[i].x = positions[i * 4];
92
+ nodes[i].y = positions[i * 4 + 1];
93
+ nodes[i].vx = positions[i * 4 + 2];
94
+ nodes[i].vy = positions[i * 4 + 3];
95
+ }
96
+ alpha = msg.alpha;
97
+ nodeHash.rebuild(nodes);
98
+ render();
99
+ }
100
+ if (msg.type === "settled") {
101
+ alpha = 0;
102
+ if (walkMode && walkTrail.length > 0 && !walkAnimFrame) {
103
+ walkAnimFrame = requestAnimationFrame(walkAnimate);
104
+ }
105
+ }
106
+ }
107
+ // Scene cache — avoids full redraws during walk pulse animation.
108
+ // When the graph is settled, we snapshot the rendered scene (minus walk effects)
109
+ // to an OffscreenCanvas. Walk animate then composites cache + draws pulse overlay.
110
+ let sceneCache = null;
111
+ let sceneCacheCtx = null;
112
+ let sceneCacheDirty = true;
113
+ function invalidateSceneCache() {
114
+ sceneCacheDirty = true;
115
+ }
40
116
  // Pan animation state
41
117
  let panTarget = null;
42
118
  let panStart = null;
@@ -45,7 +121,8 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
45
121
  function resize() {
46
122
  canvas.width = canvas.clientWidth * dpr;
47
123
  canvas.height = canvas.clientHeight * dpr;
48
- render();
124
+ invalidateSceneCache();
125
+ requestRedraw();
49
126
  }
50
127
  const observer = new ResizeObserver(resize);
51
128
  observer.observe(container);
@@ -62,23 +139,95 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
62
139
  if (!state)
63
140
  return null;
64
141
  const [wx, wy] = screenToWorld(sx, sy);
65
- // Iterate in reverse so topmost (last drawn) nodes are hit first
66
- for (let i = state.nodes.length - 1; i >= 0; i--) {
67
- const node = state.nodes[i];
68
- const dx = wx - node.x;
69
- const dy = wy - node.y;
70
- if (dx * dx + dy * dy <= NODE_RADIUS * NODE_RADIUS) {
71
- return node;
142
+ return nodeHash.query(wx, wy, NODE_RADIUS);
143
+ }
144
+ // --- Rendering ---
145
+ /** Draw only walk pulse effects (edges + node glows) + minimap on top of cached scene. */
146
+ function renderWalkOverlay() {
147
+ if (!state)
148
+ return;
149
+ pulsePhase += walkCfg.pulseSpeed;
150
+ const walkTrailSet = new Set(walkTrail);
151
+ ctx.save();
152
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
153
+ // Composite cached scene
154
+ if (sceneCache) {
155
+ ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
156
+ ctx.drawImage(sceneCache, 0, 0, canvas.clientWidth, canvas.clientHeight);
157
+ }
158
+ ctx.save();
159
+ ctx.translate(-camera.x * camera.scale, -camera.y * camera.scale);
160
+ ctx.scale(camera.scale, camera.scale);
161
+ // Walk edge pulse — redraw only walk trail edges
162
+ const walkEdgeColor = cssVar("--canvas-walk-edge") || "#1a1a1a";
163
+ const walkLines = [];
164
+ for (const edge of state.edges) {
165
+ if (!walkTrailSet.has(edge.sourceId) || !walkTrailSet.has(edge.targetId))
166
+ continue;
167
+ if (edge.sourceId === edge.targetId)
168
+ continue;
169
+ const source = state.nodeMap.get(edge.sourceId);
170
+ const target = state.nodeMap.get(edge.targetId);
171
+ if (!source || !target)
172
+ continue;
173
+ walkLines.push(source.x, source.y, target.x, target.y);
174
+ }
175
+ if (walkLines.length > 0) {
176
+ ctx.beginPath();
177
+ for (let i = 0; i < walkLines.length; i += 4) {
178
+ ctx.moveTo(walkLines[i], walkLines[i + 1]);
179
+ ctx.lineTo(walkLines[i + 2], walkLines[i + 3]);
72
180
  }
181
+ ctx.strokeStyle = walkEdgeColor;
182
+ ctx.lineWidth = 3;
183
+ ctx.globalAlpha = 0.5 + 0.5 * Math.sin(pulsePhase);
184
+ ctx.stroke();
185
+ ctx.globalAlpha = 1;
186
+ }
187
+ // Walk node glows
188
+ const r = camera.scale < lod.smallNodes ? NODE_RADIUS * 0.5 : NODE_RADIUS;
189
+ const accent = cssVar("--accent") || "#d4a27f";
190
+ for (const nodeId of walkTrail) {
191
+ const node = state.nodeMap.get(nodeId);
192
+ if (!node)
193
+ continue;
194
+ if (!isInViewport(node.x, node.y, camera, canvas.clientWidth, canvas.clientHeight))
195
+ continue;
196
+ const isCurrent = nodeId === walkTrail[walkTrail.length - 1];
197
+ const pulse = 0.5 + 0.5 * Math.sin(pulsePhase);
198
+ ctx.strokeStyle = accent;
199
+ ctx.lineWidth = isCurrent ? 3 : 2;
200
+ ctx.globalAlpha = isCurrent ? 0.5 + 0.5 * pulse : 0.3 + 0.4 * pulse;
201
+ ctx.beginPath();
202
+ ctx.arc(node.x, node.y, r + (isCurrent ? 6 : 4), 0, Math.PI * 2);
203
+ ctx.stroke();
73
204
  }
74
- return null;
205
+ ctx.globalAlpha = 1;
206
+ ctx.restore();
207
+ // Minimap
208
+ if (showMinimap && state.nodes.length > 1) {
209
+ drawMinimap();
210
+ }
211
+ ctx.restore();
75
212
  }
76
- // --- Rendering ---
77
213
  function render() {
78
214
  if (!state) {
79
215
  ctx.clearRect(0, 0, canvas.width, canvas.height);
80
216
  return;
81
217
  }
218
+ // Fast path: walk-only animation with valid scene cache
219
+ if (!sceneCacheDirty && sceneCache && walkMode && walkTrail.length > 0 && alpha < ALPHA_MIN) {
220
+ renderWalkOverlay();
221
+ return;
222
+ }
223
+ // Determine if we should cache the scene (settled + walk mode active).
224
+ // When caching, we skip walk effects so the cache is a clean base layer.
225
+ const shouldCache = alpha < ALPHA_MIN && walkMode && walkTrail.length > 0;
226
+ // Advance pulse animation for walk mode
227
+ if (walkMode && walkTrail.length > 0) {
228
+ pulsePhase += walkCfg.pulseSpeed;
229
+ }
230
+ const walkTrailSet = (walkMode && !shouldCache) ? new Set(walkTrail) : null;
82
231
  // Read theme colors from CSS variables each frame
83
232
  const edgeColor = cssVar("--canvas-edge");
84
233
  const edgeHighlight = cssVar("--canvas-edge-highlight");
@@ -145,117 +294,236 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
145
294
  ctx.globalAlpha = 1;
146
295
  }
147
296
  }
148
- // Draw edges
149
- if (showEdges)
297
+ // Pre-compute neighbor set for selected nodes (avoids O(n×e) scan)
298
+ let neighborIds = null;
299
+ if (selectedNodeIds.size > 0) {
300
+ neighborIds = new Set();
301
+ for (const edge of state.edges) {
302
+ if (selectedNodeIds.has(edge.sourceId))
303
+ neighborIds.add(edge.targetId);
304
+ if (selectedNodeIds.has(edge.targetId))
305
+ neighborIds.add(edge.sourceId);
306
+ }
307
+ }
308
+ const accent = cssVar("--accent") || "#d4a27f";
309
+ const walkEdgeColor = cssVar("--canvas-walk-edge") || "#1a1a1a";
310
+ const drawArrows = camera.scale >= lod.hideArrows;
311
+ const drawEdgeLabelsThisFrame = showEdgeLabels && camera.scale >= lod.hideEdgeLabels;
312
+ // Draw edges — batched by visual state to minimize Canvas state changes
313
+ if (showEdges) {
314
+ // Classify edges into batches by visual style
315
+ const normalBatch = [];
316
+ const highlightBatch = [];
317
+ const dimBatch = [];
318
+ const walkBatch = [];
319
+ const pathBatch = [];
320
+ const deferred = [];
150
321
  for (const edge of state.edges) {
151
322
  const source = state.nodeMap.get(edge.sourceId);
152
323
  const target = state.nodeMap.get(edge.targetId);
153
324
  if (!source || !target)
154
325
  continue;
155
- // Viewport culling — skip if both endpoints are off-screen
326
+ // Viewport culling
156
327
  if (!isInViewport(source.x, source.y, camera, canvas.clientWidth, canvas.clientHeight, 200) &&
157
328
  !isInViewport(target.x, target.y, camera, canvas.clientWidth, canvas.clientHeight, 200))
158
329
  continue;
159
330
  const sourceMatch = filteredNodeIds === null || filteredNodeIds.has(edge.sourceId);
160
331
  const targetMatch = filteredNodeIds === null || filteredNodeIds.has(edge.targetId);
161
332
  const bothMatch = sourceMatch && targetMatch;
162
- // Hide edges where neither endpoint matches the filter
163
333
  if (filteredNodeIds !== null && !sourceMatch && !targetMatch)
164
334
  continue;
165
335
  const isConnected = selectedNodeIds.size > 0 &&
166
336
  (selectedNodeIds.has(edge.sourceId) || selectedNodeIds.has(edge.targetId));
167
337
  const highlighted = isConnected || (filteredNodeIds !== null && bothMatch);
168
338
  const edgeDimmed = filteredNodeIds !== null && !bothMatch;
339
+ const isWalkEdge = walkTrailSet !== null && walkTrailSet.has(edge.sourceId) && walkTrailSet.has(edge.targetId);
340
+ const fullEdge = highlightedPath ? lastLoadedData?.edges.find(e => (e.sourceId === edge.sourceId && e.targetId === edge.targetId) ||
341
+ (e.targetId === edge.sourceId && e.sourceId === edge.targetId)) : null;
342
+ const isPathEdge = !!(highlightedPath && fullEdge && highlightedPath.edgeIds.has(fullEdge.id));
169
343
  // Self-loop
170
344
  if (edge.sourceId === edge.targetId) {
171
345
  drawSelfLoop(source, edge.type, highlighted, edgeColor, edgeHighlight, edgeLabel, edgeLabelHighlight);
172
346
  continue;
173
347
  }
174
- // Line
348
+ // Sort into batch by visual state
349
+ const batch = isPathEdge ? pathBatch
350
+ : isWalkEdge ? walkBatch
351
+ : highlighted ? highlightBatch
352
+ : edgeDimmed ? dimBatch
353
+ : normalBatch;
354
+ batch.push(source.x, source.y, target.x, target.y);
355
+ // Queue deferred work (arrowheads, labels)
356
+ if (drawArrows || drawEdgeLabelsThisFrame) {
357
+ deferred.push({ sx: source.x, sy: source.y, tx: target.x, ty: target.y, type: edge.type, highlighted, edgeDimmed, isPathEdge, isWalkEdge });
358
+ }
359
+ }
360
+ // Stroke each batch with one beginPath/stroke pair
361
+ const normalWidth = drawArrows ? 1.5 : 1;
362
+ const highlightWidth = drawArrows ? 2.5 : 1;
363
+ const batches = [
364
+ { lines: normalBatch, color: edgeColor, width: normalWidth, alpha: 1 },
365
+ { lines: dimBatch, color: edgeDimColor, width: normalWidth, alpha: 1 },
366
+ { lines: highlightBatch, color: edgeHighlight, width: highlightWidth, alpha: 1 },
367
+ { lines: pathBatch, color: accent, width: 3, alpha: 1 },
368
+ { lines: walkBatch, color: walkEdgeColor, width: 3, alpha: 0.5 + 0.5 * Math.sin(pulsePhase) },
369
+ ];
370
+ for (const b of batches) {
371
+ if (b.lines.length === 0)
372
+ continue;
175
373
  ctx.beginPath();
176
- ctx.moveTo(source.x, source.y);
177
- ctx.lineTo(target.x, target.y);
178
- ctx.strokeStyle = highlighted
179
- ? edgeHighlight
180
- : edgeDimmed
181
- ? edgeDimColor
182
- : edgeColor;
183
- ctx.lineWidth = camera.scale < lod.hideArrows ? 1 : highlighted ? 2.5 : 1.5;
374
+ for (let i = 0; i < b.lines.length; i += 4) {
375
+ ctx.moveTo(b.lines[i], b.lines[i + 1]);
376
+ ctx.lineTo(b.lines[i + 2], b.lines[i + 3]);
377
+ }
378
+ ctx.strokeStyle = b.color;
379
+ ctx.lineWidth = b.width;
380
+ ctx.globalAlpha = b.alpha;
184
381
  ctx.stroke();
185
- // Arrowhead
186
- if (camera.scale >= lod.hideArrows) {
187
- drawArrowhead(source.x, source.y, target.x, target.y, highlighted, arrowColor, arrowHighlight);
382
+ }
383
+ ctx.globalAlpha = 1;
384
+ // Draw arrowheads and labels (can't batch each needs individual positioning)
385
+ for (const d of deferred) {
386
+ if (drawArrows) {
387
+ drawArrowhead(d.sx, d.sy, d.tx, d.ty, d.highlighted || d.isPathEdge, arrowColor, arrowHighlight);
188
388
  }
189
- // Edge label at midpoint
190
- if (showEdgeLabels && camera.scale >= lod.hideEdgeLabels) {
191
- const mx = (source.x + target.x) / 2;
192
- const my = (source.y + target.y) / 2;
193
- ctx.fillStyle = highlighted
389
+ if (drawEdgeLabelsThisFrame) {
390
+ const mx = (d.sx + d.tx) / 2;
391
+ const my = (d.sy + d.ty) / 2;
392
+ ctx.fillStyle = d.highlighted
194
393
  ? edgeLabelHighlight
195
- : edgeDimmed
394
+ : d.edgeDimmed
196
395
  ? edgeLabelDim
197
396
  : edgeLabel;
198
397
  ctx.font = "9px system-ui, sans-serif";
199
398
  ctx.textAlign = "center";
200
399
  ctx.textBaseline = "bottom";
201
- ctx.fillText(edge.type, mx, my - 4);
400
+ ctx.fillText(d.type, mx, my - 4);
202
401
  }
203
402
  }
204
- // Draw nodes
205
- for (const node of state.nodes) {
206
- // Viewport culling
207
- if (!isInViewport(node.x, node.y, camera, canvas.clientWidth, canvas.clientHeight))
208
- continue;
209
- const color = getColor(node.type);
210
- const isSelected = selectedNodeIds.has(node.id);
211
- const isNeighbor = selectedNodeIds.size > 0 &&
212
- state.edges.some((e) => (selectedNodeIds.has(e.sourceId) && e.targetId === node.id) ||
213
- (selectedNodeIds.has(e.targetId) && e.sourceId === node.id));
214
- const filteredOut = filteredNodeIds !== null && !filteredNodeIds.has(node.id);
215
- const dimmed = filteredOut ||
216
- (selectedNodeIds.size > 0 && !isSelected && !isNeighbor);
217
- const r = camera.scale < lod.smallNodes ? NODE_RADIUS * 0.5 : NODE_RADIUS;
218
- // Glow for selected node
219
- if (isSelected) {
220
- ctx.save();
221
- ctx.shadowColor = color;
222
- ctx.shadowBlur = 20;
403
+ }
404
+ // Entrance animation fade/scale nodes in over ENTRANCE_DURATION ms
405
+ const entranceElapsed = performance.now() - loadTime;
406
+ const entranceT = Math.min(1, entranceElapsed / ENTRANCE_DURATION);
407
+ const entranceProgress = 1 - (1 - entranceT) * (1 - entranceT); // ease-out quad
408
+ const isEntering = entranceT < 1;
409
+ // Draw nodes — skip entirely at extreme zoom-out (hulls-only mode)
410
+ const hullsOnlyMode = camera.scale < lod.hullsOnly;
411
+ const dotMode = !hullsOnlyMode && camera.scale < lod.dotNodes;
412
+ if (!hullsOnlyMode)
413
+ for (const node of state.nodes) {
414
+ // Viewport culling
415
+ if (!isInViewport(node.x, node.y, camera, canvas.clientWidth, canvas.clientHeight))
416
+ continue;
417
+ const color = getColor(node.type);
418
+ // Dot mode — render as single-pixel colored dots, skip all decorations
419
+ if (dotMode) {
420
+ const filteredOut = filteredNodeIds !== null && !filteredNodeIds.has(node.id);
421
+ ctx.fillStyle = color;
422
+ const dotAlpha = filteredOut ? 0.1 : 0.8;
423
+ ctx.globalAlpha = isEntering ? dotAlpha * entranceProgress : dotAlpha;
424
+ ctx.fillRect(node.x - 2, node.y - 2, 4, 4);
425
+ continue;
426
+ }
427
+ const isSelected = selectedNodeIds.has(node.id);
428
+ const isNeighbor = neighborIds !== null && neighborIds.has(node.id);
429
+ const filteredOut = filteredNodeIds !== null && !filteredNodeIds.has(node.id);
430
+ const dimmed = filteredOut ||
431
+ (selectedNodeIds.size > 0 && !isSelected && !isNeighbor);
432
+ const baseR = camera.scale < lod.smallNodes ? NODE_RADIUS * 0.5 : NODE_RADIUS;
433
+ const r = isEntering ? baseR * entranceProgress : baseR;
434
+ // Walk trail effect — all visited nodes pulse together
435
+ if (walkTrailSet?.has(node.id)) {
436
+ const isCurrent = walkTrail[walkTrail.length - 1] === node.id;
437
+ const pulse = 0.5 + 0.5 * Math.sin(pulsePhase);
438
+ const accent = cssVar("--accent") || "#d4a27f";
439
+ ctx.save();
440
+ ctx.strokeStyle = accent;
441
+ ctx.lineWidth = isCurrent ? 3 : 2;
442
+ ctx.globalAlpha = isCurrent ? 0.5 + 0.5 * pulse : 0.3 + 0.4 * pulse;
443
+ ctx.beginPath();
444
+ ctx.arc(node.x, node.y, r + (isCurrent ? 6 : 4), 0, Math.PI * 2);
445
+ ctx.stroke();
446
+ ctx.restore();
447
+ }
448
+ // Glow for selected node
449
+ if (isSelected) {
450
+ ctx.save();
451
+ ctx.shadowColor = color;
452
+ ctx.shadowBlur = 20;
453
+ ctx.beginPath();
454
+ ctx.arc(node.x, node.y, r + 3, 0, Math.PI * 2);
455
+ ctx.fillStyle = color;
456
+ ctx.globalAlpha = 0.3;
457
+ ctx.fill();
458
+ ctx.restore();
459
+ }
460
+ // Circle
223
461
  ctx.beginPath();
224
- ctx.arc(node.x, node.y, r + 3, 0, Math.PI * 2);
462
+ ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
225
463
  ctx.fillStyle = color;
226
- ctx.globalAlpha = 0.3;
464
+ const baseAlpha = filteredOut ? 0.1 : dimmed ? 0.3 : 1;
465
+ ctx.globalAlpha = isEntering ? baseAlpha * entranceProgress : baseAlpha;
227
466
  ctx.fill();
228
- ctx.restore();
229
- }
230
- // Circle
231
- ctx.beginPath();
232
- ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
233
- ctx.fillStyle = color;
234
- ctx.globalAlpha = filteredOut ? 0.1 : dimmed ? 0.3 : 1;
235
- ctx.fill();
236
- ctx.strokeStyle = isSelected ? selectionBorder : nodeBorder;
237
- ctx.lineWidth = isSelected ? 3 : 1.5;
238
- ctx.stroke();
239
- // Label below
240
- if (camera.scale >= lod.hideLabels) {
241
- const label = node.label.length > 24 ? node.label.slice(0, 22) + "..." : node.label;
242
- ctx.fillStyle = dimmed ? nodeLabelDim : nodeLabel;
243
- ctx.font = "11px system-ui, sans-serif";
244
- ctx.textAlign = "center";
245
- ctx.textBaseline = "top";
246
- ctx.fillText(label, node.x, node.y + r + 4);
247
- }
248
- // Type badge above
249
- if (camera.scale >= lod.hideBadges) {
250
- ctx.fillStyle = dimmed ? typeBadgeDim : typeBadge;
251
- ctx.font = "9px system-ui, sans-serif";
252
- ctx.textBaseline = "bottom";
253
- ctx.fillText(node.type, node.x, node.y - r - 3);
467
+ ctx.strokeStyle = isSelected ? selectionBorder : nodeBorder;
468
+ ctx.lineWidth = isSelected ? 3 : 1.5;
469
+ ctx.stroke();
470
+ ctx.globalAlpha = 1;
471
+ // Highlighted path glow
472
+ if (highlightedPath && highlightedPath.nodeIds.has(node.id) && !isSelected) {
473
+ ctx.save();
474
+ ctx.shadowColor = cssVar("--accent") || "#d4a27f";
475
+ ctx.shadowBlur = 15;
476
+ ctx.beginPath();
477
+ ctx.arc(node.x, node.y, r + 2, 0, Math.PI * 2);
478
+ ctx.strokeStyle = cssVar("--accent") || "#d4a27f";
479
+ ctx.globalAlpha = 0.5;
480
+ ctx.lineWidth = 2;
481
+ ctx.stroke();
482
+ ctx.restore();
483
+ }
484
+ // Star indicator for starred nodes
485
+ const originalNode = lastLoadedData?.nodes.find(n => n.id === node.id);
486
+ const isStarred = originalNode?.properties?._starred === true;
487
+ if (isStarred) {
488
+ ctx.fillStyle = "#ffd700";
489
+ ctx.font = "10px system-ui, sans-serif";
490
+ ctx.textAlign = "left";
491
+ ctx.textBaseline = "bottom";
492
+ ctx.fillText("\u2605", node.x + r - 2, node.y - r + 2);
493
+ }
494
+ // Label below (cached offscreen)
495
+ if (camera.scale >= lod.hideLabels) {
496
+ const label = node.label.length > 24 ? node.label.slice(0, 22) + "..." : node.label;
497
+ const labelColor = dimmed ? nodeLabelDim : nodeLabel;
498
+ drawCachedLabel(ctx, label, node.x, node.y + r + 4, "11px system-ui, sans-serif", labelColor, "top");
499
+ }
500
+ // Type badge above (cached offscreen)
501
+ if (camera.scale >= lod.hideBadges) {
502
+ const badgeColor = dimmed ? typeBadgeDim : typeBadge;
503
+ drawCachedLabel(ctx, node.type, node.x, node.y - r - 3, "9px system-ui, sans-serif", badgeColor, "bottom");
504
+ }
505
+ ctx.globalAlpha = 1;
254
506
  }
255
- ctx.globalAlpha = 1;
256
- }
257
507
  ctx.restore();
258
508
  ctx.restore();
509
+ // Snapshot scene to cache BEFORE walk overlay and minimap.
510
+ // The cache contains the clean scene (hulls + edges + nodes) without walk effects.
511
+ if (shouldCache) {
512
+ const w = canvas.width;
513
+ const h = canvas.height;
514
+ if (!sceneCache || sceneCache.width !== w || sceneCache.height !== h) {
515
+ sceneCache = new OffscreenCanvas(w, h);
516
+ sceneCacheCtx = sceneCache.getContext("2d");
517
+ }
518
+ if (sceneCacheCtx) {
519
+ sceneCacheCtx.clearRect(0, 0, w, h);
520
+ sceneCacheCtx.drawImage(canvas, 0, 0);
521
+ sceneCacheDirty = false;
522
+ }
523
+ // Now draw walk effects + minimap on top for this frame
524
+ renderWalkOverlay();
525
+ return;
526
+ }
259
527
  // Minimap
260
528
  if (showMinimap && state.nodes.length > 1) {
261
529
  drawMinimap();
@@ -376,6 +644,7 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
376
644
  const ease = 1 - Math.pow(1 - t, 3);
377
645
  camera.x = panStart.x + (panTarget.x - panStart.x) * ease;
378
646
  camera.y = panStart.y + (panTarget.y - panStart.y) * ease;
647
+ invalidateSceneCache();
379
648
  render();
380
649
  if (t < 1) {
381
650
  requestAnimationFrame(animatePan);
@@ -385,10 +654,49 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
385
654
  panStart = null;
386
655
  }
387
656
  }
657
+ let walkAnimFrame = 0;
658
+ function walkAnimate() {
659
+ if (!walkMode || walkTrail.length === 0) {
660
+ walkAnimFrame = 0;
661
+ return;
662
+ }
663
+ render();
664
+ walkAnimFrame = requestAnimationFrame(walkAnimate);
665
+ }
666
+ function fitToNodes() {
667
+ if (!state || state.nodes.length === 0)
668
+ return;
669
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
670
+ for (const n of state.nodes) {
671
+ if (n.x < minX)
672
+ minX = n.x;
673
+ if (n.y < minY)
674
+ minY = n.y;
675
+ if (n.x > maxX)
676
+ maxX = n.x;
677
+ if (n.y > maxY)
678
+ maxY = n.y;
679
+ }
680
+ const pad = NODE_RADIUS * 4;
681
+ const graphW = (maxX - minX) + pad * 2;
682
+ const graphH = (maxY - minY) + pad * 2;
683
+ const scaleX = canvas.clientWidth / Math.max(graphW, 1);
684
+ const scaleY = canvas.clientHeight / Math.max(graphH, 1);
685
+ camera.scale = Math.min(scaleX, scaleY, 2);
686
+ camera.x = (minX + maxX) / 2 - canvas.clientWidth / (2 * camera.scale);
687
+ camera.y = (minY + maxY) / 2 - canvas.clientHeight / (2 * camera.scale);
688
+ requestRedraw();
689
+ }
388
690
  function simulate() {
389
- if (!state || alpha < ALPHA_MIN)
691
+ if (!state || alpha < ALPHA_MIN) {
692
+ // Start walk animation loop if simulation stopped but walk mode is active
693
+ if (walkMode && walkTrail.length > 0 && !walkAnimFrame) {
694
+ walkAnimFrame = requestAnimationFrame(walkAnimate);
695
+ }
390
696
  return;
697
+ }
391
698
  alpha = tick(state, alpha);
699
+ nodeHash.rebuild(state.nodes);
392
700
  render();
393
701
  animFrame = requestAnimationFrame(simulate);
394
702
  }
@@ -408,13 +716,13 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
408
716
  return;
409
717
  const dx = e.clientX - lastX;
410
718
  const dy = e.clientY - lastY;
411
- if (Math.abs(dx) > 2 || Math.abs(dy) > 2)
719
+ if (Math.abs(dx) > 5 || Math.abs(dy) > 5)
412
720
  didDrag = true;
413
721
  camera.x -= dx / camera.scale;
414
722
  camera.y -= dy / camera.scale;
415
723
  lastX = e.clientX;
416
724
  lastY = e.clientY;
417
- render();
725
+ requestRedraw();
418
726
  });
419
727
  canvas.addEventListener("mouseup", (e) => {
420
728
  dragging = false;
@@ -426,6 +734,71 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
426
734
  const my = e.clientY - rect.top;
427
735
  const hit = nodeAtScreen(mx, my);
428
736
  const multiSelect = e.ctrlKey || e.metaKey;
737
+ if (walkMode && focusSeedIds && hit && state) {
738
+ // Walk mode: find path from current position to clicked node
739
+ const currentId = walkTrail.length > 0 ? walkTrail[walkTrail.length - 1] : focusSeedIds[0];
740
+ // BFS in the current subgraph to find path
741
+ const visited = new Set([currentId]);
742
+ const queue = [{ id: currentId, path: [currentId] }];
743
+ let pathToTarget = null;
744
+ while (queue.length > 0) {
745
+ const { id, path } = queue.shift();
746
+ if (id === hit.id) {
747
+ pathToTarget = path;
748
+ break;
749
+ }
750
+ for (const edge of state.edges) {
751
+ let neighbor = null;
752
+ if (edge.sourceId === id)
753
+ neighbor = edge.targetId;
754
+ else if (edge.targetId === id)
755
+ neighbor = edge.sourceId;
756
+ if (neighbor && !visited.has(neighbor)) {
757
+ visited.add(neighbor);
758
+ queue.push({ id: neighbor, path: [...path, neighbor] });
759
+ }
760
+ }
761
+ }
762
+ // No path found — node is unreachable, ignore click
763
+ if (!pathToTarget)
764
+ return;
765
+ // Add all intermediate nodes to the trail (skip first since it's already the current position)
766
+ for (const id of pathToTarget.slice(1)) {
767
+ if (!walkTrail.includes(id))
768
+ walkTrail.push(id);
769
+ }
770
+ focusSeedIds = [hit.id];
771
+ const walkHops = Math.max(1, focusHops);
772
+ focusHops = walkHops;
773
+ const subgraph = extractSubgraph(lastLoadedData, [hit.id], walkHops);
774
+ cancelAnimationFrame(animFrame);
775
+ if (layoutWorker)
776
+ layoutWorker.postMessage({ type: "stop" });
777
+ state = createLayout(subgraph);
778
+ nodeHash.rebuild(state.nodes);
779
+ alpha = 1;
780
+ selectedNodeIds = new Set([hit.id]);
781
+ filteredNodeIds = null;
782
+ camera = { x: 0, y: 0, scale: 1 };
783
+ useWorker = subgraph.nodes.length >= WORKER_THRESHOLD;
784
+ const w = useWorker ? getWorker() : null;
785
+ if (w) {
786
+ w.postMessage({ type: "start", data: subgraph });
787
+ }
788
+ else {
789
+ useWorker = false;
790
+ simulate();
791
+ }
792
+ // Center after physics settle
793
+ setTimeout(() => {
794
+ if (!state)
795
+ return;
796
+ fitToNodes();
797
+ }, 300);
798
+ onFocusChange?.({ seedNodeIds: [hit.id], hops: walkHops, totalNodes: subgraph.nodes.length });
799
+ onNodeClick?.([hit.id]);
800
+ return; // skip normal selection
801
+ }
429
802
  if (hit) {
430
803
  if (multiSelect) {
431
804
  // Toggle node in/out of multi-selection
@@ -453,7 +826,7 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
453
826
  selectedNodeIds.clear();
454
827
  onNodeClick?.(null);
455
828
  }
456
- render();
829
+ requestRedraw();
457
830
  });
458
831
  canvas.addEventListener("mouseleave", () => {
459
832
  dragging = false;
@@ -473,7 +846,7 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
473
846
  camera.scale = Math.max(nav.zoomMin, Math.min(nav.zoomMax, camera.scale * factor));
474
847
  camera.x = wx - mx / camera.scale;
475
848
  camera.y = wy - my / camera.scale;
476
- render();
849
+ requestRedraw();
477
850
  }, { passive: false });
478
851
  // --- Interaction: Touch (pinch zoom + drag) ---
479
852
  let touches = [];
@@ -504,7 +877,7 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
504
877
  const dist = touchDistance(current[0], current[1]);
505
878
  const ratio = dist / initialPinchDist;
506
879
  camera.scale = Math.max(nav.zoomMin, Math.min(nav.zoomMax, initialPinchScale * ratio));
507
- render();
880
+ requestRedraw();
508
881
  }
509
882
  else if (current.length === 1) {
510
883
  const dx = current[0].clientX - lastX;
@@ -517,7 +890,7 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
517
890
  camera.y -= dy / camera.scale;
518
891
  lastX = current[0].clientX;
519
892
  lastY = current[0].clientY;
520
- render();
893
+ requestRedraw();
521
894
  }
522
895
  touches = current;
523
896
  }, { passive: false });
@@ -545,7 +918,7 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
545
918
  selectedNodeIds.clear();
546
919
  onNodeClick?.(null);
547
920
  }
548
- render();
921
+ requestRedraw();
549
922
  }, { passive: false });
550
923
  // Prevent Safari page-level pinch zoom on the canvas
551
924
  canvas.addEventListener("gesturestart", (e) => e.preventDefault());
@@ -569,7 +942,7 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
569
942
  camera.scale = Math.min(nav.zoomMax, camera.scale * nav.zoomFactor);
570
943
  camera.x = wx - cx / camera.scale;
571
944
  camera.y = wy - cy / camera.scale;
572
- render();
945
+ requestRedraw();
573
946
  });
574
947
  const zoomOutBtn = document.createElement("button");
575
948
  zoomOutBtn.className = "zoom-btn";
@@ -582,7 +955,7 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
582
955
  camera.scale = Math.max(nav.zoomMin, camera.scale / nav.zoomFactor);
583
956
  camera.x = wx - cx / camera.scale;
584
957
  camera.y = wy - cy / camera.scale;
585
- render();
958
+ requestRedraw();
586
959
  });
587
960
  const zoomResetBtn = document.createElement("button");
588
961
  zoomResetBtn.className = "zoom-btn";
@@ -609,22 +982,77 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
609
982
  camera.x = cx - canvas.clientWidth / 2;
610
983
  camera.y = cy - canvas.clientHeight / 2;
611
984
  }
612
- render();
985
+ requestRedraw();
613
986
  });
614
987
  zoomControls.appendChild(zoomInBtn);
615
988
  zoomControls.appendChild(zoomResetBtn);
616
989
  zoomControls.appendChild(zoomOutBtn);
617
990
  container.appendChild(zoomControls);
991
+ // --- Hover tooltip ---
992
+ const tooltip = document.createElement("div");
993
+ tooltip.className = "node-tooltip";
994
+ tooltip.style.display = "none";
995
+ container.appendChild(tooltip);
996
+ let hoverNodeId = null;
997
+ let hoverTimeout = null;
998
+ canvas.addEventListener("mousemove", (e) => {
999
+ if (dragging) {
1000
+ if (tooltip.style.display !== "none") {
1001
+ tooltip.style.display = "none";
1002
+ hoverNodeId = null;
1003
+ }
1004
+ return;
1005
+ }
1006
+ const rect = canvas.getBoundingClientRect();
1007
+ const mx = e.clientX - rect.left;
1008
+ const my = e.clientY - rect.top;
1009
+ const hit = nodeAtScreen(mx, my);
1010
+ const hitId = hit?.id ?? null;
1011
+ if (hitId !== hoverNodeId) {
1012
+ hoverNodeId = hitId;
1013
+ tooltip.style.display = "none";
1014
+ if (hoverTimeout)
1015
+ clearTimeout(hoverTimeout);
1016
+ hoverTimeout = null;
1017
+ if (hitId && hit) {
1018
+ hoverTimeout = setTimeout(() => {
1019
+ if (!state || !lastLoadedData)
1020
+ return;
1021
+ const edgeCount = state.edges.filter((edge) => edge.sourceId === hitId || edge.targetId === hitId).length;
1022
+ tooltip.textContent = `${hit.label} · ${hit.type} · ${edgeCount} edge${edgeCount !== 1 ? "s" : ""}`;
1023
+ tooltip.style.left = `${e.clientX - rect.left + 12}px`;
1024
+ tooltip.style.top = `${e.clientY - rect.top - 8}px`;
1025
+ tooltip.style.display = "block";
1026
+ }, 200);
1027
+ }
1028
+ }
1029
+ else if (hitId && tooltip.style.display === "block") {
1030
+ tooltip.style.left = `${e.clientX - rect.left + 12}px`;
1031
+ tooltip.style.top = `${e.clientY - rect.top - 8}px`;
1032
+ }
1033
+ });
1034
+ canvas.addEventListener("mouseleave", () => {
1035
+ tooltip.style.display = "none";
1036
+ hoverNodeId = null;
1037
+ if (hoverTimeout)
1038
+ clearTimeout(hoverTimeout);
1039
+ hoverTimeout = null;
1040
+ });
618
1041
  // --- Public API ---
619
1042
  return {
620
1043
  loadGraph(data) {
621
1044
  cancelAnimationFrame(animFrame);
1045
+ if (layoutWorker)
1046
+ layoutWorker.postMessage({ type: "stop" });
1047
+ clearLabelCache();
622
1048
  lastLoadedData = data;
623
1049
  // Exit any active focus when full graph reloads
624
1050
  focusSeedIds = null;
625
1051
  savedFullState = null;
626
1052
  savedFullCamera = null;
1053
+ loadTime = performance.now();
627
1054
  state = createLayout(data);
1055
+ nodeHash.rebuild(state.nodes);
628
1056
  alpha = 1;
629
1057
  selectedNodeIds = new Set();
630
1058
  filteredNodeIds = null;
@@ -649,11 +1077,20 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
649
1077
  camera.x = cx - w / 2;
650
1078
  camera.y = cy - h / 2;
651
1079
  }
652
- simulate();
1080
+ // Use worker for large graphs, main thread for small ones
1081
+ useWorker = data.nodes.length >= WORKER_THRESHOLD;
1082
+ const w = useWorker ? getWorker() : null;
1083
+ if (w) {
1084
+ w.postMessage({ type: "start", data });
1085
+ }
1086
+ else {
1087
+ useWorker = false;
1088
+ simulate();
1089
+ }
653
1090
  },
654
1091
  setFilteredNodeIds(ids) {
655
1092
  filteredNodeIds = ids;
656
- render();
1093
+ requestRedraw();
657
1094
  },
658
1095
  panToNode(nodeId) {
659
1096
  this.panToNodes([nodeId]);
@@ -705,45 +1142,27 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
705
1142
  },
706
1143
  setEdges(visible) {
707
1144
  showEdges = visible;
708
- render();
1145
+ requestRedraw();
709
1146
  },
710
1147
  setEdgeLabels(visible) {
711
1148
  showEdgeLabels = visible;
712
- render();
1149
+ requestRedraw();
713
1150
  },
714
1151
  setTypeHulls(visible) {
715
1152
  showTypeHulls = visible;
716
- render();
1153
+ requestRedraw();
717
1154
  },
718
1155
  setMinimap(visible) {
719
1156
  showMinimap = visible;
720
- render();
1157
+ requestRedraw();
721
1158
  },
722
1159
  centerView() {
723
- if (!state)
724
- return;
725
- camera = { x: 0, y: 0, scale: 1 };
726
- if (state.nodes.length > 0) {
727
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
728
- for (const n of state.nodes) {
729
- if (n.x < minX)
730
- minX = n.x;
731
- if (n.y < minY)
732
- minY = n.y;
733
- if (n.x > maxX)
734
- maxX = n.x;
735
- if (n.y > maxY)
736
- maxY = n.y;
737
- }
738
- camera.x = (minX + maxX) / 2 - canvas.clientWidth / 2;
739
- camera.y = (minY + maxY) / 2 - canvas.clientHeight / 2;
740
- }
741
- render();
1160
+ fitToNodes();
742
1161
  },
743
1162
  panBy(dx, dy) {
744
1163
  camera.x += dx / camera.scale;
745
1164
  camera.y += dy / camera.scale;
746
- render();
1165
+ requestRedraw();
747
1166
  },
748
1167
  zoomBy(factor) {
749
1168
  const cx = canvas.clientWidth / 2;
@@ -752,12 +1171,17 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
752
1171
  camera.scale = Math.max(nav.zoomMin, Math.min(nav.zoomMax, camera.scale * factor));
753
1172
  camera.x = wx - cx / camera.scale;
754
1173
  camera.y = wy - cy / camera.scale;
755
- render();
1174
+ requestRedraw();
756
1175
  },
757
1176
  reheat() {
758
- alpha = 0.5;
759
- cancelAnimationFrame(animFrame);
760
- simulate();
1177
+ if (useWorker && layoutWorker) {
1178
+ layoutWorker.postMessage({ type: "params", params: getLayoutParams() });
1179
+ }
1180
+ else {
1181
+ alpha = 0.5;
1182
+ cancelAnimationFrame(animFrame);
1183
+ simulate();
1184
+ }
761
1185
  },
762
1186
  exportImage(format) {
763
1187
  if (!state)
@@ -800,30 +1224,30 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
800
1224
  focusHops = hops;
801
1225
  const subgraph = extractSubgraph(lastLoadedData, seedNodeIds, hops);
802
1226
  cancelAnimationFrame(animFrame);
1227
+ if (layoutWorker)
1228
+ layoutWorker.postMessage({ type: "stop" });
803
1229
  state = createLayout(subgraph);
1230
+ nodeHash.rebuild(state.nodes);
804
1231
  alpha = 1;
805
1232
  selectedNodeIds = new Set(seedNodeIds);
806
1233
  filteredNodeIds = null;
807
- // Center camera
1234
+ // Start simulation, then center after layout settles
808
1235
  camera = { x: 0, y: 0, scale: 1 };
809
- if (state.nodes.length > 0) {
810
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
811
- for (const n of state.nodes) {
812
- if (n.x < minX)
813
- minX = n.x;
814
- if (n.y < minY)
815
- minY = n.y;
816
- if (n.x > maxX)
817
- maxX = n.x;
818
- if (n.y > maxY)
819
- maxY = n.y;
820
- }
821
- const cx = (minX + maxX) / 2;
822
- const cy = (minY + maxY) / 2;
823
- camera.x = cx - canvas.clientWidth / 2;
824
- camera.y = cy - canvas.clientHeight / 2;
1236
+ useWorker = subgraph.nodes.length >= WORKER_THRESHOLD;
1237
+ const w = useWorker ? getWorker() : null;
1238
+ if (w) {
1239
+ w.postMessage({ type: "start", data: subgraph });
825
1240
  }
826
- simulate();
1241
+ else {
1242
+ useWorker = false;
1243
+ simulate();
1244
+ }
1245
+ // Center + fit after physics settle
1246
+ setTimeout(() => {
1247
+ if (!state || !focusSeedIds)
1248
+ return;
1249
+ fitToNodes();
1250
+ }, 300);
827
1251
  onFocusChange?.({
828
1252
  seedNodeIds,
829
1253
  hops,
@@ -834,14 +1258,17 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
834
1258
  if (!focusSeedIds || !savedFullState)
835
1259
  return;
836
1260
  cancelAnimationFrame(animFrame);
1261
+ if (layoutWorker)
1262
+ layoutWorker.postMessage({ type: "stop" });
837
1263
  state = savedFullState;
1264
+ nodeHash.rebuild(state.nodes);
838
1265
  camera = savedFullCamera ?? { x: 0, y: 0, scale: 1 };
839
1266
  focusSeedIds = null;
840
1267
  savedFullState = null;
841
1268
  savedFullCamera = null;
842
1269
  selectedNodeIds = new Set();
843
1270
  filteredNodeIds = null;
844
- render();
1271
+ requestRedraw();
845
1272
  onFocusChange?.(null);
846
1273
  },
847
1274
  isFocused() {
@@ -856,6 +1283,83 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
856
1283
  totalNodes: state.nodes.length,
857
1284
  };
858
1285
  },
1286
+ findPath(sourceId, targetId) {
1287
+ if (!state)
1288
+ return null;
1289
+ const visited = new Set([sourceId]);
1290
+ const queue = [
1291
+ { nodeId: sourceId, path: [sourceId], edges: [] }
1292
+ ];
1293
+ while (queue.length > 0) {
1294
+ const { nodeId, path, edges } = queue.shift();
1295
+ if (nodeId === targetId)
1296
+ return { nodeIds: path, edgeIds: edges };
1297
+ for (const edge of state.edges) {
1298
+ let neighbor = null;
1299
+ if (edge.sourceId === nodeId)
1300
+ neighbor = edge.targetId;
1301
+ else if (edge.targetId === nodeId)
1302
+ neighbor = edge.sourceId;
1303
+ if (neighbor && !visited.has(neighbor)) {
1304
+ visited.add(neighbor);
1305
+ const fullEdge = lastLoadedData?.edges.find(e => (e.sourceId === edge.sourceId && e.targetId === edge.targetId) ||
1306
+ (e.targetId === edge.sourceId && e.sourceId === edge.targetId));
1307
+ queue.push({
1308
+ nodeId: neighbor,
1309
+ path: [...path, neighbor],
1310
+ edges: [...edges, fullEdge?.id ?? ""]
1311
+ });
1312
+ }
1313
+ }
1314
+ }
1315
+ return null;
1316
+ },
1317
+ setHighlightedPath(nodeIds, edgeIds) {
1318
+ if (nodeIds && edgeIds) {
1319
+ highlightedPath = { nodeIds: new Set(nodeIds), edgeIds: new Set(edgeIds) };
1320
+ }
1321
+ else {
1322
+ highlightedPath = null;
1323
+ }
1324
+ requestRedraw();
1325
+ },
1326
+ clearHighlightedPath() {
1327
+ highlightedPath = null;
1328
+ requestRedraw();
1329
+ },
1330
+ setWalkMode(enabled) {
1331
+ walkMode = enabled;
1332
+ if (enabled) {
1333
+ walkTrail = focusSeedIds ? [...focusSeedIds] : [...selectedNodeIds];
1334
+ if (!walkAnimFrame)
1335
+ walkAnimFrame = requestAnimationFrame(walkAnimate);
1336
+ }
1337
+ else {
1338
+ walkTrail = [];
1339
+ if (walkAnimFrame) {
1340
+ cancelAnimationFrame(walkAnimFrame);
1341
+ walkAnimFrame = 0;
1342
+ }
1343
+ }
1344
+ requestRedraw();
1345
+ },
1346
+ getWalkMode() {
1347
+ return walkMode;
1348
+ },
1349
+ getWalkTrail() {
1350
+ return [...walkTrail];
1351
+ },
1352
+ getFilteredNodeIds() {
1353
+ return filteredNodeIds;
1354
+ },
1355
+ removeFromWalkTrail(nodeId) {
1356
+ walkTrail = walkTrail.filter((id) => id !== nodeId);
1357
+ requestRedraw();
1358
+ },
1359
+ /** Hit-test a screen coordinate against nodes. Returns the node or null. */
1360
+ nodeAtScreen(sx, sy) {
1361
+ return nodeAtScreen(sx, sy);
1362
+ },
859
1363
  /** Get all node IDs in the current layout (subgraph if focused, full graph otherwise). Seed nodes first. */
860
1364
  getNodeIds() {
861
1365
  if (!state)
@@ -870,6 +1374,20 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
870
1374
  },
871
1375
  destroy() {
872
1376
  cancelAnimationFrame(animFrame);
1377
+ if (renderPending) {
1378
+ cancelAnimationFrame(renderPending);
1379
+ renderPending = 0;
1380
+ }
1381
+ if (walkAnimFrame) {
1382
+ cancelAnimationFrame(walkAnimFrame);
1383
+ walkAnimFrame = 0;
1384
+ }
1385
+ if (layoutWorker) {
1386
+ layoutWorker.terminate();
1387
+ layoutWorker = null;
1388
+ }
1389
+ sceneCache = null;
1390
+ sceneCacheCtx = null;
873
1391
  observer.disconnect();
874
1392
  },
875
1393
  };