feat(debug): enhance node table visualizer with force-directed layout and toolbar controls

This commit is contained in:
mynameisdeleted
2026-07-09 12:36:43 -04:00
parent a8d105005e
commit 7630ab9050

View File

@@ -3,18 +3,20 @@
// as a row of boxes -- one per struct/node -- where each box is a table of
// that node's member variables (address in the header, fields below,
// pointer values in hex), connected by arrows for pointer fields.
// Nodes are draggable, the canvas is pannable/zoomable, and a toolbar lets
// you switch between a force-directed ("Auto", vis-network-style) layout
// and the original left-to-right layered chain layout, or fit everything
// back into view.
//
// Registered via the "debugVisualizer.customVisualizerScriptPaths" setting;
// the extension hot-reloads this file on save, no debug session restart needed.
//
// Data shape produced by cpp/main.cpp's LinkedList<T>::toDebugGraph():
// Data shape produced by gdb/debug_graph.py's $debug_graph(...):
// nodes: [{ id: "0x...", fields: [{ name, value, isPointer? }, ...] }]
// edges: [{ from: "0x...", to: "0x...", label? }]
// roots: [{ name: "top", value: "0x..." }] -- external pointer variables
// (e.g. a local `top`/`end` in the caller's stack frame); drawn as a
// small pill above the graph with an arrow to whichever node's id
// matches its value, since a node's own address equals its first
// member's address.
// small pill that tracks above whichever node's id matches its value.
module.exports = function (register, lib) {
var sj = lib.semanticJson;
@@ -54,17 +56,16 @@ module.exports = function (register, lib) {
var NODE_WIDTH = 220;
var H_GAP = 70;
var V_GAP = 24;
var PADDING = 20;
var ROOT_ROW_HEIGHT = 50;
var ROOT_BADGE_HEIGHT = 22;
var ROOT_GAP = 30;
var MIN_ZOOM = 0.15;
var MAX_ZOOM = 3;
function layout(nodes, edges) {
var ids = nodes.map(function (n) {
return n.id;
});
// ---- seed layout: left-to-right BFS levels, also used as "Layered" mode ----
function layeredPositions(nodeIds, edges, boxHeights) {
var adjacency = {};
var incoming = {};
ids.forEach(function (id) {
nodeIds.forEach(function (id) {
adjacency[id] = [];
incoming[id] = 0;
});
@@ -75,15 +76,15 @@ module.exports = function (register, lib) {
var level = {};
var queue = [];
ids.forEach(function (id) {
nodeIds.forEach(function (id) {
if (incoming[id] === 0) {
level[id] = 0;
queue.push(id);
}
});
if (queue.length === 0 && ids.length > 0) {
level[ids[0]] = 0;
queue.push(ids[0]);
if (queue.length === 0 && nodeIds.length > 0) {
level[nodeIds[0]] = 0;
queue.push(nodeIds[0]);
}
var head = 0;
@@ -102,7 +103,7 @@ module.exports = function (register, lib) {
var colCount = {};
var positions = {};
ids.forEach(function (id) {
nodeIds.forEach(function (id) {
var lvl = level[id];
if (lvl === undefined) {
maxLevel++;
@@ -110,11 +111,90 @@ module.exports = function (register, lib) {
}
var col = colCount[lvl] || 0;
colCount[lvl] = col + 1;
positions[id] = { level: lvl, col: col };
var x = lvl * (NODE_WIDTH + H_GAP) + NODE_WIDTH / 2;
var y = col * (Math.max(boxHeights[id], HEADER_HEIGHT + ROW_HEIGHT) + V_GAP) + boxHeights[id] / 2;
positions[id] = { x: x, y: y };
});
return positions;
}
// ---- simple O(n^2) force-directed layout, vis-network-style ----
function createSimulation(nodeList, edges) {
var REPULSION = 55000;
var SPRING_LENGTH = NODE_WIDTH + H_GAP;
var SPRING_STIFFNESS = 0.02;
var DAMPING = 0.82;
var GRAVITY = 0.01;
var byId = {};
nodeList.forEach(function (n) {
byId[n.id] = n;
});
function step() {
var fx = {};
var fy = {};
nodeList.forEach(function (n) {
fx[n.id] = 0;
fy[n.id] = 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];
var dx = a.x - b.x;
var dy = a.y - b.y;
var distSq = dx * dx + dy * dy || 0.01;
var dist = Math.sqrt(distSq);
var force = REPULSION / 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;
}
}
edges.forEach(function (e) {
var a = byId[e.from];
var b = byId[e.to];
if (!a || !b) return;
var dx = b.x - a.x;
var dy = b.y - a.y;
var dist = Math.sqrt(dx * dx + dy * dy) || 0.01;
var diff = dist - SPRING_LENGTH;
var force = diff * SPRING_STIFFNESS;
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 kinetic = 0;
nodeList.forEach(function (n) {
if (n.fixed) {
n.vx = 0;
n.vy = 0;
return;
}
fx[n.id] -= n.x * GRAVITY;
fy[n.id] -= n.y * GRAVITY;
n.vx = (n.vx + fx[n.id]) * DAMPING;
n.vy = (n.vy + fy[n.id]) * DAMPING;
n.x += n.vx;
n.y += n.vy;
kinetic += n.vx * n.vx + n.vy * n.vy;
});
return kinetic;
}
return { nodes: nodeList, byId: byId, step: step };
}
function el(tag, style, attrs) {
var e = document.createElement(tag);
if (style) Object.assign(e.style, style);
@@ -124,15 +204,32 @@ module.exports = function (register, lib) {
return e;
}
function button(label, colors) {
var b = el(
"button",
{
font: "12px var(--vscode-editor-font-family, monospace)",
padding: "3px 8px",
borderRadius: "4px",
border: "1px solid " + colors.border,
background: colors.headerBg,
color: colors.headerFg,
cursor: "pointer",
},
{ type: "button" }
);
b.textContent = label;
return b;
}
function render(data, target, theme) {
target.innerHTML = "";
Object.assign(target.style, {
position: "relative",
overflow: "auto",
overflow: "hidden",
width: "100%",
height: "100%",
boxSizing: "border-box",
padding: PADDING + "px",
});
var dark = theme === "dark";
@@ -146,6 +243,7 @@ module.exports = function (register, lib) {
fieldValue: "#d4d4d4",
pointerValue: "#ce9178",
edge: "#8a8a8a",
viewportBg: "#1e1e1e",
}
: {
border: "#b8b8b8",
@@ -156,16 +254,10 @@ module.exports = function (register, lib) {
fieldValue: "#1a1a1a",
pointerValue: "#a31515",
edge: "#6b6b6b",
viewportBg: "#fafafa",
};
var positions = layout(data.nodes, data.edges);
var roots = data.roots || [];
var topOffset = roots.length > 0 ? ROOT_ROW_HEIGHT : 0;
var byId = {};
data.nodes.forEach(function (n) {
byId[n.id] = n;
});
var boxHeights = {};
data.nodes.forEach(function (n) {
@@ -173,57 +265,46 @@ module.exports = function (register, lib) {
boxHeights[n.id] = HEADER_HEIGHT + Math.max(visibleRows, 1) * ROW_HEIGHT;
});
var pixelPos = {};
var maxX = 0;
var maxY = 0;
Object.keys(positions).forEach(function (id) {
var p = positions[id];
var x = p.level * (NODE_WIDTH + H_GAP);
var y = topOffset + p.col * (Math.max(boxHeights[id], HEADER_HEIGHT + ROW_HEIGHT) + V_GAP);
pixelPos[id] = { x: x, y: y };
maxX = Math.max(maxX, x + NODE_WIDTH);
maxY = Math.max(maxY, y + boxHeights[id]);
var nodeIds = data.nodes.map(function (n) {
return n.id;
});
var seed = layeredPositions(nodeIds, data.edges, boxHeights);
var nodeList = data.nodes.map(function (n) {
var p = seed[n.id];
return { id: n.id, x: p.x, y: p.y, vx: 0, vy: 0, fixed: false, height: boxHeights[n.id] };
});
var byId = {};
nodeList.forEach(function (n) {
byId[n.id] = n;
});
// Spread root badges above the nodes they point to; unmatched roots
// (e.g. a pointer into the middle of a struct, or a stale/null value)
// queue up along the top-left instead.
var rootPos = {};
var nextUnmatchedX = 0;
var badgesPerTarget = {};
roots.forEach(function (r) {
var target = pixelPos[r.value];
var x;
if (target) {
var stackIndex = badgesPerTarget[r.value] || 0;
badgesPerTarget[r.value] = stackIndex + 1;
x = target.x + stackIndex * 90;
} else {
x = nextUnmatchedX;
nextUnmatchedX += NODE_WIDTH / 2 + 20;
}
rootPos[r.name] = { x: x, y: 0, matched: !!target };
maxX = Math.max(maxX, x + NODE_WIDTH);
var sim = createSimulation(nodeList, data.edges);
var layoutMode = "auto"; // "auto" (physics) | "layered" (static chain)
var running = true;
// ---- DOM: viewport (pan/zoom window) + world (transformed content) ----
var viewport = el("div", {
position: "absolute",
left: "0",
top: "0",
width: "100%",
height: "100%",
overflow: "hidden",
background: colors.viewportBg,
cursor: "grab",
});
var canvas = el("div", {
position: "relative",
width: maxX + "px",
height: maxY + "px",
var world = el("div", {
position: "absolute",
left: "0",
top: "0",
transformOrigin: "0 0",
});
var svgNs = "http://www.w3.org/2000/svg";
var svg = document.createElementNS(svgNs, "svg");
svg.setAttribute("width", String(maxX));
svg.setAttribute("height", String(maxY));
Object.assign(svg.style, {
position: "absolute",
left: "0",
top: "0",
overflow: "visible",
pointerEvents: "none",
});
Object.assign(svg.style, { position: "absolute", left: "0", top: "0", overflow: "visible", pointerEvents: "none" });
var defs = document.createElementNS(svgNs, "defs");
var marker = document.createElementNS(svgNs, "marker");
marker.setAttribute("id", "dv-arrow");
marker.setAttribute("markerWidth", "8");
@@ -235,35 +316,20 @@ module.exports = function (register, lib) {
arrowPath.setAttribute("d", "M0,0 L8,4 L0,8 Z");
arrowPath.setAttribute("fill", colors.edge);
marker.appendChild(arrowPath);
var defs = document.createElementNS(svgNs, "defs");
defs.appendChild(marker);
svg.appendChild(defs);
world.appendChild(svg);
data.edges.forEach(function (e) {
var from = pixelPos[e.from];
var to = pixelPos[e.to];
if (!from || !to) return;
var x1 = from.x + NODE_WIDTH;
var y1 = from.y + boxHeights[e.from] / 2;
var x2 = to.x;
var y2 = to.y + boxHeights[e.to] / 2;
var edgePaths = data.edges.map(function (e) {
var line = document.createElementNS(svgNs, "path");
var midX = (x1 + x2) / 2;
line.setAttribute(
"d",
"M" + x1 + "," + y1 + " C " + midX + "," + y1 + " " + midX + "," + y2 + " " + x2 + "," + y2
);
line.setAttribute("fill", "none");
line.setAttribute("stroke", colors.edge);
line.setAttribute("stroke-width", "1.5");
line.setAttribute("marker-end", "url(#dv-arrow)");
svg.appendChild(line);
var text = null;
if (e.label) {
var text = document.createElementNS(svgNs, "text");
text.setAttribute("x", String(midX));
text.setAttribute("y", String((y1 + y2) / 2 - 4));
text = document.createElementNS(svgNs, "text");
text.setAttribute("fill", colors.edge);
text.setAttribute("font-size", "11");
text.setAttribute("font-family", "monospace");
@@ -271,16 +337,13 @@ module.exports = function (register, lib) {
text.textContent = e.label;
svg.appendChild(text);
}
return { edge: e, line: line, text: text };
});
canvas.appendChild(svg);
var boxes = {};
data.nodes.forEach(function (n) {
var pos = pixelPos[n.id];
var box = el("div", {
position: "absolute",
left: pos.x + "px",
top: pos.y + "px",
width: NODE_WIDTH + "px",
border: "1px solid " + colors.border,
borderRadius: "6px",
@@ -289,6 +352,7 @@ module.exports = function (register, lib) {
fontFamily: "var(--vscode-editor-font-family, monospace)",
fontSize: "12px",
overflow: "hidden",
cursor: "grab",
});
var header = el("div", {
@@ -304,15 +368,8 @@ module.exports = function (register, lib) {
header.textContent = n.id;
box.appendChild(header);
var body = el("div", {
maxHeight: MAX_VISIBLE_ROWS * ROW_HEIGHT + "px",
overflowY: "auto",
});
var table = el("table", {
borderCollapse: "collapse",
width: "100%",
});
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) {
var row = el("tr");
@@ -340,39 +397,22 @@ module.exports = function (register, lib) {
body.appendChild(table);
box.appendChild(body);
canvas.appendChild(box);
world.appendChild(box);
boxes[n.id] = box;
});
roots.forEach(function (r) {
var pos = rootPos[r.name];
if (pos.matched) {
var targetPos = pixelPos[r.value];
var x1 = pos.x + NODE_WIDTH / 2;
var y1 = ROOT_BADGE_HEIGHT;
var x2 = targetPos.x + NODE_WIDTH / 2;
var y2 = targetPos.y;
var line = document.createElementNS(svgNs, "path");
line.setAttribute("d", "M" + x1 + "," + y1 + " L " + x2 + "," + y2);
line.setAttribute("fill", "none");
line.setAttribute("stroke", colors.edge);
line.setAttribute("stroke-width", "1.5");
line.setAttribute("stroke-dasharray", "3,3");
line.setAttribute("marker-end", "url(#dv-arrow)");
svg.appendChild(line);
}
var rootBadges = roots.map(function (r) {
var matched = !!byId[r.value];
var badge = el("div", {
position: "absolute",
left: pos.x + "px",
top: "0px",
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: pos.matched ? colors.headerBg : "transparent",
color: pos.matched ? colors.headerFg : colors.pointerValue,
background: matched ? colors.headerBg : "transparent",
color: matched ? colors.headerFg : colors.pointerValue,
fontFamily: "var(--vscode-editor-font-family, monospace)",
fontSize: "12px",
fontWeight: "bold",
@@ -381,11 +421,268 @@ module.exports = function (register, lib) {
textOverflow: "ellipsis",
});
badge.title = r.name + " = " + r.value;
badge.textContent = r.name + (pos.matched ? "" : " (" + r.value + ")");
canvas.appendChild(badge);
badge.textContent = r.name + (matched ? "" : " (" + r.value + ")");
world.appendChild(badge);
var line = null;
if (matched) {
line = document.createElementNS(svgNs, "path");
line.setAttribute("fill", "none");
line.setAttribute("stroke", colors.edge);
line.setAttribute("stroke-width", "1.5");
line.setAttribute("stroke-dasharray", "3,3");
line.setAttribute("marker-end", "url(#dv-arrow)");
svg.appendChild(line);
}
return { root: r, badge: badge, line: line, matched: matched, x: 0 };
});
var nextUnmatchedX = 0;
var badgesPerTarget = {};
viewport.appendChild(world);
target.appendChild(viewport);
// ---- toolbar ----
var toolbar = el("div", {
position: "absolute",
right: "8px",
top: "8px",
display: "flex",
gap: "6px",
zIndex: "10",
});
var autoBtn = button("Auto-arrange", colors);
var layeredBtn = button("Layered", colors);
var fitBtn = button("Fit", colors);
var zoomOutBtn = button("−", colors);
var zoomInBtn = button("+", colors);
[autoBtn, layeredBtn, fitBtn, zoomOutBtn, zoomInBtn].forEach(function (b) {
toolbar.appendChild(b);
});
target.appendChild(toolbar);
function highlightModeButtons() {
autoBtn.style.background = layoutMode === "auto" ? colors.pointerValue : colors.headerBg;
layeredBtn.style.background = layoutMode === "layered" ? colors.pointerValue : colors.headerBg;
}
highlightModeButtons();
// ---- camera (pan/zoom) ----
var camera = { x: target.clientWidth / 2 - NODE_WIDTH / 2, y: 40, zoom: 1 };
function applyCamera() {
world.style.transform = "translate(" + camera.x + "px, " + camera.y + "px) scale(" + camera.zoom + ")";
}
function zoomBy(factor, centerX, centerY) {
var newZoom = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, camera.zoom * factor));
camera.x = centerX - ((centerX - camera.x) * newZoom) / camera.zoom;
camera.y = centerY - ((centerY - camera.y) * newZoom) / camera.zoom;
camera.zoom = newZoom;
applyCamera();
}
function fitToView() {
if (nodeList.length === 0) return;
var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
nodeList.forEach(function (n) {
minX = Math.min(minX, n.x - NODE_WIDTH / 2);
maxX = Math.max(maxX, n.x + NODE_WIDTH / 2);
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);
});
var pad = 30;
var w = Math.max(1, maxX - minX + pad * 2);
var h = Math.max(1, maxY - minY + pad * 2);
var vw = target.clientWidth || 800;
var vh = target.clientHeight || 600;
var zoom = Math.min(MAX_ZOOM, Math.min(vw / w, vh / h));
camera.zoom = zoom;
camera.x = vw / 2 - ((minX + maxX) / 2) * zoom;
camera.y = vh / 2 - ((minY + maxY) / 2) * zoom;
applyCamera();
}
viewport.addEventListener(
"wheel",
function (e) {
e.preventDefault();
var rect = viewport.getBoundingClientRect();
var factor = Math.pow(1.0015, -e.deltaY);
zoomBy(factor, e.clientX - rect.left, e.clientY - rect.top);
},
{ passive: false }
);
zoomInBtn.addEventListener("click", function () {
zoomBy(1.2, target.clientWidth / 2, target.clientHeight / 2);
});
zoomOutBtn.addEventListener("click", function () {
zoomBy(1 / 1.2, target.clientWidth / 2, target.clientHeight / 2);
});
fitBtn.addEventListener("click", fitToView);
autoBtn.addEventListener("click", function () {
layoutMode = "auto";
highlightModeButtons();
nodeList.forEach(function (n) {
if (!n.userPinned) {
n.fixed = false;
n.vx += (Math.random() - 0.5) * 2;
n.vy += (Math.random() - 0.5) * 2;
}
});
stabilize(500);
redraw();
fitToView();
});
layeredBtn.addEventListener("click", function () {
layoutMode = "layered";
highlightModeButtons();
var pos = layeredPositions(nodeIds, data.edges, boxHeights);
nodeList.forEach(function (n) {
n.x = pos[n.id].x;
n.y = pos[n.id].y;
n.vx = 0;
n.vy = 0;
n.fixed = true;
});
redraw();
fitToView();
});
target.appendChild(canvas);
// ---- pan (drag on background) ----
var panDrag = null;
viewport.addEventListener("mousedown", function (e) {
if (e.target !== viewport && e.target !== world && e.target !== svg) return;
panDrag = { startX: e.clientX, startY: e.clientY, camX: camera.x, camY: camera.y };
viewport.style.cursor = "grabbing";
e.preventDefault();
});
// ---- node drag ----
var nodeDrag = null;
data.nodes.forEach(function (n) {
var box = boxes[n.id];
box.addEventListener("mousedown", function (e) {
e.stopPropagation();
var node = byId[n.id];
node.fixed = true;
node.userPinned = true;
nodeDrag = { node: node, startX: e.clientX, startY: e.clientY, nodeX: node.x, nodeY: node.y };
box.style.cursor = "grabbing";
e.preventDefault();
});
});
window.addEventListener("mousemove", function (e) {
if (panDrag) {
camera.x = panDrag.camX + (e.clientX - panDrag.startX);
camera.y = panDrag.camY + (e.clientY - panDrag.startY);
applyCamera();
} else if (nodeDrag) {
nodeDrag.node.x = nodeDrag.nodeX + (e.clientX - nodeDrag.startX) / camera.zoom;
nodeDrag.node.y = nodeDrag.nodeY + (e.clientY - nodeDrag.startY) / camera.zoom;
redraw();
}
});
window.addEventListener("mouseup", function () {
if (panDrag) {
panDrag = null;
viewport.style.cursor = "grab";
}
if (nodeDrag) {
var box = boxes[nodeDrag.node.id];
if (box) box.style.cursor = "grab";
nodeDrag = null;
}
});
// ---- per-frame redraw ----
function redraw() {
nodeList.forEach(function (n) {
var box = boxes[n.id];
box.style.left = n.x - NODE_WIDTH / 2 + "px";
box.style.top = n.y - n.height / 2 + "px";
});
edgePaths.forEach(function (ep) {
var a = byId[ep.edge.from];
var b = byId[ep.edge.to];
if (!a || !b) return;
var x1 = a.x, y1 = a.y, x2 = b.x, y2 = b.y;
var midX = (x1 + x2) / 2;
var midY = (y1 + y2) / 2;
ep.line.setAttribute(
"d",
"M" + x1 + "," + y1 + " C " + midX + "," + y1 + " " + midX + "," + y2 + " " + x2 + "," + y2
);
if (ep.text) {
ep.text.setAttribute("x", String(midX));
ep.text.setAttribute("y", String(midY - 4));
}
});
nextUnmatchedX = 0;
badgesPerTarget = {};
rootBadges.forEach(function (rb) {
if (rb.matched) {
var target = byId[rb.root.value];
var stackIndex = badgesPerTarget[rb.root.value] || 0;
badgesPerTarget[rb.root.value] = stackIndex + 1;
rb.x = target.x + stackIndex * 90;
var by = target.y - target.height / 2 - ROOT_GAP;
rb.badge.style.left = rb.x - NODE_WIDTH / 4 + "px";
rb.badge.style.top = by + "px";
if (rb.line) {
rb.line.setAttribute(
"d",
"M" + rb.x + "," + (by + ROOT_BADGE_HEIGHT) + " L " + target.x + "," + (target.y - target.height / 2)
);
}
} else {
rb.x = nextUnmatchedX;
nextUnmatchedX += NODE_WIDTH / 2 + 20;
rb.badge.style.left = rb.x + "px";
rb.badge.style.top = "0px";
}
});
}
// ---- animation loop ----
var lastKinetic = Infinity;
// Force-directed layouts need many iterations to go from the seed
// positions to something settled; fitting the camera to the seed
// shape (then letting physics keep running) means nodes drift well
// outside whatever we framed. So run the simulation synchronously
// up front -- like vis-network's stabilization pass -- and only
// fit/paint once it's essentially settled.
function stabilize(maxSteps) {
var k = Infinity;
for (var i = 0; i < maxSteps; i++) {
k = sim.step();
if (k < 0.01) break;
}
lastKinetic = k;
running = k >= 0.01;
}
function frame() {
if (!document.body.contains(target)) return; // this render() call was superseded
if (layoutMode === "auto" && running) {
var kinetic = sim.step();
if (kinetic < 0.01 && lastKinetic < 0.01) {
running = false;
}
lastKinetic = kinetic;
}
redraw();
requestAnimationFrame(frame);
}
stabilize(500);
redraw();
fitToView();
requestAnimationFrame(frame);
}
register({