feat(debug): improve node collision handling and enhance unmatched-root card behavior
This commit is contained in:
@@ -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";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user