backpack-viewer 0.2.20 → 0.2.21
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/bin/serve.js +62 -0
- package/dist/api.d.ts +12 -0
- package/dist/api.js +30 -0
- package/dist/app/assets/index-BBfZ1JvO.js +21 -0
- package/dist/app/assets/index-DNiYjxNx.css +1 -0
- package/dist/app/index.html +2 -2
- package/dist/canvas.d.ts +17 -0
- package/dist/canvas.js +251 -45
- 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/keybindings.d.ts +1 -1
- package/dist/keybindings.js +2 -0
- package/dist/main.js +221 -2
- package/dist/shortcuts.js +2 -0
- package/dist/sidebar.d.ts +8 -0
- package/dist/sidebar.js +44 -3
- package/dist/style.css +164 -0
- package/dist/tools-pane.d.ts +9 -0
- package/dist/tools-pane.js +83 -0
- package/package.json +1 -1
- package/dist/app/assets/index-BAsAhA_i.js +0 -21
- package/dist/app/assets/index-CvETIueX.css +0 -1
package/dist/canvas.js
CHANGED
|
@@ -18,6 +18,7 @@ function isInViewport(x, y, camera, canvasW, canvasH, pad = 100) {
|
|
|
18
18
|
export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
19
19
|
const lod = { ...LOD_DEFAULTS, ...(config?.lod ?? {}) };
|
|
20
20
|
const nav = { ...NAV_DEFAULTS, ...(config?.navigation ?? {}) };
|
|
21
|
+
const walkCfg = { pulseSpeed: 0.02, ...(config?.walk ?? {}) };
|
|
21
22
|
const canvas = container.querySelector("canvas");
|
|
22
23
|
const ctx = canvas.getContext("2d");
|
|
23
24
|
const dpr = window.devicePixelRatio || 1;
|
|
@@ -37,6 +38,12 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
37
38
|
let focusHops = 1;
|
|
38
39
|
let savedFullState = null;
|
|
39
40
|
let savedFullCamera = null;
|
|
41
|
+
// Highlighted path state (for path finding visualization)
|
|
42
|
+
let highlightedPath = null;
|
|
43
|
+
// Walk mode state
|
|
44
|
+
let walkMode = false;
|
|
45
|
+
let walkTrail = []; // node IDs visited during walk, most recent last
|
|
46
|
+
let pulsePhase = 0; // animation counter for pulse effect
|
|
40
47
|
// Pan animation state
|
|
41
48
|
let panTarget = null;
|
|
42
49
|
let panStart = null;
|
|
@@ -79,6 +86,11 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
79
86
|
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
80
87
|
return;
|
|
81
88
|
}
|
|
89
|
+
// Advance pulse animation for walk mode
|
|
90
|
+
if (walkMode && walkTrail.length > 0) {
|
|
91
|
+
pulsePhase += walkCfg.pulseSpeed;
|
|
92
|
+
}
|
|
93
|
+
const walkTrailSet = walkMode ? new Set(walkTrail) : null;
|
|
82
94
|
// Read theme colors from CSS variables each frame
|
|
83
95
|
const edgeColor = cssVar("--canvas-edge");
|
|
84
96
|
const edgeHighlight = cssVar("--canvas-edge-highlight");
|
|
@@ -166,6 +178,11 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
166
178
|
(selectedNodeIds.has(edge.sourceId) || selectedNodeIds.has(edge.targetId));
|
|
167
179
|
const highlighted = isConnected || (filteredNodeIds !== null && bothMatch);
|
|
168
180
|
const edgeDimmed = filteredNodeIds !== null && !bothMatch;
|
|
181
|
+
const isWalkEdge = walkTrailSet !== null && walkTrailSet.has(edge.sourceId) && walkTrailSet.has(edge.targetId);
|
|
182
|
+
// Check if this edge is part of the highlighted path
|
|
183
|
+
const fullEdge = highlightedPath ? lastLoadedData?.edges.find(e => (e.sourceId === edge.sourceId && e.targetId === edge.targetId) ||
|
|
184
|
+
(e.targetId === edge.sourceId && e.sourceId === edge.targetId)) : null;
|
|
185
|
+
const isPathEdge = highlightedPath && fullEdge && highlightedPath.edgeIds.has(fullEdge.id);
|
|
169
186
|
// Self-loop
|
|
170
187
|
if (edge.sourceId === edge.targetId) {
|
|
171
188
|
drawSelfLoop(source, edge.type, highlighted, edgeColor, edgeHighlight, edgeLabel, edgeLabelHighlight);
|
|
@@ -175,13 +192,25 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
175
192
|
ctx.beginPath();
|
|
176
193
|
ctx.moveTo(source.x, source.y);
|
|
177
194
|
ctx.lineTo(target.x, target.y);
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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);
|
|
211
|
+
}
|
|
184
212
|
ctx.stroke();
|
|
213
|
+
ctx.globalAlpha = 1;
|
|
185
214
|
// Arrowhead
|
|
186
215
|
if (camera.scale >= lod.hideArrows) {
|
|
187
216
|
drawArrowhead(source.x, source.y, target.x, target.y, highlighted, arrowColor, arrowHighlight);
|
|
@@ -215,6 +244,20 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
215
244
|
const dimmed = filteredOut ||
|
|
216
245
|
(selectedNodeIds.size > 0 && !isSelected && !isNeighbor);
|
|
217
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
|
+
}
|
|
218
261
|
// Glow for selected node
|
|
219
262
|
if (isSelected) {
|
|
220
263
|
ctx.save();
|
|
@@ -236,6 +279,29 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
236
279
|
ctx.strokeStyle = isSelected ? selectionBorder : nodeBorder;
|
|
237
280
|
ctx.lineWidth = isSelected ? 3 : 1.5;
|
|
238
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;
|
|
292
|
+
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);
|
|
304
|
+
}
|
|
239
305
|
// Label below
|
|
240
306
|
if (camera.scale >= lod.hideLabels) {
|
|
241
307
|
const label = node.label.length > 24 ? node.label.slice(0, 22) + "..." : node.label;
|
|
@@ -385,9 +451,47 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
385
451
|
panStart = null;
|
|
386
452
|
}
|
|
387
453
|
}
|
|
454
|
+
let walkAnimFrame = 0;
|
|
455
|
+
function walkAnimate() {
|
|
456
|
+
if (!walkMode || walkTrail.length === 0) {
|
|
457
|
+
walkAnimFrame = 0;
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
render();
|
|
461
|
+
walkAnimFrame = requestAnimationFrame(walkAnimate);
|
|
462
|
+
}
|
|
463
|
+
function fitToNodes() {
|
|
464
|
+
if (!state || state.nodes.length === 0)
|
|
465
|
+
return;
|
|
466
|
+
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
467
|
+
for (const n of state.nodes) {
|
|
468
|
+
if (n.x < minX)
|
|
469
|
+
minX = n.x;
|
|
470
|
+
if (n.y < minY)
|
|
471
|
+
minY = n.y;
|
|
472
|
+
if (n.x > maxX)
|
|
473
|
+
maxX = n.x;
|
|
474
|
+
if (n.y > maxY)
|
|
475
|
+
maxY = n.y;
|
|
476
|
+
}
|
|
477
|
+
const pad = NODE_RADIUS * 4;
|
|
478
|
+
const graphW = (maxX - minX) + pad * 2;
|
|
479
|
+
const graphH = (maxY - minY) + pad * 2;
|
|
480
|
+
const scaleX = canvas.clientWidth / Math.max(graphW, 1);
|
|
481
|
+
const scaleY = canvas.clientHeight / Math.max(graphH, 1);
|
|
482
|
+
camera.scale = Math.min(scaleX, scaleY, 2);
|
|
483
|
+
camera.x = (minX + maxX) / 2 - canvas.clientWidth / (2 * camera.scale);
|
|
484
|
+
camera.y = (minY + maxY) / 2 - canvas.clientHeight / (2 * camera.scale);
|
|
485
|
+
render();
|
|
486
|
+
}
|
|
388
487
|
function simulate() {
|
|
389
|
-
if (!state || alpha < ALPHA_MIN)
|
|
488
|
+
if (!state || alpha < ALPHA_MIN) {
|
|
489
|
+
// Start walk animation loop if simulation stopped but walk mode is active
|
|
490
|
+
if (walkMode && walkTrail.length > 0 && !walkAnimFrame) {
|
|
491
|
+
walkAnimFrame = requestAnimationFrame(walkAnimate);
|
|
492
|
+
}
|
|
390
493
|
return;
|
|
494
|
+
}
|
|
391
495
|
alpha = tick(state, alpha);
|
|
392
496
|
render();
|
|
393
497
|
animFrame = requestAnimationFrame(simulate);
|
|
@@ -408,7 +512,7 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
408
512
|
return;
|
|
409
513
|
const dx = e.clientX - lastX;
|
|
410
514
|
const dy = e.clientY - lastY;
|
|
411
|
-
if (Math.abs(dx) >
|
|
515
|
+
if (Math.abs(dx) > 5 || Math.abs(dy) > 5)
|
|
412
516
|
didDrag = true;
|
|
413
517
|
camera.x -= dx / camera.scale;
|
|
414
518
|
camera.y -= dy / camera.scale;
|
|
@@ -426,6 +530,60 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
426
530
|
const my = e.clientY - rect.top;
|
|
427
531
|
const hit = nodeAtScreen(mx, my);
|
|
428
532
|
const multiSelect = e.ctrlKey || e.metaKey;
|
|
533
|
+
if (walkMode && focusSeedIds && hit && state) {
|
|
534
|
+
// Walk mode: find path from current position to clicked node
|
|
535
|
+
const currentId = walkTrail.length > 0 ? walkTrail[walkTrail.length - 1] : focusSeedIds[0];
|
|
536
|
+
// BFS in the current subgraph to find path
|
|
537
|
+
const visited = new Set([currentId]);
|
|
538
|
+
const queue = [{ id: currentId, path: [currentId] }];
|
|
539
|
+
let pathToTarget = null;
|
|
540
|
+
while (queue.length > 0) {
|
|
541
|
+
const { id, path } = queue.shift();
|
|
542
|
+
if (id === hit.id) {
|
|
543
|
+
pathToTarget = path;
|
|
544
|
+
break;
|
|
545
|
+
}
|
|
546
|
+
for (const edge of state.edges) {
|
|
547
|
+
let neighbor = null;
|
|
548
|
+
if (edge.sourceId === id)
|
|
549
|
+
neighbor = edge.targetId;
|
|
550
|
+
else if (edge.targetId === id)
|
|
551
|
+
neighbor = edge.sourceId;
|
|
552
|
+
if (neighbor && !visited.has(neighbor)) {
|
|
553
|
+
visited.add(neighbor);
|
|
554
|
+
queue.push({ id: neighbor, path: [...path, neighbor] });
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
// No path found — node is unreachable, ignore click
|
|
559
|
+
if (!pathToTarget)
|
|
560
|
+
return;
|
|
561
|
+
// Add all intermediate nodes to the trail (skip first since it's already the current position)
|
|
562
|
+
for (const id of pathToTarget.slice(1)) {
|
|
563
|
+
if (!walkTrail.includes(id))
|
|
564
|
+
walkTrail.push(id);
|
|
565
|
+
}
|
|
566
|
+
focusSeedIds = [hit.id];
|
|
567
|
+
const walkHops = Math.max(1, focusHops);
|
|
568
|
+
focusHops = walkHops;
|
|
569
|
+
const subgraph = extractSubgraph(lastLoadedData, [hit.id], walkHops);
|
|
570
|
+
cancelAnimationFrame(animFrame);
|
|
571
|
+
state = createLayout(subgraph);
|
|
572
|
+
alpha = 1;
|
|
573
|
+
selectedNodeIds = new Set([hit.id]);
|
|
574
|
+
filteredNodeIds = null;
|
|
575
|
+
camera = { x: 0, y: 0, scale: 1 };
|
|
576
|
+
simulate();
|
|
577
|
+
// Center after physics settle
|
|
578
|
+
setTimeout(() => {
|
|
579
|
+
if (!state)
|
|
580
|
+
return;
|
|
581
|
+
fitToNodes();
|
|
582
|
+
}, 300);
|
|
583
|
+
onFocusChange?.({ seedNodeIds: [hit.id], hops: walkHops, totalNodes: subgraph.nodes.length });
|
|
584
|
+
onNodeClick?.([hit.id]);
|
|
585
|
+
return; // skip normal selection
|
|
586
|
+
}
|
|
429
587
|
if (hit) {
|
|
430
588
|
if (multiSelect) {
|
|
431
589
|
// Toggle node in/out of multi-selection
|
|
@@ -720,25 +878,7 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
720
878
|
render();
|
|
721
879
|
},
|
|
722
880
|
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();
|
|
881
|
+
fitToNodes();
|
|
742
882
|
},
|
|
743
883
|
panBy(dx, dy) {
|
|
744
884
|
camera.x += dx / camera.scale;
|
|
@@ -804,26 +944,15 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
804
944
|
alpha = 1;
|
|
805
945
|
selectedNodeIds = new Set(seedNodeIds);
|
|
806
946
|
filteredNodeIds = null;
|
|
807
|
-
//
|
|
947
|
+
// Start simulation, then center after layout settles
|
|
808
948
|
camera = { x: 0, y: 0, scale: 1 };
|
|
809
|
-
if (state.nodes.length > 0) {
|
|
810
|
-
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
811
|
-
for (const n of state.nodes) {
|
|
812
|
-
if (n.x < minX)
|
|
813
|
-
minX = n.x;
|
|
814
|
-
if (n.y < minY)
|
|
815
|
-
minY = n.y;
|
|
816
|
-
if (n.x > maxX)
|
|
817
|
-
maxX = n.x;
|
|
818
|
-
if (n.y > maxY)
|
|
819
|
-
maxY = n.y;
|
|
820
|
-
}
|
|
821
|
-
const cx = (minX + maxX) / 2;
|
|
822
|
-
const cy = (minY + maxY) / 2;
|
|
823
|
-
camera.x = cx - canvas.clientWidth / 2;
|
|
824
|
-
camera.y = cy - canvas.clientHeight / 2;
|
|
825
|
-
}
|
|
826
949
|
simulate();
|
|
950
|
+
// Center + fit after physics settle
|
|
951
|
+
setTimeout(() => {
|
|
952
|
+
if (!state || !focusSeedIds)
|
|
953
|
+
return;
|
|
954
|
+
fitToNodes();
|
|
955
|
+
}, 300);
|
|
827
956
|
onFocusChange?.({
|
|
828
957
|
seedNodeIds,
|
|
829
958
|
hops,
|
|
@@ -856,6 +985,83 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
856
985
|
totalNodes: state.nodes.length,
|
|
857
986
|
};
|
|
858
987
|
},
|
|
988
|
+
findPath(sourceId, targetId) {
|
|
989
|
+
if (!state)
|
|
990
|
+
return null;
|
|
991
|
+
const visited = new Set([sourceId]);
|
|
992
|
+
const queue = [
|
|
993
|
+
{ nodeId: sourceId, path: [sourceId], edges: [] }
|
|
994
|
+
];
|
|
995
|
+
while (queue.length > 0) {
|
|
996
|
+
const { nodeId, path, edges } = queue.shift();
|
|
997
|
+
if (nodeId === targetId)
|
|
998
|
+
return { nodeIds: path, edgeIds: edges };
|
|
999
|
+
for (const edge of state.edges) {
|
|
1000
|
+
let neighbor = null;
|
|
1001
|
+
if (edge.sourceId === nodeId)
|
|
1002
|
+
neighbor = edge.targetId;
|
|
1003
|
+
else if (edge.targetId === nodeId)
|
|
1004
|
+
neighbor = edge.sourceId;
|
|
1005
|
+
if (neighbor && !visited.has(neighbor)) {
|
|
1006
|
+
visited.add(neighbor);
|
|
1007
|
+
const fullEdge = lastLoadedData?.edges.find(e => (e.sourceId === edge.sourceId && e.targetId === edge.targetId) ||
|
|
1008
|
+
(e.targetId === edge.sourceId && e.sourceId === edge.targetId));
|
|
1009
|
+
queue.push({
|
|
1010
|
+
nodeId: neighbor,
|
|
1011
|
+
path: [...path, neighbor],
|
|
1012
|
+
edges: [...edges, fullEdge?.id ?? ""]
|
|
1013
|
+
});
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
return null;
|
|
1018
|
+
},
|
|
1019
|
+
setHighlightedPath(nodeIds, edgeIds) {
|
|
1020
|
+
if (nodeIds && edgeIds) {
|
|
1021
|
+
highlightedPath = { nodeIds: new Set(nodeIds), edgeIds: new Set(edgeIds) };
|
|
1022
|
+
}
|
|
1023
|
+
else {
|
|
1024
|
+
highlightedPath = null;
|
|
1025
|
+
}
|
|
1026
|
+
render();
|
|
1027
|
+
},
|
|
1028
|
+
clearHighlightedPath() {
|
|
1029
|
+
highlightedPath = null;
|
|
1030
|
+
render();
|
|
1031
|
+
},
|
|
1032
|
+
setWalkMode(enabled) {
|
|
1033
|
+
walkMode = enabled;
|
|
1034
|
+
if (enabled) {
|
|
1035
|
+
walkTrail = focusSeedIds ? [...focusSeedIds] : [...selectedNodeIds];
|
|
1036
|
+
if (!walkAnimFrame)
|
|
1037
|
+
walkAnimFrame = requestAnimationFrame(walkAnimate);
|
|
1038
|
+
}
|
|
1039
|
+
else {
|
|
1040
|
+
walkTrail = [];
|
|
1041
|
+
if (walkAnimFrame) {
|
|
1042
|
+
cancelAnimationFrame(walkAnimFrame);
|
|
1043
|
+
walkAnimFrame = 0;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
render();
|
|
1047
|
+
},
|
|
1048
|
+
getWalkMode() {
|
|
1049
|
+
return walkMode;
|
|
1050
|
+
},
|
|
1051
|
+
getWalkTrail() {
|
|
1052
|
+
return [...walkTrail];
|
|
1053
|
+
},
|
|
1054
|
+
getFilteredNodeIds() {
|
|
1055
|
+
return filteredNodeIds;
|
|
1056
|
+
},
|
|
1057
|
+
removeFromWalkTrail(nodeId) {
|
|
1058
|
+
walkTrail = walkTrail.filter((id) => id !== nodeId);
|
|
1059
|
+
render();
|
|
1060
|
+
},
|
|
1061
|
+
/** Hit-test a screen coordinate against nodes. Returns the node or null. */
|
|
1062
|
+
nodeAtScreen(sx, sy) {
|
|
1063
|
+
return nodeAtScreen(sx, sy);
|
|
1064
|
+
},
|
|
859
1065
|
/** Get all node IDs in the current layout (subgraph if focused, full graph otherwise). Seed nodes first. */
|
|
860
1066
|
getNodeIds() {
|
|
861
1067
|
if (!state)
|
package/dist/config.js
CHANGED
|
@@ -23,6 +23,7 @@ export function loadViewerConfig() {
|
|
|
23
23
|
layout: { ...defaultConfig.layout, ...(user.layout ?? {}) },
|
|
24
24
|
navigation: { ...defaultConfig.navigation, ...(user.navigation ?? {}) },
|
|
25
25
|
lod: { ...defaultConfig.lod, ...(user.lod ?? {}) },
|
|
26
|
+
walk: { ...defaultConfig.walk, ...(user.walk ?? {}) },
|
|
26
27
|
limits: { ...defaultConfig.limits, ...(user.limits ?? {}) },
|
|
27
28
|
};
|
|
28
29
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface ContextMenuCallbacks {
|
|
2
|
+
onStar: (nodeId: string) => void;
|
|
3
|
+
onFocusNode: (nodeId: string) => void;
|
|
4
|
+
onExploreInBranch: (nodeId: string) => void;
|
|
5
|
+
onCopyId: (nodeId: string) => void;
|
|
6
|
+
onExpand?: (nodeId: string) => void;
|
|
7
|
+
onExplainPath?: (nodeId: string) => void;
|
|
8
|
+
onEnrich?: (nodeId: string) => void;
|
|
9
|
+
}
|
|
10
|
+
export declare function initContextMenu(container: HTMLElement, callbacks: ContextMenuCallbacks): {
|
|
11
|
+
show: (nodeId: string, nodeLabel: string, isStarred: boolean, screenX: number, screenY: number) => void;
|
|
12
|
+
hide: () => void;
|
|
13
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
export function initContextMenu(container, callbacks) {
|
|
2
|
+
let menuEl = null;
|
|
3
|
+
function show(nodeId, nodeLabel, isStarred, screenX, screenY) {
|
|
4
|
+
hide();
|
|
5
|
+
menuEl = document.createElement("div");
|
|
6
|
+
menuEl.className = "context-menu";
|
|
7
|
+
menuEl.style.left = `${screenX}px`;
|
|
8
|
+
menuEl.style.top = `${screenY}px`;
|
|
9
|
+
const items = [
|
|
10
|
+
{ label: isStarred ? "\u2605 Unstar" : "\u2606 Star", action: () => callbacks.onStar(nodeId), premium: false },
|
|
11
|
+
{ label: "\u25CE Focus on node", action: () => callbacks.onFocusNode(nodeId), premium: false },
|
|
12
|
+
{ label: "\u2442 Explore in branch", action: () => callbacks.onExploreInBranch(nodeId), premium: false },
|
|
13
|
+
{ label: "\u2398 Copy ID", action: () => callbacks.onCopyId(nodeId), premium: false },
|
|
14
|
+
];
|
|
15
|
+
if (callbacks.onExpand)
|
|
16
|
+
items.push({ label: "\u2295 Expand node", action: () => callbacks.onExpand(nodeId), premium: true });
|
|
17
|
+
if (callbacks.onExplainPath)
|
|
18
|
+
items.push({ label: "\u2194 Explain path to\u2026", action: () => callbacks.onExplainPath(nodeId), premium: true });
|
|
19
|
+
if (callbacks.onEnrich)
|
|
20
|
+
items.push({ label: "\u2261 Enrich from web", action: () => callbacks.onEnrich(nodeId), premium: true });
|
|
21
|
+
let addedSep = false;
|
|
22
|
+
for (const item of items) {
|
|
23
|
+
if (!addedSep && item.premium) {
|
|
24
|
+
const sep = document.createElement("div");
|
|
25
|
+
sep.className = "context-menu-separator";
|
|
26
|
+
menuEl.appendChild(sep);
|
|
27
|
+
addedSep = true;
|
|
28
|
+
}
|
|
29
|
+
const row = document.createElement("div");
|
|
30
|
+
row.className = "context-menu-item";
|
|
31
|
+
row.textContent = item.label;
|
|
32
|
+
row.addEventListener("click", () => {
|
|
33
|
+
item.action();
|
|
34
|
+
hide();
|
|
35
|
+
});
|
|
36
|
+
menuEl.appendChild(row);
|
|
37
|
+
}
|
|
38
|
+
container.appendChild(menuEl);
|
|
39
|
+
// Clamp to viewport
|
|
40
|
+
const rect = menuEl.getBoundingClientRect();
|
|
41
|
+
if (rect.right > window.innerWidth) {
|
|
42
|
+
menuEl.style.left = `${screenX - rect.width}px`;
|
|
43
|
+
}
|
|
44
|
+
if (rect.bottom > window.innerHeight) {
|
|
45
|
+
menuEl.style.top = `${screenY - rect.height}px`;
|
|
46
|
+
}
|
|
47
|
+
// Close on outside click (delayed to not catch the opening right-click)
|
|
48
|
+
setTimeout(() => document.addEventListener("click", hide), 0);
|
|
49
|
+
document.addEventListener("keydown", handleEscape);
|
|
50
|
+
}
|
|
51
|
+
function hide() {
|
|
52
|
+
if (menuEl) {
|
|
53
|
+
menuEl.remove();
|
|
54
|
+
menuEl = null;
|
|
55
|
+
}
|
|
56
|
+
document.removeEventListener("click", hide);
|
|
57
|
+
document.removeEventListener("keydown", handleEscape);
|
|
58
|
+
}
|
|
59
|
+
function handleEscape(e) {
|
|
60
|
+
if (e.key === "Escape")
|
|
61
|
+
hide();
|
|
62
|
+
}
|
|
63
|
+
return { show, hide };
|
|
64
|
+
}
|
package/dist/default-config.json
CHANGED
|
@@ -29,7 +29,9 @@
|
|
|
29
29
|
"spacingIncrease": "]",
|
|
30
30
|
"clusteringDecrease": "{",
|
|
31
31
|
"clusteringIncrease": "}",
|
|
32
|
-
"toggleSidebar": "Tab"
|
|
32
|
+
"toggleSidebar": "Tab",
|
|
33
|
+
"walkMode": "w",
|
|
34
|
+
"walkIsolate": "i"
|
|
33
35
|
},
|
|
34
36
|
"display": {
|
|
35
37
|
"edges": true,
|
|
@@ -57,6 +59,9 @@
|
|
|
57
59
|
"smallNodes": 0.2,
|
|
58
60
|
"hideArrows": 0.15
|
|
59
61
|
},
|
|
62
|
+
"walk": {
|
|
63
|
+
"pulseSpeed": 0.02
|
|
64
|
+
},
|
|
60
65
|
"limits": {
|
|
61
66
|
"maxSearchResults": 8,
|
|
62
67
|
"maxQualityItems": 5,
|
package/dist/keybindings.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type KeybindingAction = "search" | "searchAlt" | "undo" | "redo" | "help" | "escape" | "focus" | "toggleEdges" | "center" | "nextNode" | "prevNode" | "nextConnection" | "prevConnection" | "historyBack" | "historyForward" | "hopsIncrease" | "hopsDecrease" | "panLeft" | "panDown" | "panUp" | "panRight" | "panFastLeft" | "zoomOut" | "zoomIn" | "panFastRight" | "spacingDecrease" | "spacingIncrease" | "clusteringDecrease" | "clusteringIncrease" | "toggleSidebar";
|
|
1
|
+
export type KeybindingAction = "search" | "searchAlt" | "undo" | "redo" | "help" | "escape" | "focus" | "toggleEdges" | "center" | "nextNode" | "prevNode" | "nextConnection" | "prevConnection" | "historyBack" | "historyForward" | "hopsIncrease" | "hopsDecrease" | "panLeft" | "panDown" | "panUp" | "panRight" | "panFastLeft" | "zoomOut" | "zoomIn" | "panFastRight" | "spacingDecrease" | "spacingIncrease" | "clusteringDecrease" | "clusteringIncrease" | "toggleSidebar" | "walkMode" | "walkIsolate";
|
|
2
2
|
export type KeybindingMap = Record<KeybindingAction, string>;
|
|
3
3
|
/** Parse a binding string like "ctrl+shift+z" and check if it matches a KeyboardEvent. */
|
|
4
4
|
export declare function matchKey(e: KeyboardEvent, binding: string): boolean;
|
package/dist/keybindings.js
CHANGED
|
@@ -63,5 +63,7 @@ export function actionDescriptions() {
|
|
|
63
63
|
clusteringDecrease: "Decrease clustering",
|
|
64
64
|
clusteringIncrease: "Increase clustering",
|
|
65
65
|
toggleSidebar: "Toggle sidebar",
|
|
66
|
+
walkMode: "Toggle walk mode (in focus)",
|
|
67
|
+
walkIsolate: "Isolate walk trail nodes",
|
|
66
68
|
};
|
|
67
69
|
}
|