Files
visual_debugger/vis-plugins/node-table-visualizer.js

1197 lines
38 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Custom "Debug Visualizer" (hediet.debug-visualizer) visualization.
// Renders a { kind: { nodeTable: true }, nodes: [...], edges: [...] } payload
// 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 debuggers/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...", kind? }] -- external pointer
// variables (e.g. a local `top`/`end` in the caller's stack frame);
// drawn as a small pill that tracks above whichever node's id matches
// its value. kind is "local" | "watched" | "global" (missing/unknown
// treated as "watched"); the toolbar offers a show/hide toggle per
// kind that's actually present in the data.
module.exports = function (register, lib) {
var sj = lib.semanticJson;
var sField = sj.sOpenObject({
name: sj.sString(),
value: sj.sString(),
isPointer: sj.sOptionalProp(sj.sBoolean(), {}),
typeHint: sj.sOptionalProp(sj.sString(), {}),
});
var sNode = sj.sOpenObject({
id: sj.sString(),
fields: sj.sArrayOf(sField),
type: sj.sOptionalProp(sj.sString(), {}),
});
var sEdge = sj.sOpenObject({
from: sj.sString(),
to: sj.sString(),
label: sj.sOptionalProp(sj.sString(), {}),
});
var sRoot = sj.sOpenObject({
name: sj.sString(),
value: sj.sString(),
kind: sj.sOptionalProp(sj.sString(), {}),
type: sj.sOptionalProp(sj.sString(), {}),
});
var sData = sj.sOpenObject({
kind: sj.sOpenObject({ nodeTable: sj.sLiteral(true) }),
nodes: sj.sArrayOf(sNode),
edges: sj.sArrayOf(sEdge),
roots: sj.sOptionalProp(sj.sArrayOf(sRoot), {}),
});
var HEADER_HEIGHT = 24;
var ROW_HEIGHT = 20;
var MAX_VISIBLE_ROWS = 10;
var NODE_WIDTH = 220;
var H_GAP = 70;
var V_GAP = 24;
var ROOT_BADGE_HEIGHT = 22;
var ROOT_GAP = 30;
var MIN_ZOOM = 0.15;
var MAX_ZOOM = 3;
// ---- seed layout: left-to-right BFS levels, also used as "Layered" mode ----
function layeredPositions(nodeIds, edges, boxHeights) {
var adjacency = {};
var incoming = {};
nodeIds.forEach(function (id) {
adjacency[id] = [];
incoming[id] = 0;
});
edges.forEach(function (e) {
if (adjacency[e.from]) adjacency[e.from].push(e.to);
if (incoming[e.to] !== undefined) incoming[e.to]++;
});
var level = {};
var queue = [];
nodeIds.forEach(function (id) {
if (incoming[id] === 0) {
level[id] = 0;
queue.push(id);
}
});
if (queue.length === 0 && nodeIds.length > 0) {
level[nodeIds[0]] = 0;
queue.push(nodeIds[0]);
}
var head = 0;
var maxLevel = 0;
while (head < queue.length) {
var cur = queue[head++];
var curLevel = level[cur];
maxLevel = Math.max(maxLevel, curLevel);
(adjacency[cur] || []).forEach(function (next) {
if (level[next] === undefined) {
level[next] = curLevel + 1;
queue.push(next);
}
});
}
var colCount = {};
var positions = {};
nodeIds.forEach(function (id) {
var lvl = level[id];
if (lvl === undefined) {
maxLevel++;
lvl = maxLevel;
}
var col = colCount[lvl] || 0;
colCount[lvl] = col + 1;
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 ----
// isVisible(id) lets collapsed-subtree nodes sit out of the simulation
// entirely (frozen, not just hidden) so they don't invisibly shove
// visible nodes around, and reappear where they last were when expanded.
function createSimulation(nodeList, edges, isVisible) {
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;
var byId = {};
nodeList.forEach(function (n) {
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 = {};
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];
if (!isVisible(a.id) || !isVisible(b.id)) continue;
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 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;
}
}
}
}
edges.forEach(function (e) {
if (!isVisible(e.from) || !isVisible(e.to)) return;
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 || !isVisible(n.id)) {
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 };
}
// 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 = {};
var stack = startChildren.slice();
while (stack.length) {
var id = stack.pop();
if (seen[id]) continue;
seen[id] = true;
(childrenOf[id] || []).forEach(function (c) {
if (!seen[c]) stack.push(c);
});
}
return Object.keys(seen).length;
}
function el(tag, style, attrs) {
var e = document.createElement(tag);
if (style) Object.assign(e.style, style);
if (attrs) Object.keys(attrs).forEach(function (k) {
e.setAttribute(k, attrs[k]);
});
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;
}
var ROOT_KINDS = ["local", "watched", "global"];
var ROOT_KIND_LABELS = { local: "Locals", watched: "Watched", global: "Globals" };
function rootKind(r) {
return ROOT_KINDS.indexOf(r.kind) !== -1 ? r.kind : "watched";
}
function kindToggle(labelText, colors) {
var wrap = el("label", {
display: "flex",
alignItems: "center",
gap: "4px",
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",
userSelect: "none",
});
var input = el("input", { cursor: "pointer" }, { type: "checkbox" });
input.checked = true;
wrap.appendChild(input);
wrap.appendChild(document.createTextNode(labelText));
return { wrap: wrap, input: input };
}
function render(data, target, theme) {
target.innerHTML = "";
Object.assign(target.style, {
position: "relative",
overflow: "hidden",
width: "100%",
height: "100%",
boxSizing: "border-box",
});
var dark = theme === "dark";
var colors = dark
? {
border: "#5a5a5a",
headerBg: "#333333",
headerFg: "#e8e8e8",
bodyBg: "#252526",
fieldName: "#9cdcfe",
fieldValue: "#d4d4d4",
fieldType: "#7a7a7a",
pointerValue: "#ce9178",
edge: "#8a8a8a",
viewportBg: "#1e1e1e",
}
: {
border: "#b8b8b8",
headerBg: "#eeeeee",
headerFg: "#1a1a1a",
bodyBg: "#ffffff",
fieldName: "#0451a5",
fieldValue: "#1a1a1a",
fieldType: "#8a8a8a",
pointerValue: "#a31515",
edge: "#6b6b6b",
viewportBg: "#fafafa",
};
var roots = data.roots || [];
var boxHeights = {};
data.nodes.forEach(function (n) {
var visibleRows = Math.min(n.fields.length, MAX_VISIBLE_ROWS);
boxHeights[n.id] = HEADER_HEIGHT + Math.max(visibleRows, 1) * ROW_HEIGHT;
});
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;
});
var childrenOf = {};
var parentsOf = {};
data.edges.forEach(function (e) {
(childrenOf[e.from] = childrenOf[e.from] || []).push(e.to);
(parentsOf[e.to] = parentsOf[e.to] || []).push(e.from);
});
var collapsed = {}; // nodeId -> bool, user-toggled
var visible = {}; // nodeId -> bool, recomputed by computeVisible()
function computeVisible() {
var newVisible = {};
var hasIncoming = {};
data.edges.forEach(function (e) {
hasIncoming[e.to] = true;
});
var roots = nodeIds.filter(function (id) {
return !hasIncoming[id];
});
if (roots.length === 0 && nodeIds.length > 0) roots = [nodeIds[0]];
var queue = roots.slice();
roots.forEach(function (id) {
newVisible[id] = true;
});
while (queue.length) {
var id = queue.shift();
if (collapsed[id]) continue; // don't expand past a collapsed node
(childrenOf[id] || []).forEach(function (childId) {
if (!newVisible[childId]) {
newVisible[childId] = true;
queue.push(childId);
}
});
}
visible = newVisible;
}
computeVisible();
// A local/global/watched pointer's target can end up hidden inside a
// collapsed subtree; rather than just hiding its arrow, walk up the
// edge graph to whichever visible ancestor is currently standing in
// for that hidden subtree, and point the arrow there instead -- so
// expanding that ancestor is what makes the arrow "arrive" at its
// real target.
function nearestVisibleTarget(id) {
var seen = {};
var queue = [id];
while (queue.length) {
var cur = queue.shift();
if (visible[cur]) return cur;
if (seen[cur]) continue;
seen[cur] = true;
(parentsOf[cur] || []).forEach(function (p) {
if (!seen[p]) queue.push(p);
});
}
return null;
}
var sim = createSimulation(nodeList, data.edges, function (id) {
return !!visible[id];
});
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 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");
Object.assign(svg.style, { position: "absolute", left: "0", top: "0", overflow: "visible", pointerEvents: "none" });
var defs = document.createElementNS(svgNs, "defs");
function addArrowMarker(id, fill) {
var marker = document.createElementNS(svgNs, "marker");
marker.setAttribute("id", id);
marker.setAttribute("markerWidth", "8");
marker.setAttribute("markerHeight", "8");
marker.setAttribute("refX", "7");
marker.setAttribute("refY", "4");
marker.setAttribute("orient", "auto");
var arrowPath = document.createElementNS(svgNs, "path");
arrowPath.setAttribute("d", "M0,0 L8,4 L0,8 Z");
arrowPath.setAttribute("fill", fill);
marker.appendChild(arrowPath);
defs.appendChild(marker);
}
// Struct-to-struct pointer fields (e.g. `next`) use the neutral edge
// color; local/watched/global variable pointers use the same accent
// color as pointer values in the field tables, so a glance at an
// arrow's color says whether it's "inside the data structure" or
// "a variable pointing into it".
addArrowMarker("dv-arrow", colors.edge);
addArrowMarker("dv-arrow-root", colors.pointerValue);
svg.appendChild(defs);
world.appendChild(svg);
var edgePaths = data.edges.map(function (e) {
var line = document.createElementNS(svgNs, "path");
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) {
text = document.createElementNS(svgNs, "text");
text.setAttribute("fill", colors.edge);
text.setAttribute("font-size", "11");
text.setAttribute("font-family", "monospace");
text.setAttribute("text-anchor", "middle");
text.textContent = e.label;
svg.appendChild(text);
}
return { edge: e, line: line, text: text };
});
var boxes = {};
var toggles = {};
data.nodes.forEach(function (n) {
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,
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "6px",
});
var headerLabel = el("span", {
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
minWidth: "0",
});
headerLabel.appendChild(document.createTextNode(n.id));
if (n.type) {
var nodeTypeSpan = el("span", {
color: colors.fieldType,
fontSize: "10px",
fontWeight: "normal",
marginLeft: "6px",
});
nodeTypeSpan.textContent = n.type;
headerLabel.appendChild(nodeTypeSpan);
header.title = n.id + " : " + n.type;
}
header.appendChild(headerLabel);
var kids = childrenOf[n.id];
if (kids && kids.length) {
var toggle = el("span", {
flexShrink: "0",
cursor: "pointer",
border: "1px solid " + colors.border,
borderRadius: "3px",
padding: "0 5px",
fontSize: "11px",
lineHeight: "14px",
userSelect: "none",
});
toggle.addEventListener("mousedown", function (e) {
e.stopPropagation(); // don't start a box-drag
});
toggle.addEventListener("click", function (e) {
e.stopPropagation();
collapsed[n.id] = !collapsed[n.id];
computeVisible();
redraw();
if (layoutMode === "auto") stabilize(200);
});
header.appendChild(toggle);
toggles[n.id] = toggle;
}
box.appendChild(header);
var body = el("div", { maxHeight: MAX_VISIBLE_ROWS * ROW_HEIGHT + "px", overflowY: "auto" });
var table = el("table", { borderCollapse: "collapse", width: "100%" });
// 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", {
padding: "2px 8px",
color: colors.fieldName,
whiteSpace: "nowrap",
height: ROW_HEIGHT - 4 + "px",
});
nameCell.appendChild(document.createTextNode(f.name));
if (f.typeHint) {
var typeSpan = el("span", {
color: colors.fieldType,
fontSize: "10px",
marginLeft: "5px",
});
typeSpan.textContent = " " + f.typeHint;
nameCell.appendChild(typeSpan);
}
var valueCell = el("td", {
padding: "2px 8px",
color: f.isPointer ? colors.pointerValue : colors.fieldValue,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
maxWidth: "0",
width: "100%",
});
valueCell.textContent = f.value;
row.appendChild(nameCell);
row.appendChild(valueCell);
table.appendChild(row);
});
body.appendChild(table);
box.appendChild(body);
world.appendChild(box);
boxes[n.id] = box;
});
var rootBadges = roots.map(function (r) {
var matched = !!byId[r.value];
if (matched) {
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 };
}
// 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 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 expandAllBtn = button("Expand all", colors);
var fitBtn = button("Fit", colors);
var zoomOutBtn = button("−", colors);
var zoomInBtn = button("+", colors);
[autoBtn, layeredBtn, expandAllBtn, fitBtn, zoomOutBtn, zoomInBtn].forEach(function (b) {
toolbar.appendChild(b);
});
target.appendChild(toolbar);
// ---- root-kind toggles (Locals / Watched / Globals) ----
var rootKindEnabled = { local: true, watched: true, global: true };
var kindsPresent = {};
roots.forEach(function (r) {
kindsPresent[rootKind(r)] = true;
});
var presentKinds = ROOT_KINDS.filter(function (k) {
return kindsPresent[k];
});
if (presentKinds.length > 0) {
var kindToolbar = el("div", {
position: "absolute",
left: "8px",
top: "8px",
display: "flex",
gap: "6px",
zIndex: "10",
});
presentKinds.forEach(function (kind) {
var cb = kindToggle(ROOT_KIND_LABELS[kind], colors);
cb.input.addEventListener("change", function () {
rootKindEnabled[kind] = cb.input.checked;
redraw();
});
kindToolbar.appendChild(cb.wrap);
});
target.appendChild(kindToolbar);
}
expandAllBtn.addEventListener("click", function () {
collapsed = {};
computeVisible();
redraw();
if (layoutMode === "auto") stabilize(200);
});
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() {
var visibleNodes = nodeList.filter(function (n) {
return visible[n.id];
});
if (visibleNodes.length === 0) return;
var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
visibleNodes.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);
});
// 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);
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();
});
// ---- 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();
});
});
// ---- 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);
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();
} 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 () {
if (panDrag) {
panDrag = null;
viewport.style.cursor = "grab";
}
if (nodeDrag) {
var box = boxes[nodeDrag.node.id];
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];
box.style.display = isVis ? "" : "none";
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) {
if (collapsed[n.id]) {
toggle.textContent = "+" + subtreeSize(childrenOf[n.id] || [], childrenOf);
toggle.title = "Expand";
} else {
toggle.textContent = "−";
toggle.title = "Collapse";
}
}
});
edgePaths.forEach(function (ep) {
var a = byId[ep.edge.from];
var b = byId[ep.edge.to];
var show = !!(a && b && visible[ep.edge.from] && visible[ep.edge.to]);
ep.line.style.display = show ? "" : "none";
if (ep.text) ep.text.style.display = show ? "" : "none";
if (!show) return;
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(
"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));
}
});
// Unmatched-root cards (plain scalar locals/globals/watches) that
// aren't pinned by a drag auto-flow into a compact roughly-square
// cluster -- sqrt(count) columns -- instead of one ever-widening
// row, so a handful of locals reads as a group near the top
// instead of a line stretching off to the side. The cluster's
// bottom row sits just above the node cloud (same non-overlap
// tracking as before); earlier rows stack upward from there.
var unmatchedFlow = rootBadges.filter(function (rb) {
return !rb.matched && !rb.pinned && rootKindEnabled[rb.kind];
});
var flowCols = Math.max(1, Math.ceil(Math.sqrt(unmatchedFlow.length)));
var flowRows = Math.ceil(unmatchedFlow.length / flowCols);
var flowRowHeight = 0;
unmatchedFlow.forEach(function (rb) {
flowRowHeight = Math.max(flowRowHeight, rb.height);
});
var flowRowGap = 10;
var flowShelfBottom = (visibleTopY === Infinity ? 0 : visibleTopY) - ROOT_GAP;
var flowIndex = 0;
badgesPerTarget = {};
rootBadges.forEach(function (rb) {
if (!rootKindEnabled[rb.kind]) {
rb.badge.style.display = "none";
if (rb.line) rb.line.style.display = "none";
return;
}
if (rb.matched) {
var targetId = visible[rb.root.value] ? rb.root.value : nearestVisibleTarget(rb.root.value);
rb.badge.style.display = targetId ? "" : "none";
if (rb.line) rb.line.style.display = targetId ? "" : "none";
if (!targetId) return;
var redirected = targetId !== rb.root.value;
rb.badge.title = rb.root.name + (rb.root.type ? " : " + rb.root.type : "") + " = " + rb.root.value +
(redirected ? " (inside collapsed " + targetId + ")" : "");
var target = byId[targetId];
var stackIndex = badgesPerTarget[targetId] || 0;
badgesPerTarget[targetId] = 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.badge.style.display = "";
if (!rb.pinned) {
var row = Math.floor(flowIndex / flowCols); // 0 = top row
var col = flowIndex % flowCols;
var rowFromBottom = flowRows - 1 - row; // 0 = row nearest the graph
var rowBottom = flowShelfBottom - rowFromBottom * (flowRowHeight + flowRowGap);
rb.x = col * (NODE_WIDTH + 20);
rb.y = rowBottom - rb.height;
flowIndex++;
}
// A pinned (user-dragged) card keeps whatever rb.x/rb.y
// the drag left it at, and drops out of the auto-flow
// cluster entirely -- the remaining unpinned cards
// re-flow to close the gap instead of leaving a hole.
rb.badge.style.left = rb.x + "px";
rb.badge.style.top = rb.y + "px";
}
});
}
// ---- 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);
// The panel/webview may not have its final layout size yet at the
// instant render() runs (fresh panel, window just reloaded, tab just
// became visible) -- fitToView() above would then measure a stale or
// fallback 800x600 target size. Refit once more the first time the
// target's real size shows up differently, then stop watching so we
// don't fight the user's own manual pan/zoom on later resizes.
if (typeof ResizeObserver !== "undefined") {
var lastW = target.clientWidth;
var lastH = target.clientHeight;
var ro = new ResizeObserver(function () {
if (!document.body.contains(target)) {
ro.disconnect(); // this render() call was superseded
return;
}
if (target.clientWidth === lastW && target.clientHeight === lastH) return;
ro.disconnect();
fitToView();
});
ro.observe(target);
}
}
register({
id: "node-table-graph",
name: "Node Table Graph",
serializer: sData,
getVisualization: function (data, self) {
return {
id: "node-table-graph-instance",
name: "Node Table Graph",
priority: 2000,
preload: function () {
return Promise.resolve();
},
render: function (target, options) {
render(data, target, options.theme.kind);
return { renderState: undefined, ready: Promise.resolve() };
},
};
},
});
};