From 67e9a0d48835b68567dd16cd054ba1aa4e125131 Mon Sep 17 00:00:00 2001 From: mynameisdeleted Date: Thu, 9 Jul 2026 15:53:14 -0400 Subject: [PATCH] feat(debug): improve node collision handling and enhance unmatched-root card behavior --- vis-plugins/node-table-visualizer.js | 318 +++++++++++++++++++-------- 1 file changed, 228 insertions(+), 90 deletions(-) diff --git a/vis-plugins/node-table-visualizer.js b/vis-plugins/node-table-visualizer.js index d5b1ea6..3533165 100644 --- a/vis-plugins/node-table-visualizer.js +++ b/vis-plugins/node-table-visualizer.js @@ -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"; } }); }