Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 41 additions & 6 deletions engraphis/dashboard_assets/engraphis-graph.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@
const MAX_AUTO_FIT_ZOOM = 4;
const SETTINGS_ALPHA_TARGET = 0.12;
const ALPHA_TARGET_HOLD_MS = 180;
const DRAG_ALPHA_TARGET = 0.18;
const DRAG_ALPHA_TARGET = 0.04;
const DRAG_SETTLE_DELAY_MS = 80;

/* Physics is allowed to respond live, but one bad force update must never turn a
Expand Down Expand Up @@ -933,6 +933,7 @@
const api = {};

let activeDragNode = null, activeDragLinks = [], dragFollowForce = null;
let dragIsolated = false, dragCenterForce = null;

function setActiveDragNode(node) {
activeDragNode = node || null;
Expand All @@ -945,6 +946,28 @@
const source = linkEndpoint(link, 'source'), target = linkEndpoint(link, 'target');
return source === activeId || target === activeId;
});
isolateDragPhysics();
}

/* A pinned node is a local interaction. Keeping charge, centering, radial, and collision
forces active while that pin jumps makes every unrelated node respond to the pointer.
Leave only link attraction, the bounded one-hop follow force, and the final velocity
guard active until pointer-up. Settings renders can reinstall the normal forces; the
final isolation pass in applyForces() removes them again while the gesture is live. */
function isolateDragPhysics() {
if (!dragIsolated) {
dragIsolated = true;
dragCenterForce = fg.d3Force('center');
}
['charge', 'x', 'y', 'radial', 'collide', 'center'].forEach(name => fg.d3Force(name, null));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Re-register the velocity guard after restoring drag forces

Removing these forces deletes their entries from D3's insertion-ordered force map; restoreDragPhysics() then calls applyForces(), which recreates charge, x, y, and collide after the existing velocityGuard. Consequently the guard runs before those restored forces and cannot cap the velocity they add during the release reheat, so every completed drag can again produce the high-speed graph-wide movement this change is intended to prevent. Reinstall the guard after all normal forces have been restored.

Useful? React with 👍 / 👎.

}

function restoreDragPhysics() {
if (!dragIsolated) return;
dragIsolated = false;
if (dragCenterForce) fg.d3Force('center', dragCenterForce);
dragCenterForce = null;
applyForces();
}

function makeDragFollowForce() {
Expand Down Expand Up @@ -1310,6 +1333,7 @@
per node on every tick, and a large store pays that on the initial layout and on every
reheat, which is exactly where it is least affordable. */
if (d3.forceCollide) fg.d3Force('collide', d3.forceCollide(n => n.radius + 1.5).iterations(large ? 1 : 2));
if (dragIsolated) isolateDragPhysics();
}

function clearPinnedPositions(data) {
Expand Down Expand Up @@ -1590,8 +1614,10 @@
}
function setDragSimulationBudget(active) {
if (active && !staticFullLayout && !state.settings.frozen) {
if (fg.cooldownTime) fg.cooldownTime(Infinity);
if (fg.cooldownTicks) fg.cooldownTicks(Infinity);
/* Never use an unbounded drag budget. The scoped forces above keep the gesture
responsive without allowing a long pointer hold to become an infinite reheat. */
if (fg.cooldownTime) fg.cooldownTime(5000);
if (fg.cooldownTicks) fg.cooldownTicks(260);
if (fg.warmupTicks) fg.warmupTicks(0);
if (fg.d3AlphaDecay) fg.d3AlphaDecay(0.08);
return;
Expand Down Expand Up @@ -1785,6 +1811,15 @@
if (!dragging || supportsSoftAlpha()) softReheat(dragging);
}

function beginNodeDrag(node) {
if (destroyed || state.settings.frozen || staticFullLayout) return;
setActiveDragNode(node);
setDragSimulationBudget(true);
prepareReheat();
if (fg.d3AlphaDecay) fg.d3AlphaDecay(0.08);
if (supportsSoftAlpha()) softReheat(true);
}

function finishNodeDrag(node) {
if (!node) return;
const retainAnchor = state.settings.frozen || staticFullLayout;
Expand All @@ -1793,6 +1828,7 @@
node.fy = undefined;
}
setActiveDragNode(null);
restoreDragPhysics();
setDragSimulationBudget(false);
node.vx = 0;
node.vy = 0;
Expand Down Expand Up @@ -1867,8 +1903,7 @@
remains the primary controller, but register vendor callbacks when available. */
if (typeof fg.onNodeDragStart === 'function') {
fg.onNodeDragStart(node => {
setActiveDragNode(node);
reheatLiveLayout(true);
beginNodeDrag(node);
});
}
if (typeof fg.onNodeDragEnd === 'function') {
Expand Down Expand Up @@ -1922,7 +1957,7 @@
manualDrag.dragged = true;
started = true;
}
if (started) setActiveDragNode(manualDrag.node);
if (started) beginNodeDrag(manualDrag.node);
const node = manualDrag.node;
node.x = node.fx = point.x + manualDrag.offsetX;
node.y = node.fy = point.y + manualDrag.offsetY;
Expand Down
12 changes: 9 additions & 3 deletions tests/test_graph_engine_asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -1388,7 +1388,7 @@ def test_drag_release_contract_keeps_physics_live() -> None:


def test_drag_follow_force_is_capped_and_neighbor_scoped() -> None:
"""The connected-node spring is bounded and uses an explicit drag-only budget."""
"""The connected-node spring is bounded and drag cannot restart global forces forever."""
source = ASSET.read_text(encoding="utf-8")
assert "function makeDragFollowForce()" in source
assert "activeDragLinks.forEach(link =>" in source
Expand All @@ -1397,8 +1397,14 @@ def test_drag_follow_force_is_capped_and_neighbor_scoped() -> None:
assert "const MAX_DRAG_PULL = 18;" in source
assert "fg.d3Force('dragFollow', dragFollowForce);" in source
assert "function setDragSimulationBudget(active)" in source
assert "fg.cooldownTime(Infinity)" in source
assert "fg.cooldownTicks(Infinity)" in source
assert "function isolateDragPhysics()" in source
assert "function restoreDragPhysics()" in source
assert "['charge', 'x', 'y', 'radial', 'collide', 'center']" in source
assert "if (dragIsolated) isolateDragPhysics();" in source
assert "fg.cooldownTime(5000)" in source
assert "fg.cooldownTicks(260)" in source
assert "fg.cooldownTime(Infinity)" not in source
assert "fg.cooldownTicks(Infinity)" not in source
assert source.index("fg.d3Force('dragFollow', dragFollowForce);") < source.index("fg.d3Force('velocityGuard', velocityGuardForce);")


Expand Down