feat(debug): improve node collision handling and enhance unmatched-root card behavior

This commit is contained in:
mynameisdeleted
2026-07-09 15:53:14 -04:00
parent 7fbdb51be7
commit 67e9a0d488

View File

@@ -124,62 +124,6 @@ module.exports = function (register, lib) {
return positions;
}
// ---- hard rectangle-overlap resolution ----
// Repulsion/gravity alone only balance to *some* equilibrium distance;
// nothing about that equilibrium accounts for box size, and node boxes
// vary a lot in height (one row vs. MAX_VISIBLE_ROWS). So two boxes can
// settle closer than their combined half-heights and visibly overlap.
// This runs every simulation step and directly separates any pair of
// visible boxes whose padded bounding rects intersect, along whichever
// axis needs the smaller push -- a standard AABB de-overlap pass on top
// of the physics, not a replacement for it. Returns the total remaining
// overlap so callers can tell the simulation hasn't truly settled even
// if velocities alone look calm.
function resolveCollisions(nodeList, isVisible) {
var totalOverlap = 0;
for (var i = 0; i < nodeList.length; i++) {
for (var j = i + 1; j < nodeList.length; j++) {
var a = nodeList[i];
var b = nodeList[j];
if (!isVisible(a.id) || !isVisible(b.id)) continue;
if (a.fixed && b.fixed) continue;
var minDX = NODE_WIDTH + H_GAP;
var minDY = (a.height + b.height) / 2 + V_GAP;
var dx = b.x - a.x;
var dy = b.y - a.y;
var overlapX = minDX - Math.abs(dx);
var overlapY = minDY - Math.abs(dy);
if (overlapX <= 0 || overlapY <= 0) continue;
totalOverlap += Math.min(overlapX, overlapY);
var pushX = 0, pushY = 0;
if (dx === 0 && dy === 0) {
pushX = minDX; // exactly coincident: nudge apart deterministically
} else if (overlapX < overlapY) {
pushX = (dx < 0 ? -1 : 1) * overlapX;
} else {
pushY = (dy < 0 ? -1 : 1) * overlapY;
}
if (a.fixed) {
b.x += pushX;
b.y += pushY;
} else if (b.fixed) {
a.x -= pushX;
a.y -= pushY;
} else {
a.x -= pushX / 2;
a.y -= pushY / 2;
b.x += pushX / 2;
b.y += pushY / 2;
}
}
}
return totalOverlap;
}
// ---- simple O(n^2) force-directed layout, vis-network-style ----
// isVisible(id) lets collapsed-subtree nodes sit out of the simulation
// entirely (frozen, not just hidden) so they don't invisibly shove
@@ -188,6 +132,34 @@ module.exports = function (register, lib) {
var REPULSION = 55000;
var SPRING_LENGTH = NODE_WIDTH + H_GAP;
var SPRING_STIFFNESS = 0.02;
// A pointer's target should sit near it whenever the rest of the
// graph allows -- but plain repulsion is global (every node pushes
// every other node), so in a busy graph the combined push from
// everything *else* can easily outweigh the one spring pulling a
// connected pair together, leaving "next" sitting far from the node
// it points at. Cutting repulsion between directly-linked pairs lets
// their spring actually win that tug-of-war, while collision
// avoidance below still keeps them from touching.
var LINKED_REPULSION_SCALE = 0.3;
// Boxes vary a lot in height (one row vs. MAX_VISIBLE_ROWS), so the
// plain 1/dist^2 repulsion above -- tuned for the common case --
// doesn't reliably keep a short box and a tall box apart. This adds
// a second force, zero until two boxes' padded rects actually
// intersect and growing with how deep the overlap is, along
// whichever axis has the smaller overlap. Modeling it as a force
// (added into the same fx/fy accumulator as repulsion/spring, then
// run through the normal damped integrator below) instead of a
// direct position snap is what makes it converge instead of
// fighting the spring/gravity every frame: an earlier version that
// hard-set positions (and zeroed velocity) after integration could
// get stuck in a permanent tug-of-war with gravity pulling chain
// ends back in, never settling. COLLISION_PAD is deliberately much
// smaller than H_GAP/V_GAP -- it's a last-resort "don't visually
// touch" floor, not the comfortable resting distance (that's still
// SPRING_LENGTH); asking it for a full H_GAP-sized gap made it
// fight the spring hard enough to be the same instability.
var COLLISION_PAD = 16;
var COLLISION_STIFFNESS = 1;
var DAMPING = 0.82;
var GRAVITY = 0.01;
@@ -196,6 +168,12 @@ module.exports = function (register, lib) {
byId[n.id] = n;
});
var linked = {};
edges.forEach(function (e) {
var key = e.from < e.to ? e.from + "|" + e.to : e.to + "|" + e.from;
linked[key] = true;
});
function step() {
var fx = {};
var fy = {};
@@ -213,13 +191,33 @@ module.exports = function (register, lib) {
var dy = a.y - b.y;
var distSq = dx * dx + dy * dy || 0.01;
var dist = Math.sqrt(distSq);
var force = REPULSION / distSq;
var key = a.id < b.id ? a.id + "|" + b.id : b.id + "|" + a.id;
var scale = linked[key] ? LINKED_REPULSION_SCALE : 1;
var force = (REPULSION * scale) / distSq;
var ux = dx / dist;
var uy = dy / dist;
fx[a.id] += ux * force;
fy[a.id] += uy * force;
fx[b.id] -= ux * force;
fy[b.id] -= uy * force;
var minDX = NODE_WIDTH + COLLISION_PAD;
var minDY = (a.height + b.height) / 2 + COLLISION_PAD;
var overlapX = minDX - Math.abs(dx);
var overlapY = minDY - Math.abs(dy);
if (overlapX > 0 && overlapY > 0) {
if (overlapX < overlapY) {
var cf = overlapX * COLLISION_STIFFNESS;
var csign = dx < 0 ? -1 : 1;
fx[a.id] += csign * cf;
fx[b.id] -= csign * cf;
} else {
var cf2 = overlapY * COLLISION_STIFFNESS;
var csign2 = dy < 0 ? -1 : 1;
fy[a.id] += csign2 * cf2;
fy[b.id] -= csign2 * cf2;
}
}
}
}
@@ -257,16 +255,28 @@ module.exports = function (register, lib) {
kinetic += n.vx * n.vx + n.vy * n.vy;
});
// Folded into the same "settled" signal as velocity: as long as
// boxes still overlap, the simulation isn't done, even if forces
// alone have gone quiet.
var overlap = resolveCollisions(nodeList, isVisible);
return kinetic + overlap;
return kinetic;
}
return { nodes: nodeList, byId: byId, step: step };
}
// Where a line from (fromX,fromY) toward box-center (cx,cy) crosses that
// box's boundary -- used to end an edge at the target's edge instead of
// its center. Ending at the center buries the last stretch of the line
// (and its arrowhead marker) under the box's own opaque background,
// since boxes are appended to the DOM after the edge SVG and so paint
// over it wherever they'd overlap.
function boxEdgePoint(cx, cy, halfW, halfH, fromX, fromY) {
var dx = fromX - cx;
var dy = fromY - cy;
if (dx === 0 && dy === 0) return { x: cx, y: cy };
var tx = dx !== 0 ? halfW / Math.abs(dx) : Infinity;
var ty = dy !== 0 ? halfH / Math.abs(dy) : Infinity;
var t = Math.min(tx, ty);
return { x: cx + dx * t, y: cy + dy * t };
}
// ---- collapse/expand: purely client-side, over the already-fetched graph ----
function subtreeSize(startChildren, childrenOf) {
var seen = {};
@@ -595,7 +605,16 @@ module.exports = function (register, lib) {
var body = el("div", { maxHeight: MAX_VISIBLE_ROWS * ROW_HEIGHT + "px", overflowY: "auto" });
var table = el("table", { borderCollapse: "collapse", width: "100%" });
n.fields.forEach(function (f) {
// Plain values first, pointers below -- so the data you'd read at
// a glance isn't pushed below the fold by hex addresses, and so
// a box with many fields still shows its scalars without
// scrolling past pointers to reach them. Stable sort keeps each
// group in its original (declaration) order.
var orderedFields = n.fields.slice().sort(function (a, b) {
return (a.isPointer ? 1 : 0) - (b.isPointer ? 1 : 0);
});
orderedFields.forEach(function (f) {
var row = el("tr");
if (f.typeHint) row.title = f.name + ": " + f.typeHint + " = " + f.value;
var nameCell = el("td", {
@@ -637,36 +656,101 @@ module.exports = function (register, lib) {
var rootBadges = roots.map(function (r) {
var matched = !!byId[r.value];
var badge = el("div", {
position: "absolute",
width: NODE_WIDTH / 2 + "px",
height: ROOT_BADGE_HEIGHT + "px",
lineHeight: ROOT_BADGE_HEIGHT + "px",
textAlign: "center",
borderRadius: "11px",
border: "1px solid " + colors.border,
background: matched ? colors.headerBg : "transparent",
color: matched ? colors.headerFg : colors.pointerValue,
fontFamily: "var(--vscode-editor-font-family, monospace)",
fontSize: "12px",
fontWeight: "bold",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
});
badge.title = r.name + (r.type ? " : " + r.type : "") + " = " + r.value;
badge.textContent = r.name + (matched ? "" : " (" + r.value + ")");
world.appendChild(badge);
var line = null;
if (matched) {
line = document.createElementNS(svgNs, "path");
var badge = el("div", {
position: "absolute",
width: NODE_WIDTH / 2 + "px",
height: ROOT_BADGE_HEIGHT + "px",
lineHeight: ROOT_BADGE_HEIGHT + "px",
textAlign: "center",
borderRadius: "11px",
border: "1px solid " + colors.border,
background: colors.headerBg,
color: colors.headerFg,
fontFamily: "var(--vscode-editor-font-family, monospace)",
fontSize: "12px",
fontWeight: "bold",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
});
badge.title = r.name + (r.type ? " : " + r.type : "") + " = " + r.value;
badge.textContent = r.name;
world.appendChild(badge);
var line = document.createElementNS(svgNs, "path");
line.setAttribute("fill", "none");
line.setAttribute("stroke", colors.pointerValue);
line.setAttribute("stroke-width", "1.5");
line.setAttribute("marker-end", "url(#dv-arrow-root)");
svg.appendChild(line);
return { root: r, kind: rootKind(r), badge: badge, line: line, matched: true, x: 0 };
}
return { root: r, kind: rootKind(r), badge: badge, line: line, matched: matched, x: 0 };
// Not a pointer into the graph (a plain scalar local like `count`,
// or a null/unresolved pointer) -- show it as its own small card,
// name on top then type then value, the same shape as a graph
// node's field table, instead of collapsing it into one line of
// text. This is what makes non-pointer locals show up as boxes
// the way pointer locals already do via their target node.
var rows = [];
if (r.type) rows.push({ name: "type", value: r.type });
rows.push({ name: "value", value: r.value });
var height = HEADER_HEIGHT + rows.length * ROW_HEIGHT;
var box = el("div", {
position: "absolute",
width: NODE_WIDTH + "px",
border: "1px solid " + colors.border,
borderRadius: "6px",
background: colors.bodyBg,
boxShadow: "0 1px 3px rgba(0,0,0,0.3)",
fontFamily: "var(--vscode-editor-font-family, monospace)",
fontSize: "12px",
overflow: "hidden",
cursor: "grab",
});
var header = el("div", {
padding: "4px 8px",
background: colors.headerBg,
color: colors.headerFg,
fontWeight: "bold",
borderBottom: "1px solid " + colors.border,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
});
header.textContent = r.name;
box.appendChild(header);
var table = el("table", { borderCollapse: "collapse", width: "100%" });
rows.forEach(function (row) {
var tr = el("tr");
var nameCell = el("td", {
padding: "2px 8px",
color: colors.fieldName,
whiteSpace: "nowrap",
height: ROW_HEIGHT - 4 + "px",
});
nameCell.textContent = row.name;
var valueCell = el("td", {
padding: "2px 8px",
color: colors.fieldValue,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: "0",
width: "100%",
});
valueCell.textContent = row.value;
tr.appendChild(nameCell);
tr.appendChild(valueCell);
table.appendChild(tr);
});
box.appendChild(table);
box.title = r.name + (r.type ? " : " + r.type : "") + " = " + r.value;
world.appendChild(box);
return { root: r, kind: rootKind(r), badge: box, line: null, matched: false, height: height, x: 0, y: 0, pinned: false };
});
var nextUnmatchedX = 0;
var badgesPerTarget = {};
@@ -763,6 +847,17 @@ module.exports = function (register, lib) {
minY = Math.min(minY, n.y - n.height / 2 - (roots.length ? ROOT_BADGE_HEIGHT + ROOT_GAP : 0));
maxY = Math.max(maxY, n.y + n.height / 2);
});
// Unmatched-root cards (scalar locals/globals/watches) sit in
// their own shelf above the node cloud, positioned by redraw()
// -- not part of nodeList, so fold their bounds in explicitly or
// "Fit" can crop them out of view entirely.
rootBadges.forEach(function (rb) {
if (rb.matched || !rootKindEnabled[rb.kind]) return;
minX = Math.min(minX, rb.x);
maxX = Math.max(maxX, rb.x + NODE_WIDTH);
minY = Math.min(minY, rb.y);
maxY = Math.max(maxY, rb.y + rb.height);
});
var pad = 30;
var w = Math.max(1, maxX - minX + pad * 2);
var h = Math.max(1, maxY - minY + pad * 2);
@@ -846,6 +941,23 @@ module.exports = function (register, lib) {
});
});
// ---- unmatched-root card drag (e.g. a plain scalar local like
// `count`) -- these aren't part of the physics simulation, they're
// auto-flowed into a shelf above the node cloud by redraw(); once
// dragged, `pinned` takes a card out of that auto-flow so it stays
// wherever the user put it instead of snapping back next frame.
var rootDrag = null;
rootBadges.forEach(function (rb) {
if (rb.matched) return;
rb.badge.addEventListener("mousedown", function (e) {
e.stopPropagation();
rb.pinned = true;
rootDrag = { rb: rb, startX: e.clientX, startY: e.clientY, rbX: rb.x, rbY: rb.y };
rb.badge.style.cursor = "grabbing";
e.preventDefault();
});
});
window.addEventListener("mousemove", function (e) {
if (panDrag) {
camera.x = panDrag.camX + (e.clientX - panDrag.startX);
@@ -855,6 +967,10 @@ module.exports = function (register, lib) {
nodeDrag.node.x = nodeDrag.nodeX + (e.clientX - nodeDrag.startX) / camera.zoom;
nodeDrag.node.y = nodeDrag.nodeY + (e.clientY - nodeDrag.startY) / camera.zoom;
redraw();
} else if (rootDrag) {
rootDrag.rb.x = rootDrag.rbX + (e.clientX - rootDrag.startX) / camera.zoom;
rootDrag.rb.y = rootDrag.rbY + (e.clientY - rootDrag.startY) / camera.zoom;
redraw();
}
});
window.addEventListener("mouseup", function () {
@@ -867,10 +983,15 @@ module.exports = function (register, lib) {
if (box) box.style.cursor = "grab";
nodeDrag = null;
}
if (rootDrag) {
rootDrag.rb.badge.style.cursor = "grab";
rootDrag = null;
}
});
// ---- per-frame redraw ----
function redraw() {
var visibleTopY = Infinity;
nodeList.forEach(function (n) {
var box = boxes[n.id];
var isVis = !!visible[n.id];
@@ -878,6 +999,7 @@ module.exports = function (register, lib) {
if (isVis) {
box.style.left = n.x - NODE_WIDTH / 2 + "px";
box.style.top = n.y - n.height / 2 + "px";
visibleTopY = Math.min(visibleTopY, n.y - n.height / 2);
}
var toggle = toggles[n.id];
if (toggle) {
@@ -898,7 +1020,9 @@ module.exports = function (register, lib) {
ep.line.style.display = show ? "" : "none";
if (ep.text) ep.text.style.display = show ? "" : "none";
if (!show) return;
var x1 = a.x, y1 = a.y, x2 = b.x, y2 = b.y;
var start = boxEdgePoint(a.x, a.y, NODE_WIDTH / 2, a.height / 2, b.x, b.y);
var end = boxEdgePoint(b.x, b.y, NODE_WIDTH / 2, b.height / 2, a.x, a.y);
var x1 = start.x, y1 = start.y, x2 = end.x, y2 = end.y;
var midX = (x1 + x2) / 2;
var midY = (y1 + y2) / 2;
ep.line.setAttribute(
@@ -942,10 +1066,24 @@ module.exports = function (register, lib) {
}
} else {
rb.badge.style.display = "";
rb.x = nextUnmatchedX;
nextUnmatchedX += NODE_WIDTH / 2 + 20;
if (!rb.pinned) {
rb.x = nextUnmatchedX;
// Sit just above whatever's currently the top of the
// visible node cloud, rather than a fixed world y --
// so this shelf of scalar-local cards tracks the
// graph instead of drifting into overlapping it once
// "auto" physics (or gravity re-centering) moves
// nodes around.
var shelfTop = (visibleTopY === Infinity ? 0 : visibleTopY) - ROOT_GAP;
rb.y = shelfTop - rb.height;
nextUnmatchedX += NODE_WIDTH + 20;
}
// A pinned (user-dragged) card keeps whatever rb.x/rb.y
// the drag left it at, and drops out of the auto-flow
// row entirely -- the remaining unpinned cards close the
// gap instead of leaving a hole where it used to sit.
rb.badge.style.left = rb.x + "px";
rb.badge.style.top = "0px";
rb.badge.style.top = rb.y + "px";
}
});
}