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/README.md +2 -0
- package/bin/serve.js +184 -1
- package/dist/api.d.ts +25 -0
- package/dist/api.js +42 -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 +19 -0
- package/dist/canvas.js +662 -144
- package/dist/config.js +1 -0
- package/dist/context-menu.d.ts +13 -0
- package/dist/context-menu.js +64 -0
- package/dist/default-config.json +6 -1
- package/dist/empty-state.js +13 -0
- package/dist/info-panel.js +2 -2
- package/dist/keybindings.d.ts +1 -1
- package/dist/keybindings.js +2 -0
- 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 +266 -14
- package/dist/quadtree.d.ts +43 -0
- package/dist/quadtree.js +147 -0
- package/dist/shortcuts.js +2 -0
- package/dist/sidebar.d.ts +10 -0
- package/dist/sidebar.js +134 -4
- package/dist/spatial-hash.d.ts +22 -0
- package/dist/spatial-hash.js +67 -0
- package/dist/style.css +357 -0
- package/dist/tools-pane.d.ts +10 -0
- package/dist/tools-pane.js +192 -0
- package/package.json +2 -2
- package/dist/app/assets/index-BAsAhA_i.js +0 -21
- package/dist/app/assets/index-CvETIueX.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) {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
149
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
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(
|
|
400
|
+
ctx.fillText(d.type, mx, my - 4);
|
|
202
401
|
}
|
|
203
402
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
(
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
|
462
|
+
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
|
|
225
463
|
ctx.fillStyle = color;
|
|
226
|
-
|
|
464
|
+
const baseAlpha = filteredOut ? 0.1 : dimmed ? 0.3 : 1;
|
|
465
|
+
ctx.globalAlpha = isEntering ? baseAlpha * entranceProgress : baseAlpha;
|
|
227
466
|
ctx.fill();
|
|
228
|
-
ctx.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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) >
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1145
|
+
requestRedraw();
|
|
709
1146
|
},
|
|
710
1147
|
setEdgeLabels(visible) {
|
|
711
1148
|
showEdgeLabels = visible;
|
|
712
|
-
|
|
1149
|
+
requestRedraw();
|
|
713
1150
|
},
|
|
714
1151
|
setTypeHulls(visible) {
|
|
715
1152
|
showTypeHulls = visible;
|
|
716
|
-
|
|
1153
|
+
requestRedraw();
|
|
717
1154
|
},
|
|
718
1155
|
setMinimap(visible) {
|
|
719
1156
|
showMinimap = visible;
|
|
720
|
-
|
|
1157
|
+
requestRedraw();
|
|
721
1158
|
},
|
|
722
1159
|
centerView() {
|
|
723
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1174
|
+
requestRedraw();
|
|
756
1175
|
},
|
|
757
1176
|
reheat() {
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
//
|
|
1234
|
+
// Start simulation, then center after layout settles
|
|
808
1235
|
camera = { x: 0, y: 0, scale: 1 };
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|