backpack-viewer 0.2.21 → 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) {
@@ -44,6 +46,73 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
44
46
  let walkMode = false;
45
47
  let walkTrail = []; // node IDs visited during walk, most recent last
46
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
+ }
47
116
  // Pan animation state
48
117
  let panTarget = null;
49
118
  let panStart = null;
@@ -52,7 +121,8 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
52
121
  function resize() {
53
122
  canvas.width = canvas.clientWidth * dpr;
54
123
  canvas.height = canvas.clientHeight * dpr;
55
- render();
124
+ invalidateSceneCache();
125
+ requestRedraw();
56
126
  }
57
127
  const observer = new ResizeObserver(resize);
58
128
  observer.observe(container);
@@ -69,28 +139,95 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
69
139
  if (!state)
70
140
  return null;
71
141
  const [wx, wy] = screenToWorld(sx, sy);
72
- // Iterate in reverse so topmost (last drawn) nodes are hit first
73
- for (let i = state.nodes.length - 1; i >= 0; i--) {
74
- const node = state.nodes[i];
75
- const dx = wx - node.x;
76
- const dy = wy - node.y;
77
- if (dx * dx + dy * dy <= NODE_RADIUS * NODE_RADIUS) {
78
- 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]);
79
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;
80
186
  }
81
- return null;
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();
204
+ }
205
+ ctx.globalAlpha = 1;
206
+ ctx.restore();
207
+ // Minimap
208
+ if (showMinimap && state.nodes.length > 1) {
209
+ drawMinimap();
210
+ }
211
+ ctx.restore();
82
212
  }
83
- // --- Rendering ---
84
213
  function render() {
85
214
  if (!state) {
86
215
  ctx.clearRect(0, 0, canvas.width, canvas.height);
87
216
  return;
88
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;
89
226
  // Advance pulse animation for walk mode
90
227
  if (walkMode && walkTrail.length > 0) {
91
228
  pulsePhase += walkCfg.pulseSpeed;
92
229
  }
93
- const walkTrailSet = walkMode ? new Set(walkTrail) : null;
230
+ const walkTrailSet = (walkMode && !shouldCache) ? new Set(walkTrail) : null;
94
231
  // Read theme colors from CSS variables each frame
95
232
  const edgeColor = cssVar("--canvas-edge");
96
233
  const edgeHighlight = cssVar("--canvas-edge-highlight");
@@ -157,21 +294,42 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
157
294
  ctx.globalAlpha = 1;
158
295
  }
159
296
  }
160
- // Draw edges
161
- 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 = [];
162
321
  for (const edge of state.edges) {
163
322
  const source = state.nodeMap.get(edge.sourceId);
164
323
  const target = state.nodeMap.get(edge.targetId);
165
324
  if (!source || !target)
166
325
  continue;
167
- // Viewport culling — skip if both endpoints are off-screen
326
+ // Viewport culling
168
327
  if (!isInViewport(source.x, source.y, camera, canvas.clientWidth, canvas.clientHeight, 200) &&
169
328
  !isInViewport(target.x, target.y, camera, canvas.clientWidth, canvas.clientHeight, 200))
170
329
  continue;
171
330
  const sourceMatch = filteredNodeIds === null || filteredNodeIds.has(edge.sourceId);
172
331
  const targetMatch = filteredNodeIds === null || filteredNodeIds.has(edge.targetId);
173
332
  const bothMatch = sourceMatch && targetMatch;
174
- // Hide edges where neither endpoint matches the filter
175
333
  if (filteredNodeIds !== null && !sourceMatch && !targetMatch)
176
334
  continue;
177
335
  const isConnected = selectedNodeIds.size > 0 &&
@@ -179,149 +337,193 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
179
337
  const highlighted = isConnected || (filteredNodeIds !== null && bothMatch);
180
338
  const edgeDimmed = filteredNodeIds !== null && !bothMatch;
181
339
  const isWalkEdge = walkTrailSet !== null && walkTrailSet.has(edge.sourceId) && walkTrailSet.has(edge.targetId);
182
- // Check if this edge is part of the highlighted path
183
340
  const fullEdge = highlightedPath ? lastLoadedData?.edges.find(e => (e.sourceId === edge.sourceId && e.targetId === edge.targetId) ||
184
341
  (e.targetId === edge.sourceId && e.sourceId === edge.targetId)) : null;
185
- const isPathEdge = highlightedPath && fullEdge && highlightedPath.edgeIds.has(fullEdge.id);
342
+ const isPathEdge = !!(highlightedPath && fullEdge && highlightedPath.edgeIds.has(fullEdge.id));
186
343
  // Self-loop
187
344
  if (edge.sourceId === edge.targetId) {
188
345
  drawSelfLoop(source, edge.type, highlighted, edgeColor, edgeHighlight, edgeLabel, edgeLabelHighlight);
189
346
  continue;
190
347
  }
191
- // 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;
192
373
  ctx.beginPath();
193
- ctx.moveTo(source.x, source.y);
194
- ctx.lineTo(target.x, target.y);
195
- const accent = cssVar("--accent") || "#d4a27f";
196
- const walkEdgeColor = cssVar("--canvas-walk-edge") || "#1a1a1a";
197
- ctx.strokeStyle = isPathEdge
198
- ? accent
199
- : isWalkEdge
200
- ? walkEdgeColor
201
- : highlighted
202
- ? edgeHighlight
203
- : edgeDimmed
204
- ? edgeDimColor
205
- : edgeColor;
206
- ctx.lineWidth = isPathEdge || isWalkEdge
207
- ? 3
208
- : camera.scale < lod.hideArrows ? 1 : highlighted ? 2.5 : 1.5;
209
- if (isWalkEdge) {
210
- ctx.globalAlpha = 0.5 + 0.5 * Math.sin(pulsePhase);
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]);
211
377
  }
378
+ ctx.strokeStyle = b.color;
379
+ ctx.lineWidth = b.width;
380
+ ctx.globalAlpha = b.alpha;
212
381
  ctx.stroke();
213
- ctx.globalAlpha = 1;
214
- // Arrowhead
215
- if (camera.scale >= lod.hideArrows) {
216
- 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);
217
388
  }
218
- // Edge label at midpoint
219
- if (showEdgeLabels && camera.scale >= lod.hideEdgeLabels) {
220
- const mx = (source.x + target.x) / 2;
221
- const my = (source.y + target.y) / 2;
222
- 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
223
393
  ? edgeLabelHighlight
224
- : edgeDimmed
394
+ : d.edgeDimmed
225
395
  ? edgeLabelDim
226
396
  : edgeLabel;
227
397
  ctx.font = "9px system-ui, sans-serif";
228
398
  ctx.textAlign = "center";
229
399
  ctx.textBaseline = "bottom";
230
- ctx.fillText(edge.type, mx, my - 4);
400
+ ctx.fillText(d.type, mx, my - 4);
231
401
  }
232
402
  }
233
- // Draw nodes
234
- for (const node of state.nodes) {
235
- // Viewport culling
236
- if (!isInViewport(node.x, node.y, camera, canvas.clientWidth, canvas.clientHeight))
237
- continue;
238
- const color = getColor(node.type);
239
- const isSelected = selectedNodeIds.has(node.id);
240
- const isNeighbor = selectedNodeIds.size > 0 &&
241
- state.edges.some((e) => (selectedNodeIds.has(e.sourceId) && e.targetId === node.id) ||
242
- (selectedNodeIds.has(e.targetId) && e.sourceId === node.id));
243
- const filteredOut = filteredNodeIds !== null && !filteredNodeIds.has(node.id);
244
- const dimmed = filteredOut ||
245
- (selectedNodeIds.size > 0 && !isSelected && !isNeighbor);
246
- const r = camera.scale < lod.smallNodes ? NODE_RADIUS * 0.5 : NODE_RADIUS;
247
- // Walk trail effect — all visited nodes pulse together
248
- if (walkTrailSet?.has(node.id)) {
249
- const isCurrent = walkTrail[walkTrail.length - 1] === node.id;
250
- const pulse = 0.5 + 0.5 * Math.sin(pulsePhase);
251
- const accent = cssVar("--accent") || "#d4a27f";
252
- ctx.save();
253
- ctx.strokeStyle = accent;
254
- ctx.lineWidth = isCurrent ? 3 : 2;
255
- ctx.globalAlpha = isCurrent ? 0.5 + 0.5 * pulse : 0.3 + 0.4 * pulse;
256
- ctx.beginPath();
257
- ctx.arc(node.x, node.y, r + (isCurrent ? 6 : 4), 0, Math.PI * 2);
258
- ctx.stroke();
259
- ctx.restore();
260
- }
261
- // Glow for selected node
262
- if (isSelected) {
263
- ctx.save();
264
- ctx.shadowColor = color;
265
- 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
266
461
  ctx.beginPath();
267
- ctx.arc(node.x, node.y, r + 3, 0, Math.PI * 2);
462
+ ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
268
463
  ctx.fillStyle = color;
269
- ctx.globalAlpha = 0.3;
464
+ const baseAlpha = filteredOut ? 0.1 : dimmed ? 0.3 : 1;
465
+ ctx.globalAlpha = isEntering ? baseAlpha * entranceProgress : baseAlpha;
270
466
  ctx.fill();
271
- ctx.restore();
272
- }
273
- // Circle
274
- ctx.beginPath();
275
- ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
276
- ctx.fillStyle = color;
277
- ctx.globalAlpha = filteredOut ? 0.1 : dimmed ? 0.3 : 1;
278
- ctx.fill();
279
- ctx.strokeStyle = isSelected ? selectionBorder : nodeBorder;
280
- ctx.lineWidth = isSelected ? 3 : 1.5;
281
- ctx.stroke();
282
- // Highlighted path glow
283
- if (highlightedPath && highlightedPath.nodeIds.has(node.id) && !isSelected) {
284
- ctx.save();
285
- ctx.shadowColor = cssVar("--accent") || "#d4a27f";
286
- ctx.shadowBlur = 15;
287
- ctx.beginPath();
288
- ctx.arc(node.x, node.y, r + 2, 0, Math.PI * 2);
289
- ctx.strokeStyle = cssVar("--accent") || "#d4a27f";
290
- ctx.globalAlpha = 0.5;
291
- ctx.lineWidth = 2;
467
+ ctx.strokeStyle = isSelected ? selectionBorder : nodeBorder;
468
+ ctx.lineWidth = isSelected ? 3 : 1.5;
292
469
  ctx.stroke();
293
- ctx.restore();
294
- }
295
- // Star indicator for starred nodes
296
- const originalNode = lastLoadedData?.nodes.find(n => n.id === node.id);
297
- const isStarred = originalNode?.properties?._starred === true;
298
- if (isStarred) {
299
- ctx.fillStyle = "#ffd700";
300
- ctx.font = "10px system-ui, sans-serif";
301
- ctx.textAlign = "left";
302
- ctx.textBaseline = "bottom";
303
- ctx.fillText("\u2605", node.x + r - 2, node.y - r + 2);
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;
304
506
  }
305
- // Label below
306
- if (camera.scale >= lod.hideLabels) {
307
- const label = node.label.length > 24 ? node.label.slice(0, 22) + "..." : node.label;
308
- ctx.fillStyle = dimmed ? nodeLabelDim : nodeLabel;
309
- ctx.font = "11px system-ui, sans-serif";
310
- ctx.textAlign = "center";
311
- ctx.textBaseline = "top";
312
- ctx.fillText(label, node.x, node.y + r + 4);
507
+ ctx.restore();
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");
313
517
  }
314
- // Type badge above
315
- if (camera.scale >= lod.hideBadges) {
316
- ctx.fillStyle = dimmed ? typeBadgeDim : typeBadge;
317
- ctx.font = "9px system-ui, sans-serif";
318
- ctx.textBaseline = "bottom";
319
- ctx.fillText(node.type, node.x, node.y - r - 3);
518
+ if (sceneCacheCtx) {
519
+ sceneCacheCtx.clearRect(0, 0, w, h);
520
+ sceneCacheCtx.drawImage(canvas, 0, 0);
521
+ sceneCacheDirty = false;
320
522
  }
321
- ctx.globalAlpha = 1;
523
+ // Now draw walk effects + minimap on top for this frame
524
+ renderWalkOverlay();
525
+ return;
322
526
  }
323
- ctx.restore();
324
- ctx.restore();
325
527
  // Minimap
326
528
  if (showMinimap && state.nodes.length > 1) {
327
529
  drawMinimap();
@@ -442,6 +644,7 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
442
644
  const ease = 1 - Math.pow(1 - t, 3);
443
645
  camera.x = panStart.x + (panTarget.x - panStart.x) * ease;
444
646
  camera.y = panStart.y + (panTarget.y - panStart.y) * ease;
647
+ invalidateSceneCache();
445
648
  render();
446
649
  if (t < 1) {
447
650
  requestAnimationFrame(animatePan);
@@ -482,7 +685,7 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
482
685
  camera.scale = Math.min(scaleX, scaleY, 2);
483
686
  camera.x = (minX + maxX) / 2 - canvas.clientWidth / (2 * camera.scale);
484
687
  camera.y = (minY + maxY) / 2 - canvas.clientHeight / (2 * camera.scale);
485
- render();
688
+ requestRedraw();
486
689
  }
487
690
  function simulate() {
488
691
  if (!state || alpha < ALPHA_MIN) {
@@ -493,6 +696,7 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
493
696
  return;
494
697
  }
495
698
  alpha = tick(state, alpha);
699
+ nodeHash.rebuild(state.nodes);
496
700
  render();
497
701
  animFrame = requestAnimationFrame(simulate);
498
702
  }
@@ -518,7 +722,7 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
518
722
  camera.y -= dy / camera.scale;
519
723
  lastX = e.clientX;
520
724
  lastY = e.clientY;
521
- render();
725
+ requestRedraw();
522
726
  });
523
727
  canvas.addEventListener("mouseup", (e) => {
524
728
  dragging = false;
@@ -568,12 +772,23 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
568
772
  focusHops = walkHops;
569
773
  const subgraph = extractSubgraph(lastLoadedData, [hit.id], walkHops);
570
774
  cancelAnimationFrame(animFrame);
775
+ if (layoutWorker)
776
+ layoutWorker.postMessage({ type: "stop" });
571
777
  state = createLayout(subgraph);
778
+ nodeHash.rebuild(state.nodes);
572
779
  alpha = 1;
573
780
  selectedNodeIds = new Set([hit.id]);
574
781
  filteredNodeIds = null;
575
782
  camera = { x: 0, y: 0, scale: 1 };
576
- simulate();
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
+ }
577
792
  // Center after physics settle
578
793
  setTimeout(() => {
579
794
  if (!state)
@@ -611,7 +826,7 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
611
826
  selectedNodeIds.clear();
612
827
  onNodeClick?.(null);
613
828
  }
614
- render();
829
+ requestRedraw();
615
830
  });
616
831
  canvas.addEventListener("mouseleave", () => {
617
832
  dragging = false;
@@ -631,7 +846,7 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
631
846
  camera.scale = Math.max(nav.zoomMin, Math.min(nav.zoomMax, camera.scale * factor));
632
847
  camera.x = wx - mx / camera.scale;
633
848
  camera.y = wy - my / camera.scale;
634
- render();
849
+ requestRedraw();
635
850
  }, { passive: false });
636
851
  // --- Interaction: Touch (pinch zoom + drag) ---
637
852
  let touches = [];
@@ -662,7 +877,7 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
662
877
  const dist = touchDistance(current[0], current[1]);
663
878
  const ratio = dist / initialPinchDist;
664
879
  camera.scale = Math.max(nav.zoomMin, Math.min(nav.zoomMax, initialPinchScale * ratio));
665
- render();
880
+ requestRedraw();
666
881
  }
667
882
  else if (current.length === 1) {
668
883
  const dx = current[0].clientX - lastX;
@@ -675,7 +890,7 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
675
890
  camera.y -= dy / camera.scale;
676
891
  lastX = current[0].clientX;
677
892
  lastY = current[0].clientY;
678
- render();
893
+ requestRedraw();
679
894
  }
680
895
  touches = current;
681
896
  }, { passive: false });
@@ -703,7 +918,7 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
703
918
  selectedNodeIds.clear();
704
919
  onNodeClick?.(null);
705
920
  }
706
- render();
921
+ requestRedraw();
707
922
  }, { passive: false });
708
923
  // Prevent Safari page-level pinch zoom on the canvas
709
924
  canvas.addEventListener("gesturestart", (e) => e.preventDefault());
@@ -727,7 +942,7 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
727
942
  camera.scale = Math.min(nav.zoomMax, camera.scale * nav.zoomFactor);
728
943
  camera.x = wx - cx / camera.scale;
729
944
  camera.y = wy - cy / camera.scale;
730
- render();
945
+ requestRedraw();
731
946
  });
732
947
  const zoomOutBtn = document.createElement("button");
733
948
  zoomOutBtn.className = "zoom-btn";
@@ -740,7 +955,7 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
740
955
  camera.scale = Math.max(nav.zoomMin, camera.scale / nav.zoomFactor);
741
956
  camera.x = wx - cx / camera.scale;
742
957
  camera.y = wy - cy / camera.scale;
743
- render();
958
+ requestRedraw();
744
959
  });
745
960
  const zoomResetBtn = document.createElement("button");
746
961
  zoomResetBtn.className = "zoom-btn";
@@ -767,22 +982,77 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
767
982
  camera.x = cx - canvas.clientWidth / 2;
768
983
  camera.y = cy - canvas.clientHeight / 2;
769
984
  }
770
- render();
985
+ requestRedraw();
771
986
  });
772
987
  zoomControls.appendChild(zoomInBtn);
773
988
  zoomControls.appendChild(zoomResetBtn);
774
989
  zoomControls.appendChild(zoomOutBtn);
775
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
+ });
776
1041
  // --- Public API ---
777
1042
  return {
778
1043
  loadGraph(data) {
779
1044
  cancelAnimationFrame(animFrame);
1045
+ if (layoutWorker)
1046
+ layoutWorker.postMessage({ type: "stop" });
1047
+ clearLabelCache();
780
1048
  lastLoadedData = data;
781
1049
  // Exit any active focus when full graph reloads
782
1050
  focusSeedIds = null;
783
1051
  savedFullState = null;
784
1052
  savedFullCamera = null;
1053
+ loadTime = performance.now();
785
1054
  state = createLayout(data);
1055
+ nodeHash.rebuild(state.nodes);
786
1056
  alpha = 1;
787
1057
  selectedNodeIds = new Set();
788
1058
  filteredNodeIds = null;
@@ -807,11 +1077,20 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
807
1077
  camera.x = cx - w / 2;
808
1078
  camera.y = cy - h / 2;
809
1079
  }
810
- 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
+ }
811
1090
  },
812
1091
  setFilteredNodeIds(ids) {
813
1092
  filteredNodeIds = ids;
814
- render();
1093
+ requestRedraw();
815
1094
  },
816
1095
  panToNode(nodeId) {
817
1096
  this.panToNodes([nodeId]);
@@ -863,19 +1142,19 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
863
1142
  },
864
1143
  setEdges(visible) {
865
1144
  showEdges = visible;
866
- render();
1145
+ requestRedraw();
867
1146
  },
868
1147
  setEdgeLabels(visible) {
869
1148
  showEdgeLabels = visible;
870
- render();
1149
+ requestRedraw();
871
1150
  },
872
1151
  setTypeHulls(visible) {
873
1152
  showTypeHulls = visible;
874
- render();
1153
+ requestRedraw();
875
1154
  },
876
1155
  setMinimap(visible) {
877
1156
  showMinimap = visible;
878
- render();
1157
+ requestRedraw();
879
1158
  },
880
1159
  centerView() {
881
1160
  fitToNodes();
@@ -883,7 +1162,7 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
883
1162
  panBy(dx, dy) {
884
1163
  camera.x += dx / camera.scale;
885
1164
  camera.y += dy / camera.scale;
886
- render();
1165
+ requestRedraw();
887
1166
  },
888
1167
  zoomBy(factor) {
889
1168
  const cx = canvas.clientWidth / 2;
@@ -892,12 +1171,17 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
892
1171
  camera.scale = Math.max(nav.zoomMin, Math.min(nav.zoomMax, camera.scale * factor));
893
1172
  camera.x = wx - cx / camera.scale;
894
1173
  camera.y = wy - cy / camera.scale;
895
- render();
1174
+ requestRedraw();
896
1175
  },
897
1176
  reheat() {
898
- alpha = 0.5;
899
- cancelAnimationFrame(animFrame);
900
- 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
+ }
901
1185
  },
902
1186
  exportImage(format) {
903
1187
  if (!state)
@@ -940,13 +1224,24 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
940
1224
  focusHops = hops;
941
1225
  const subgraph = extractSubgraph(lastLoadedData, seedNodeIds, hops);
942
1226
  cancelAnimationFrame(animFrame);
1227
+ if (layoutWorker)
1228
+ layoutWorker.postMessage({ type: "stop" });
943
1229
  state = createLayout(subgraph);
1230
+ nodeHash.rebuild(state.nodes);
944
1231
  alpha = 1;
945
1232
  selectedNodeIds = new Set(seedNodeIds);
946
1233
  filteredNodeIds = null;
947
1234
  // Start simulation, then center after layout settles
948
1235
  camera = { x: 0, y: 0, scale: 1 };
949
- simulate();
1236
+ useWorker = subgraph.nodes.length >= WORKER_THRESHOLD;
1237
+ const w = useWorker ? getWorker() : null;
1238
+ if (w) {
1239
+ w.postMessage({ type: "start", data: subgraph });
1240
+ }
1241
+ else {
1242
+ useWorker = false;
1243
+ simulate();
1244
+ }
950
1245
  // Center + fit after physics settle
951
1246
  setTimeout(() => {
952
1247
  if (!state || !focusSeedIds)
@@ -963,14 +1258,17 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
963
1258
  if (!focusSeedIds || !savedFullState)
964
1259
  return;
965
1260
  cancelAnimationFrame(animFrame);
1261
+ if (layoutWorker)
1262
+ layoutWorker.postMessage({ type: "stop" });
966
1263
  state = savedFullState;
1264
+ nodeHash.rebuild(state.nodes);
967
1265
  camera = savedFullCamera ?? { x: 0, y: 0, scale: 1 };
968
1266
  focusSeedIds = null;
969
1267
  savedFullState = null;
970
1268
  savedFullCamera = null;
971
1269
  selectedNodeIds = new Set();
972
1270
  filteredNodeIds = null;
973
- render();
1271
+ requestRedraw();
974
1272
  onFocusChange?.(null);
975
1273
  },
976
1274
  isFocused() {
@@ -1023,11 +1321,11 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
1023
1321
  else {
1024
1322
  highlightedPath = null;
1025
1323
  }
1026
- render();
1324
+ requestRedraw();
1027
1325
  },
1028
1326
  clearHighlightedPath() {
1029
1327
  highlightedPath = null;
1030
- render();
1328
+ requestRedraw();
1031
1329
  },
1032
1330
  setWalkMode(enabled) {
1033
1331
  walkMode = enabled;
@@ -1043,7 +1341,7 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
1043
1341
  walkAnimFrame = 0;
1044
1342
  }
1045
1343
  }
1046
- render();
1344
+ requestRedraw();
1047
1345
  },
1048
1346
  getWalkMode() {
1049
1347
  return walkMode;
@@ -1056,7 +1354,7 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
1056
1354
  },
1057
1355
  removeFromWalkTrail(nodeId) {
1058
1356
  walkTrail = walkTrail.filter((id) => id !== nodeId);
1059
- render();
1357
+ requestRedraw();
1060
1358
  },
1061
1359
  /** Hit-test a screen coordinate against nodes. Returns the node or null. */
1062
1360
  nodeAtScreen(sx, sy) {
@@ -1076,6 +1374,20 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
1076
1374
  },
1077
1375
  destroy() {
1078
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;
1079
1391
  observer.disconnect();
1080
1392
  },
1081
1393
  };