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/README.md +2 -0
- package/bin/serve.js +122 -1
- package/dist/api.d.ts +13 -0
- package/dist/api.js +12 -0
- package/dist/app/assets/index-CBjy2b6N.js +34 -0
- package/dist/app/assets/index-CvkozBSE.css +1 -0
- package/dist/app/assets/layout-worker-BZXiBoiC.js +1 -0
- package/dist/app/index.html +2 -2
- package/dist/canvas.d.ts +2 -0
- package/dist/canvas.js +473 -161
- package/dist/empty-state.js +13 -0
- package/dist/info-panel.js +2 -2
- package/dist/label-cache.d.ts +14 -0
- package/dist/label-cache.js +54 -0
- package/dist/layout-worker.d.ts +17 -0
- package/dist/layout-worker.js +78 -0
- package/dist/layout.js +73 -18
- package/dist/main.js +47 -14
- package/dist/quadtree.d.ts +43 -0
- package/dist/quadtree.js +147 -0
- package/dist/sidebar.d.ts +2 -0
- package/dist/sidebar.js +90 -1
- package/dist/spatial-hash.d.ts +22 -0
- package/dist/spatial-hash.js +67 -0
- package/dist/style.css +193 -0
- package/dist/tools-pane.d.ts +1 -0
- package/dist/tools-pane.js +109 -0
- package/package.json +2 -2
- package/dist/app/assets/index-BBfZ1JvO.js +0 -21
- package/dist/app/assets/index-DNiYjxNx.css +0 -1
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
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
161
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
|
|
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(
|
|
400
|
+
ctx.fillText(d.type, mx, my - 4);
|
|
231
401
|
}
|
|
232
402
|
}
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
(
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
462
|
+
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
|
|
268
463
|
ctx.fillStyle = color;
|
|
269
|
-
|
|
464
|
+
const baseAlpha = filteredOut ? 0.1 : dimmed ? 0.3 : 1;
|
|
465
|
+
ctx.globalAlpha = isEntering ? baseAlpha * entranceProgress : baseAlpha;
|
|
270
466
|
ctx.fill();
|
|
271
|
-
ctx.
|
|
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.
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1145
|
+
requestRedraw();
|
|
867
1146
|
},
|
|
868
1147
|
setEdgeLabels(visible) {
|
|
869
1148
|
showEdgeLabels = visible;
|
|
870
|
-
|
|
1149
|
+
requestRedraw();
|
|
871
1150
|
},
|
|
872
1151
|
setTypeHulls(visible) {
|
|
873
1152
|
showTypeHulls = visible;
|
|
874
|
-
|
|
1153
|
+
requestRedraw();
|
|
875
1154
|
},
|
|
876
1155
|
setMinimap(visible) {
|
|
877
1156
|
showMinimap = visible;
|
|
878
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1174
|
+
requestRedraw();
|
|
896
1175
|
},
|
|
897
1176
|
reheat() {
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1324
|
+
requestRedraw();
|
|
1027
1325
|
},
|
|
1028
1326
|
clearHighlightedPath() {
|
|
1029
1327
|
highlightedPath = null;
|
|
1030
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|