backpack-viewer 0.5.0 → 0.6.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/bin/serve.js +105 -1
- package/dist/app/assets/index-B3z5bBGl.css +1 -0
- package/dist/app/assets/index-BROJmzot.js +35 -0
- package/dist/app/assets/layout-worker-4xak23M6.js +1 -0
- package/dist/app/index.html +2 -2
- package/dist/canvas.d.ts +15 -0
- package/dist/canvas.js +352 -12
- package/dist/default-config.json +2 -1
- package/dist/keybindings.d.ts +1 -1
- package/dist/keybindings.js +1 -0
- package/dist/layout-worker.d.ts +4 -1
- package/dist/layout-worker.js +51 -1
- package/dist/layout.d.ts +8 -0
- package/dist/layout.js +8 -1
- package/dist/main.js +38 -6
- package/dist/shortcuts.js +6 -2
- package/dist/sidebar.d.ts +1 -0
- package/dist/sidebar.js +24 -0
- package/dist/style.css +34 -0
- package/package.json +1 -1
- package/dist/app/assets/index-D7P8kwxI.js +0 -34
- package/dist/app/assets/index-dtYoeajN.css +0 -1
- package/dist/app/assets/layout-worker-BZXiBoiC.js +0 -1
package/dist/canvas.js
CHANGED
|
@@ -34,6 +34,22 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
34
34
|
let showEdgeLabels = true;
|
|
35
35
|
let showTypeHulls = true;
|
|
36
36
|
let showMinimap = true;
|
|
37
|
+
let dragMode = "idle";
|
|
38
|
+
// Node(s) being dragged, with their starting world positions so the
|
|
39
|
+
// mouse delta can translate a whole selection as a rigid body.
|
|
40
|
+
let dragNodes = [];
|
|
41
|
+
// World coords of the cursor when the drag started — used to compute
|
|
42
|
+
// how far the dragged group should move each mousemove.
|
|
43
|
+
let dragStartWorldX = 0;
|
|
44
|
+
let dragStartWorldY = 0;
|
|
45
|
+
// Rubber-band rectangle in world coords during shift-drag on empty canvas.
|
|
46
|
+
let rubberBand = null;
|
|
47
|
+
// IDs of nodes that currently have `pinned: true`. Tracked here for
|
|
48
|
+
// rendering the pin indicator without scanning the full nodes array.
|
|
49
|
+
let pinnedNodeIds = new Set();
|
|
50
|
+
// Pixels the cursor must move between mousedown and mousemove before
|
|
51
|
+
// the gesture is promoted from "click" to "drag". Matches pan threshold.
|
|
52
|
+
const CLICK_DRAG_THRESHOLD = 5;
|
|
37
53
|
// Focus mode state
|
|
38
54
|
let lastLoadedData = null;
|
|
39
55
|
let focusSeedIds = null;
|
|
@@ -143,6 +159,31 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
143
159
|
}
|
|
144
160
|
// --- Rendering ---
|
|
145
161
|
/** Draw only walk pulse effects (edges + node glows) + minimap on top of cached scene. */
|
|
162
|
+
/**
|
|
163
|
+
* Draw the rubber-band selection rectangle in world coords, if active.
|
|
164
|
+
* Intended to be called AFTER the main scene has been drawn and
|
|
165
|
+
* AFTER the camera transform has been applied (so the rect is in
|
|
166
|
+
* world space). Idempotent when no rubber-band is active.
|
|
167
|
+
*/
|
|
168
|
+
function drawRubberBandIfActive() {
|
|
169
|
+
if (!rubberBand)
|
|
170
|
+
return;
|
|
171
|
+
ctx.save();
|
|
172
|
+
const minX = Math.min(rubberBand.x1, rubberBand.x2);
|
|
173
|
+
const maxX = Math.max(rubberBand.x1, rubberBand.x2);
|
|
174
|
+
const minY = Math.min(rubberBand.y1, rubberBand.y2);
|
|
175
|
+
const maxY = Math.max(rubberBand.y1, rubberBand.y2);
|
|
176
|
+
ctx.strokeStyle = cssVar("--accent") || "#d4a27f";
|
|
177
|
+
ctx.fillStyle = cssVar("--accent") || "#d4a27f";
|
|
178
|
+
ctx.globalAlpha = 0.12;
|
|
179
|
+
ctx.fillRect(minX, minY, maxX - minX, maxY - minY);
|
|
180
|
+
ctx.globalAlpha = 0.8;
|
|
181
|
+
ctx.lineWidth = 1 / Math.max(camera.scale, 0.5);
|
|
182
|
+
ctx.setLineDash([6 / Math.max(camera.scale, 0.5), 4 / Math.max(camera.scale, 0.5)]);
|
|
183
|
+
ctx.strokeRect(minX, minY, maxX - minX, maxY - minY);
|
|
184
|
+
ctx.setLineDash([]);
|
|
185
|
+
ctx.restore();
|
|
186
|
+
}
|
|
146
187
|
function renderWalkOverlay() {
|
|
147
188
|
if (!state)
|
|
148
189
|
return;
|
|
@@ -203,6 +244,8 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
203
244
|
ctx.stroke();
|
|
204
245
|
}
|
|
205
246
|
ctx.globalAlpha = 1;
|
|
247
|
+
// Rubber-band overlay (still inside the world-transform save/restore)
|
|
248
|
+
drawRubberBandIfActive();
|
|
206
249
|
ctx.restore();
|
|
207
250
|
// Minimap
|
|
208
251
|
if (showMinimap && state.nodes.length > 1) {
|
|
@@ -468,6 +511,22 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
468
511
|
ctx.lineWidth = isSelected ? 3 : 1.5;
|
|
469
512
|
ctx.stroke();
|
|
470
513
|
ctx.globalAlpha = 1;
|
|
514
|
+
// Pin indicator — subtle dashed outer ring on pinned nodes so the
|
|
515
|
+
// user can see which positions are being manually held. Rendered
|
|
516
|
+
// after the main circle but before the label so the label stays
|
|
517
|
+
// on top.
|
|
518
|
+
if (node.pinned) {
|
|
519
|
+
ctx.save();
|
|
520
|
+
ctx.strokeStyle = nodeBorder;
|
|
521
|
+
ctx.globalAlpha = 0.55;
|
|
522
|
+
ctx.lineWidth = 1;
|
|
523
|
+
ctx.setLineDash([3, 3]);
|
|
524
|
+
ctx.beginPath();
|
|
525
|
+
ctx.arc(node.x, node.y, r + 4, 0, Math.PI * 2);
|
|
526
|
+
ctx.stroke();
|
|
527
|
+
ctx.setLineDash([]);
|
|
528
|
+
ctx.restore();
|
|
529
|
+
}
|
|
471
530
|
// Highlighted path glow
|
|
472
531
|
if (highlightedPath && highlightedPath.nodeIds.has(node.id) && !isSelected) {
|
|
473
532
|
ctx.save();
|
|
@@ -528,6 +587,16 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
528
587
|
if (showMinimap && state.nodes.length > 1) {
|
|
529
588
|
drawMinimap();
|
|
530
589
|
}
|
|
590
|
+
// Rubber-band overlay — drawn AFTER the cache snapshot so it never
|
|
591
|
+
// gets baked into the cached scene. Uses its own camera transform.
|
|
592
|
+
if (rubberBand) {
|
|
593
|
+
ctx.save();
|
|
594
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
595
|
+
ctx.translate(-camera.x * camera.scale, -camera.y * camera.scale);
|
|
596
|
+
ctx.scale(camera.scale, camera.scale);
|
|
597
|
+
drawRubberBandIfActive();
|
|
598
|
+
ctx.restore();
|
|
599
|
+
}
|
|
531
600
|
}
|
|
532
601
|
function drawMinimap() {
|
|
533
602
|
if (!state)
|
|
@@ -689,6 +758,11 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
689
758
|
}
|
|
690
759
|
function simulate() {
|
|
691
760
|
if (!state || alpha < ALPHA_MIN) {
|
|
761
|
+
// Clear animFrame so callers using the `if (!animFrame) simulate()`
|
|
762
|
+
// idiom can kick the sim back on after releasing pins or bumping
|
|
763
|
+
// alpha. Without this reset, animFrame stays truthy from the last
|
|
764
|
+
// RAF handle and subsequent restart attempts silently no-op.
|
|
765
|
+
animFrame = 0;
|
|
692
766
|
// Start walk animation loop if simulation stopped but walk mode is active
|
|
693
767
|
if (walkMode && walkTrail.length > 0 && !walkAnimFrame) {
|
|
694
768
|
walkAnimFrame = requestAnimationFrame(walkAnimate);
|
|
@@ -700,32 +774,185 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
700
774
|
render();
|
|
701
775
|
animFrame = requestAnimationFrame(simulate);
|
|
702
776
|
}
|
|
703
|
-
// --- Interaction: Pan + Click ---
|
|
704
|
-
|
|
777
|
+
// --- Interaction: Pan + Click + Node drag + Rubber-band select ---
|
|
778
|
+
//
|
|
779
|
+
// Gesture dispatch on mousedown:
|
|
780
|
+
// 1. mousedown on a node (no modifier) + cursor moves > threshold →
|
|
781
|
+
// NODE DRAG. If the node is part of the current selection, drag
|
|
782
|
+
// the whole selection as a rigid body. Otherwise drag just this
|
|
783
|
+
// node. On drop, pin all dragged nodes.
|
|
784
|
+
// 2. mousedown on a node (no movement) → CLICK (existing selection
|
|
785
|
+
// and walk-mode behavior).
|
|
786
|
+
// 3. mousedown on empty canvas with Shift → RUBBER-BAND select.
|
|
787
|
+
// 4. mousedown on empty canvas without modifier → PAN (existing).
|
|
788
|
+
//
|
|
789
|
+
// Node drag is disabled during walk mode — in walk mode, clicking a
|
|
790
|
+
// node advances the path, and drag gestures would fight the animation.
|
|
705
791
|
let didDrag = false;
|
|
706
792
|
let lastX = 0;
|
|
707
793
|
let lastY = 0;
|
|
794
|
+
let mouseDownStartX = 0;
|
|
795
|
+
let mouseDownStartY = 0;
|
|
708
796
|
canvas.addEventListener("mousedown", (e) => {
|
|
709
|
-
|
|
797
|
+
dragMode = "pending";
|
|
710
798
|
didDrag = false;
|
|
711
799
|
lastX = e.clientX;
|
|
712
800
|
lastY = e.clientY;
|
|
801
|
+
mouseDownStartX = e.clientX;
|
|
802
|
+
mouseDownStartY = e.clientY;
|
|
803
|
+
// Decide the potential gesture now — committed on first movement
|
|
804
|
+
// past the threshold. We still track "pending" so mouseup without
|
|
805
|
+
// movement falls through to the click path below.
|
|
806
|
+
const rect = canvas.getBoundingClientRect();
|
|
807
|
+
const mx = e.clientX - rect.left;
|
|
808
|
+
const my = e.clientY - rect.top;
|
|
809
|
+
const hit = nodeAtScreen(mx, my);
|
|
810
|
+
if (hit && !walkMode) {
|
|
811
|
+
// Set up the pending node drag. On first movement past the
|
|
812
|
+
// threshold we commit to "nodeDrag" mode.
|
|
813
|
+
dragNodes = [];
|
|
814
|
+
if (selectedNodeIds.has(hit.id) && selectedNodeIds.size > 1) {
|
|
815
|
+
// Drag the whole selection as a group
|
|
816
|
+
for (const id of selectedNodeIds) {
|
|
817
|
+
const n = state?.nodeMap.get(id);
|
|
818
|
+
if (n)
|
|
819
|
+
dragNodes.push({ node: n, startX: n.x, startY: n.y });
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
else {
|
|
823
|
+
dragNodes.push({ node: hit, startX: hit.x, startY: hit.y });
|
|
824
|
+
}
|
|
825
|
+
const [wx, wy] = screenToWorld(mx, my);
|
|
826
|
+
dragStartWorldX = wx;
|
|
827
|
+
dragStartWorldY = wy;
|
|
828
|
+
}
|
|
829
|
+
else if (!hit && e.shiftKey) {
|
|
830
|
+
// Pending rubber-band (commits on first movement)
|
|
831
|
+
const [wx, wy] = screenToWorld(mx, my);
|
|
832
|
+
rubberBand = { x1: wx, y1: wy, x2: wx, y2: wy };
|
|
833
|
+
}
|
|
834
|
+
// Otherwise (empty canvas, no shift): will become pan on first move
|
|
713
835
|
});
|
|
714
836
|
canvas.addEventListener("mousemove", (e) => {
|
|
715
|
-
if (
|
|
837
|
+
if (dragMode === "idle")
|
|
716
838
|
return;
|
|
717
839
|
const dx = e.clientX - lastX;
|
|
718
840
|
const dy = e.clientY - lastY;
|
|
719
|
-
|
|
841
|
+
const totalDx = Math.abs(e.clientX - mouseDownStartX);
|
|
842
|
+
const totalDy = Math.abs(e.clientY - mouseDownStartY);
|
|
843
|
+
// Commit to a gesture once the cursor crosses the threshold
|
|
844
|
+
if (dragMode === "pending" &&
|
|
845
|
+
(totalDx > CLICK_DRAG_THRESHOLD || totalDy > CLICK_DRAG_THRESHOLD)) {
|
|
720
846
|
didDrag = true;
|
|
721
|
-
|
|
722
|
-
|
|
847
|
+
if (dragNodes.length > 0) {
|
|
848
|
+
dragMode = "nodeDrag";
|
|
849
|
+
// Freeze the worker while the user is actively dragging so its
|
|
850
|
+
// incoming tick messages don't fight our local x/y updates
|
|
851
|
+
if (useWorker && layoutWorker) {
|
|
852
|
+
layoutWorker.postMessage({ type: "stop" });
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
else if (rubberBand) {
|
|
856
|
+
dragMode = "rubberBand";
|
|
857
|
+
}
|
|
858
|
+
else {
|
|
859
|
+
dragMode = "pan";
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
if (dragMode === "nodeDrag") {
|
|
863
|
+
// Translate the dragged group by the cursor delta in world coords
|
|
864
|
+
const rect = canvas.getBoundingClientRect();
|
|
865
|
+
const mx = e.clientX - rect.left;
|
|
866
|
+
const my = e.clientY - rect.top;
|
|
867
|
+
const [wx, wy] = screenToWorld(mx, my);
|
|
868
|
+
const worldDx = wx - dragStartWorldX;
|
|
869
|
+
const worldDy = wy - dragStartWorldY;
|
|
870
|
+
for (const d of dragNodes) {
|
|
871
|
+
d.node.x = d.startX + worldDx;
|
|
872
|
+
d.node.y = d.startY + worldDy;
|
|
873
|
+
d.node.vx = 0;
|
|
874
|
+
d.node.vy = 0;
|
|
875
|
+
d.node.pinned = true;
|
|
876
|
+
pinnedNodeIds.add(d.node.id);
|
|
877
|
+
}
|
|
878
|
+
nodeHash.rebuild(state?.nodes ?? []);
|
|
879
|
+
requestRedraw();
|
|
880
|
+
}
|
|
881
|
+
else if (dragMode === "rubberBand" && rubberBand) {
|
|
882
|
+
const rect = canvas.getBoundingClientRect();
|
|
883
|
+
const mx = e.clientX - rect.left;
|
|
884
|
+
const my = e.clientY - rect.top;
|
|
885
|
+
const [wx, wy] = screenToWorld(mx, my);
|
|
886
|
+
rubberBand.x2 = wx;
|
|
887
|
+
rubberBand.y2 = wy;
|
|
888
|
+
requestRedraw();
|
|
889
|
+
}
|
|
890
|
+
else if (dragMode === "pan") {
|
|
891
|
+
camera.x -= dx / camera.scale;
|
|
892
|
+
camera.y -= dy / camera.scale;
|
|
893
|
+
requestRedraw();
|
|
894
|
+
}
|
|
723
895
|
lastX = e.clientX;
|
|
724
896
|
lastY = e.clientY;
|
|
725
|
-
requestRedraw();
|
|
726
897
|
});
|
|
727
898
|
canvas.addEventListener("mouseup", (e) => {
|
|
728
|
-
|
|
899
|
+
const wasNodeDrag = dragMode === "nodeDrag";
|
|
900
|
+
const wasRubberBand = dragMode === "rubberBand";
|
|
901
|
+
const draggedNodeIdsSnapshot = dragNodes.map((d) => d.node.id);
|
|
902
|
+
if (wasNodeDrag) {
|
|
903
|
+
// Commit pins to the worker's copy so its simulation respects them
|
|
904
|
+
if (useWorker && layoutWorker && state) {
|
|
905
|
+
const updates = dragNodes.map((d) => ({
|
|
906
|
+
id: d.node.id,
|
|
907
|
+
x: d.node.x,
|
|
908
|
+
y: d.node.y,
|
|
909
|
+
}));
|
|
910
|
+
layoutWorker.postMessage({ type: "pin", updates });
|
|
911
|
+
layoutWorker.postMessage({ type: "resume", alpha: 0.5 });
|
|
912
|
+
}
|
|
913
|
+
else {
|
|
914
|
+
// Main-thread simulation — just bump alpha so neighbors reflow
|
|
915
|
+
alpha = Math.max(alpha, 0.5);
|
|
916
|
+
if (!animFrame)
|
|
917
|
+
simulate();
|
|
918
|
+
}
|
|
919
|
+
dragMode = "idle";
|
|
920
|
+
dragNodes = [];
|
|
921
|
+
requestRedraw();
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
if (wasRubberBand && rubberBand && state) {
|
|
925
|
+
// Compute which nodes are inside the rectangle (world coords)
|
|
926
|
+
const minX = Math.min(rubberBand.x1, rubberBand.x2);
|
|
927
|
+
const maxX = Math.max(rubberBand.x1, rubberBand.x2);
|
|
928
|
+
const minY = Math.min(rubberBand.y1, rubberBand.y2);
|
|
929
|
+
const maxY = Math.max(rubberBand.y1, rubberBand.y2);
|
|
930
|
+
// Shift extends the existing selection; rubber-band without shift
|
|
931
|
+
// is currently impossible (we only enter this mode on shift+drag),
|
|
932
|
+
// but if that changes later we handle both cases here.
|
|
933
|
+
if (!e.shiftKey)
|
|
934
|
+
selectedNodeIds.clear();
|
|
935
|
+
for (const node of state.nodes) {
|
|
936
|
+
if (node.x >= minX && node.x <= maxX && node.y >= minY && node.y <= maxY) {
|
|
937
|
+
selectedNodeIds.add(node.id);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
const ids = [...selectedNodeIds];
|
|
941
|
+
onNodeClick?.(ids.length > 0 ? ids : null);
|
|
942
|
+
rubberBand = null;
|
|
943
|
+
dragMode = "idle";
|
|
944
|
+
requestRedraw();
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
// Pan finishing → nothing to do, state cleanup below
|
|
948
|
+
if (dragMode === "pan") {
|
|
949
|
+
dragMode = "idle";
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
// Fall through: this was a click (pending → no drag). Treat as the
|
|
953
|
+
// existing click-to-select / walk-mode-path behavior.
|
|
954
|
+
dragMode = "idle";
|
|
955
|
+
rubberBand = null;
|
|
729
956
|
if (didDrag)
|
|
730
957
|
return;
|
|
731
958
|
// Click — hit test for node selection
|
|
@@ -733,7 +960,8 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
733
960
|
const mx = e.clientX - rect.left;
|
|
734
961
|
const my = e.clientY - rect.top;
|
|
735
962
|
const hit = nodeAtScreen(mx, my);
|
|
736
|
-
const multiSelect = e.ctrlKey || e.metaKey;
|
|
963
|
+
const multiSelect = e.ctrlKey || e.metaKey || e.shiftKey;
|
|
964
|
+
void draggedNodeIdsSnapshot;
|
|
737
965
|
if (walkMode && focusSeedIds && hit && state) {
|
|
738
966
|
// Walk mode: find path from current position to clicked node
|
|
739
967
|
const currentId = walkTrail.length > 0 ? walkTrail[walkTrail.length - 1] : focusSeedIds[0];
|
|
@@ -829,7 +1057,32 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
829
1057
|
requestRedraw();
|
|
830
1058
|
});
|
|
831
1059
|
canvas.addEventListener("mouseleave", () => {
|
|
832
|
-
|
|
1060
|
+
// Cancel any in-progress drag. Node drags keep the pins they already
|
|
1061
|
+
// applied (the dragged nodes stay where the cursor last was). Rubber-
|
|
1062
|
+
// band selects are abandoned without committing the selection.
|
|
1063
|
+
if (dragMode === "nodeDrag") {
|
|
1064
|
+
// Sync the final pin positions to the worker so simulation resumes
|
|
1065
|
+
if (useWorker && layoutWorker && dragNodes.length > 0) {
|
|
1066
|
+
const updates = dragNodes.map((d) => ({
|
|
1067
|
+
id: d.node.id,
|
|
1068
|
+
x: d.node.x,
|
|
1069
|
+
y: d.node.y,
|
|
1070
|
+
}));
|
|
1071
|
+
layoutWorker.postMessage({ type: "pin", updates });
|
|
1072
|
+
layoutWorker.postMessage({ type: "resume", alpha: 0.5 });
|
|
1073
|
+
}
|
|
1074
|
+
else {
|
|
1075
|
+
alpha = Math.max(alpha, 0.5);
|
|
1076
|
+
if (!animFrame)
|
|
1077
|
+
simulate();
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
if (dragMode === "rubberBand") {
|
|
1081
|
+
rubberBand = null;
|
|
1082
|
+
requestRedraw();
|
|
1083
|
+
}
|
|
1084
|
+
dragMode = "idle";
|
|
1085
|
+
dragNodes = [];
|
|
833
1086
|
});
|
|
834
1087
|
// --- Interaction: Zoom (wheel + pinch) ---
|
|
835
1088
|
canvas.addEventListener("wheel", (e) => {
|
|
@@ -996,7 +1249,8 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
996
1249
|
let hoverNodeId = null;
|
|
997
1250
|
let hoverTimeout = null;
|
|
998
1251
|
canvas.addEventListener("mousemove", (e) => {
|
|
999
|
-
|
|
1252
|
+
// Suppress hover tooltip while any drag gesture is active
|
|
1253
|
+
if (dragMode !== "idle" && dragMode !== "pending") {
|
|
1000
1254
|
if (tooltip.style.display !== "none") {
|
|
1001
1255
|
tooltip.style.display = "none";
|
|
1002
1256
|
hoverNodeId = null;
|
|
@@ -1056,6 +1310,11 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
1056
1310
|
alpha = 1;
|
|
1057
1311
|
selectedNodeIds = new Set();
|
|
1058
1312
|
filteredNodeIds = null;
|
|
1313
|
+
// Reset any lingering drag/pin state from the previous graph
|
|
1314
|
+
pinnedNodeIds.clear();
|
|
1315
|
+
dragMode = "idle";
|
|
1316
|
+
dragNodes = [];
|
|
1317
|
+
rubberBand = null;
|
|
1059
1318
|
// Center camera on the graph
|
|
1060
1319
|
camera = { x: 0, y: 0, scale: 1 };
|
|
1061
1320
|
if (state.nodes.length > 0) {
|
|
@@ -1092,6 +1351,66 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
1092
1351
|
filteredNodeIds = ids;
|
|
1093
1352
|
requestRedraw();
|
|
1094
1353
|
},
|
|
1354
|
+
/**
|
|
1355
|
+
* Release every manually-pinned node so the force simulation
|
|
1356
|
+
* reclaims them. Called by main.ts when a data event (node/edge
|
|
1357
|
+
* add/remove, graph switch, backpack switch, focus/walk mode
|
|
1358
|
+
* enter/exit, live reload) invalidates the user's temporary
|
|
1359
|
+
* layout tweaks.
|
|
1360
|
+
*
|
|
1361
|
+
* Returns `true` if any pins were released, so the caller can
|
|
1362
|
+
* show a toast only when the user actually loses work.
|
|
1363
|
+
*/
|
|
1364
|
+
releaseAllPins() {
|
|
1365
|
+
if (!state)
|
|
1366
|
+
return false;
|
|
1367
|
+
let hadPins = false;
|
|
1368
|
+
for (const node of state.nodes) {
|
|
1369
|
+
if (node.pinned) {
|
|
1370
|
+
hadPins = true;
|
|
1371
|
+
node.pinned = false;
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
pinnedNodeIds.clear();
|
|
1375
|
+
// Clear any in-progress drag state too — a data change mid-drag
|
|
1376
|
+
// should abort the drag cleanly
|
|
1377
|
+
dragMode = "idle";
|
|
1378
|
+
dragNodes = [];
|
|
1379
|
+
rubberBand = null;
|
|
1380
|
+
if (hadPins) {
|
|
1381
|
+
// Nudge the simulation so the freed nodes start moving
|
|
1382
|
+
if (useWorker && layoutWorker) {
|
|
1383
|
+
layoutWorker.postMessage({ type: "unpin", ids: "all" });
|
|
1384
|
+
}
|
|
1385
|
+
else {
|
|
1386
|
+
alpha = Math.max(alpha, 0.5);
|
|
1387
|
+
if (!animFrame)
|
|
1388
|
+
simulate();
|
|
1389
|
+
}
|
|
1390
|
+
requestRedraw();
|
|
1391
|
+
}
|
|
1392
|
+
return hadPins;
|
|
1393
|
+
},
|
|
1394
|
+
hasPinnedNodes() {
|
|
1395
|
+
if (!state)
|
|
1396
|
+
return false;
|
|
1397
|
+
for (const node of state.nodes) {
|
|
1398
|
+
if (node.pinned)
|
|
1399
|
+
return true;
|
|
1400
|
+
}
|
|
1401
|
+
return false;
|
|
1402
|
+
},
|
|
1403
|
+
/** Clear the multi-selection (used by ESC keyboard shortcut). */
|
|
1404
|
+
clearSelection() {
|
|
1405
|
+
if (selectedNodeIds.size === 0)
|
|
1406
|
+
return;
|
|
1407
|
+
selectedNodeIds.clear();
|
|
1408
|
+
onNodeClick?.(null);
|
|
1409
|
+
requestRedraw();
|
|
1410
|
+
},
|
|
1411
|
+
getSelectedNodeIds() {
|
|
1412
|
+
return [...selectedNodeIds];
|
|
1413
|
+
},
|
|
1095
1414
|
panToNode(nodeId) {
|
|
1096
1415
|
this.panToNodes([nodeId]);
|
|
1097
1416
|
},
|
|
@@ -1215,6 +1534,14 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
1215
1534
|
enterFocus(seedNodeIds, hops) {
|
|
1216
1535
|
if (!lastLoadedData || !state)
|
|
1217
1536
|
return;
|
|
1537
|
+
// Release any user-pinned nodes — focus has its own layout and
|
|
1538
|
+
// shouldn't inherit manual tweaks from the full-graph view.
|
|
1539
|
+
for (const n of state.nodes)
|
|
1540
|
+
n.pinned = false;
|
|
1541
|
+
pinnedNodeIds.clear();
|
|
1542
|
+
dragMode = "idle";
|
|
1543
|
+
dragNodes = [];
|
|
1544
|
+
rubberBand = null;
|
|
1218
1545
|
// Save current full-graph state
|
|
1219
1546
|
if (!focusSeedIds) {
|
|
1220
1547
|
savedFullState = state;
|
|
@@ -1261,6 +1588,15 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
1261
1588
|
if (layoutWorker)
|
|
1262
1589
|
layoutWorker.postMessage({ type: "stop" });
|
|
1263
1590
|
state = savedFullState;
|
|
1591
|
+
// The saved full-graph state may have stale pinned flags from
|
|
1592
|
+
// before the focus transition; scrub them so the user starts
|
|
1593
|
+
// fresh on exit (pins are temporary view tweaks, not persistent).
|
|
1594
|
+
for (const n of state.nodes)
|
|
1595
|
+
n.pinned = false;
|
|
1596
|
+
pinnedNodeIds.clear();
|
|
1597
|
+
dragMode = "idle";
|
|
1598
|
+
dragNodes = [];
|
|
1599
|
+
rubberBand = null;
|
|
1264
1600
|
nodeHash.rebuild(state.nodes);
|
|
1265
1601
|
camera = savedFullCamera ?? { x: 0, y: 0, scale: 1 };
|
|
1266
1602
|
focusSeedIds = null;
|
|
@@ -1329,6 +1665,10 @@ export function initCanvas(container, onNodeClick, onFocusChange, config) {
|
|
|
1329
1665
|
},
|
|
1330
1666
|
setWalkMode(enabled) {
|
|
1331
1667
|
walkMode = enabled;
|
|
1668
|
+
// Walk mode has its own layout choreography and must not fight
|
|
1669
|
+
// with user-pinned positions. Releasing pins on mode transitions
|
|
1670
|
+
// (either direction) keeps the two subsystems independent.
|
|
1671
|
+
this.releaseAllPins();
|
|
1332
1672
|
if (enabled) {
|
|
1333
1673
|
walkTrail = focusSeedIds ? [...focusSeedIds] : [...selectedNodeIds];
|
|
1334
1674
|
if (!walkAnimFrame)
|
package/dist/default-config.json
CHANGED
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" | "walkMode" | "walkIsolate";
|
|
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" | "resetPins";
|
|
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
package/dist/layout-worker.d.ts
CHANGED
|
@@ -7,8 +7,11 @@
|
|
|
7
7
|
* Protocol:
|
|
8
8
|
* Main → Worker:
|
|
9
9
|
* { type: 'start', nodes, edges, params } — begin simulation
|
|
10
|
-
* { type: 'stop' } — halt simulation
|
|
10
|
+
* { type: 'stop' } — halt simulation (pauses)
|
|
11
|
+
* { type: 'resume', alpha? } — resume after a stop
|
|
11
12
|
* { type: 'params', params } — update layout params + reheat
|
|
13
|
+
* { type: 'pin', updates: [{id, x, y}] } — mark node(s) as pinned at given pos
|
|
14
|
+
* { type: 'unpin', ids: string[] | 'all' } — release pins; 'all' clears every pin
|
|
12
15
|
*
|
|
13
16
|
* Worker → Main:
|
|
14
17
|
* { type: 'tick', positions: Float64Array, alpha } — position update per tick
|
package/dist/layout-worker.js
CHANGED
|
@@ -7,8 +7,11 @@
|
|
|
7
7
|
* Protocol:
|
|
8
8
|
* Main → Worker:
|
|
9
9
|
* { type: 'start', nodes, edges, params } — begin simulation
|
|
10
|
-
* { type: 'stop' } — halt simulation
|
|
10
|
+
* { type: 'stop' } — halt simulation (pauses)
|
|
11
|
+
* { type: 'resume', alpha? } — resume after a stop
|
|
11
12
|
* { type: 'params', params } — update layout params + reheat
|
|
13
|
+
* { type: 'pin', updates: [{id, x, y}] } — mark node(s) as pinned at given pos
|
|
14
|
+
* { type: 'unpin', ids: string[] | 'all' } — release pins; 'all' clears every pin
|
|
12
15
|
*
|
|
13
16
|
* Worker → Main:
|
|
14
17
|
* { type: 'tick', positions: Float64Array, alpha } — position update per tick
|
|
@@ -66,6 +69,53 @@ self.onmessage = (e) => {
|
|
|
66
69
|
if (msg.type === "stop") {
|
|
67
70
|
running = false;
|
|
68
71
|
}
|
|
72
|
+
if (msg.type === "resume") {
|
|
73
|
+
if (!running && state) {
|
|
74
|
+
alpha = Math.max(alpha, typeof msg.alpha === "number" ? msg.alpha : 0.5);
|
|
75
|
+
running = true;
|
|
76
|
+
runLoop();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (msg.type === "pin" && state) {
|
|
80
|
+
// updates: [{ id, x, y }] — apply to the worker's state copy. Pinned
|
|
81
|
+
// nodes are treated as fixed points by tick() in layout.ts.
|
|
82
|
+
const updates = msg.updates;
|
|
83
|
+
for (const u of updates) {
|
|
84
|
+
const node = state.nodeMap.get(u.id);
|
|
85
|
+
if (node) {
|
|
86
|
+
node.x = u.x;
|
|
87
|
+
node.y = u.y;
|
|
88
|
+
node.vx = 0;
|
|
89
|
+
node.vy = 0;
|
|
90
|
+
node.pinned = true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Nudge simulation so neighbors react to the new pin location
|
|
94
|
+
alpha = Math.max(alpha, 0.3);
|
|
95
|
+
if (!running) {
|
|
96
|
+
running = true;
|
|
97
|
+
runLoop();
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (msg.type === "unpin" && state) {
|
|
101
|
+
const ids = msg.ids;
|
|
102
|
+
if (ids === "all") {
|
|
103
|
+
for (const node of state.nodes)
|
|
104
|
+
node.pinned = false;
|
|
105
|
+
}
|
|
106
|
+
else if (Array.isArray(ids)) {
|
|
107
|
+
for (const id of ids) {
|
|
108
|
+
const node = state.nodeMap.get(id);
|
|
109
|
+
if (node)
|
|
110
|
+
node.pinned = false;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
alpha = Math.max(alpha, 0.5);
|
|
114
|
+
if (!running) {
|
|
115
|
+
running = true;
|
|
116
|
+
runLoop();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
69
119
|
if (msg.type === "params") {
|
|
70
120
|
setLayoutParams(msg.params);
|
|
71
121
|
// Reheat simulation
|
package/dist/layout.d.ts
CHANGED
|
@@ -7,6 +7,14 @@ export interface LayoutNode {
|
|
|
7
7
|
vy: number;
|
|
8
8
|
label: string;
|
|
9
9
|
type: string;
|
|
10
|
+
/**
|
|
11
|
+
* When true, the simulation treats this node as a fixed point:
|
|
12
|
+
* it still contributes forces to neighbors (its x/y is read for
|
|
13
|
+
* repulsion and attraction calculations) but its own position is
|
|
14
|
+
* not updated by the integration step. Set by the viewer's drag
|
|
15
|
+
* handler to temporarily pin a node at a user-chosen location.
|
|
16
|
+
*/
|
|
17
|
+
pinned?: boolean;
|
|
10
18
|
}
|
|
11
19
|
export interface LayoutEdge {
|
|
12
20
|
sourceId: string;
|
package/dist/layout.js
CHANGED
|
@@ -229,8 +229,15 @@ export function tick(state, alpha) {
|
|
|
229
229
|
node.vx += (c.x - node.x) * params.clusterStrength * alpha;
|
|
230
230
|
node.vy += (c.y - node.y) * params.clusterStrength * alpha;
|
|
231
231
|
}
|
|
232
|
-
// Integrate — update positions, apply damping, clamp velocity
|
|
232
|
+
// Integrate — update positions, apply damping, clamp velocity.
|
|
233
|
+
// Pinned nodes keep their x/y and have velocity zeroed so they
|
|
234
|
+
// don't drift when released later with pending momentum.
|
|
233
235
|
for (const node of nodes) {
|
|
236
|
+
if (node.pinned) {
|
|
237
|
+
node.vx = 0;
|
|
238
|
+
node.vy = 0;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
234
241
|
node.vx *= DAMPING;
|
|
235
242
|
node.vy *= DAMPING;
|
|
236
243
|
const speed = Math.sqrt(node.vx * node.vx + node.vy * node.vy);
|