Files
vscode-debug-visualizer/webview/src/visualizers/nodeTableGraphVisualizer.ts
mynameisdeleted f254c3a31c
Some checks failed
/ build (macos-latest) (push) Has been cancelled
/ build (ubuntu-latest) (push) Has been cancelled
/ build (windows-latest) (push) Has been cancelled
Refactor code structure for improved readability and maintainability
2026-07-12 10:50:18 -04:00

1765 lines
60 KiB
TypeScript
Raw 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.
// @ts-nocheck
// Built-in "Node Table Graph" 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.
//
// Ported verbatim (mechanical changes only -- see git history) from a
// customVisualizerScriptPaths plugin into a statically-registered built-in;
// left untyped (`@ts-nocheck`) since it's a direct port, not new code worth
// retrofitting types onto.
//
// Data shape produced by NodeTableGraphVisualizationSupport.ts's DAP-based
// walker (extension host side):
// 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.
// lang: "cpp" | "rust" | "python" -- which assignment-expression syntax
// editing a value needs (see buildAssignmentExpression below); missing
// for JSON produced by an older, unpatched debug-graph module.
//
// Editing: double-clicking a *non-pointer* field's value (or an unmatched
// scalar root's value row) turns it into a text input. Enter constructs a
// real write-expression for the active language and sends it through
// globalThis.debugVisualizerSetValue -- a bridge only present when both the
// Debug Visualizer extension and this plugin have been patched to support
// editing (see this repo's evaluateForEffect); on an unpatched install the
// double-click handler simply doesn't attach, so the visualizer degrades to
// its normal read-only behavior instead of failing.
// Pointer fields stay read-only in this version -- reassigning a pointer
// safely needs a way to pick *what* to point it at, not just a text box.
import { createVisualizer, globalVisualizationFactory, libImplementation } from "@hediet/visualization-core";
// ---- editable-value expression construction (pure, unit-testable) --------
function quoteForLang(lang, text) {
if (lang === "python") {
return "'" + text.replace(/\\/g, "\\\\").replace(/'/g, "\\'") + "'";
}
// cpp/rust: double-quoted string literal escaping
return '"' + text.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
}
// A light heuristic, not a real per-language literal grammar: numbers pass
// through bare, everything else is treated as (and quoted like) a string.
// Good enough for this demo's int/i32/str fields; a real type-aware editor
// would use the field's typeHint instead of guessing from the text.
function literalForLang(lang, rawText) {
if (/^-?\d+(\.\d+)?$/.test(rawText.trim())) {
return rawText.trim();
}
return quoteForLang(lang, rawText);
}
// Writes into a struct/object field living at `node.id`, given the node's
// reported `type` -- verified against real gdb/rust-gdb/debugpy sessions
// for this demo's Node<int>/list_example::Node<i32>/Node shapes:
// cpp: ((Node<int>*)0x...)->data = 99
// rust: (*(0x... as *mut list_example::Node<i32>)).data = 99
// python: setattr(__import__('ctypes').cast(0x..., __import__('ctypes').py_object).value, 'data', 99)
function buildFieldAssignmentExpression(lang, node, fieldName, rawValueText) {
var literal = literalForLang(lang, rawValueText);
if (lang === "rust") {
return "(*(" + node.id + " as *mut " + node.type + "))." + fieldName + " = " + literal;
}
if (lang === "python") {
return (
"setattr(__import__('ctypes').cast(" + node.id + ", __import__('ctypes').py_object).value, " +
quoteForLang("python", fieldName) + ", " + literal + ")"
);
}
// cpp (default)
return "((" + node.type + "*)" + node.id + ")->" + fieldName + " = " + literal;
}
// Rebinds a plain local/global variable by name -- standard in gdb's and
// rust-gdb's expression parsers; relies on debugpy/pydevd's evaluate(repl)
// handler syncing the frame's fast-locals after the assignment exec's,
// the same mechanism that makes "x = 5" in VS Code's own Python Debug
// Console update the Variables view.
function buildVariableAssignmentExpression(lang, name, rawValueText) {
return name + " = " + literalForLang(lang, rawValueText);
}
var sj = libImplementation.semanticJson;
var sField = sj.sOpenObject({
name: sj.sString(),
value: sj.sString(),
isPointer: sj.sOptionalProp(sj.sBoolean(), {}),
typeHint: sj.sOptionalProp(sj.sString(), {}),
});
var sArrayTableColumn = sj.sOpenObject({
id: sj.sString(),
values: sj.sArrayOf(sField),
});
var sArrayTable = sj.sOpenObject({
memberNames: sj.sArrayOf(sj.sString()),
columns: sj.sArrayOf(sArrayTableColumn),
totalCount: sj.sOptionalProp(sj.sNumber(), {}),
truncated: sj.sOptionalProp(sj.sBoolean(), {}),
});
var sNode = sj.sOpenObject({
id: sj.sString(),
fields: sj.sOptionalProp(sj.sArrayOf(sField), {}),
type: sj.sOptionalProp(sj.sString(), {}),
// Present instead of meaningful `fields` when this node is an array of
// struct-shaped elements -- see NodeTableGraphVisualizationSupport.ts's
// buildArrayTableNode. Rendered as one row per member, one column per
// element, instead of the usual name/value field table.
arrayTable: sj.sOptionalProp(sArrayTable, {}),
});
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) }),
lang: sj.sOptionalProp(sj.sString(), {}),
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;
var MIN_NODE_WIDTH = 100;
var MIN_NODE_HEIGHT = HEADER_HEIGHT + ROW_HEIGHT;
var RESIZE_EDGE_SIZE = 6;
var RESIZE_CORNER_SIZE = 10;
// A field row's name+typeHint column is otherwise unbounded (nowrap, no
// max-width), so a long field name or type could squeeze -- or in table
// auto-layout, actually push clean off the edge of -- the value column
// entirely. Capping the name column at "whatever's left after reserving
// this many characters for the value" keeps the value legible no matter
// how long the name/type text is.
var MIN_VALUE_CH = 15;
// An array-table node shows this many array-element columns at a time --
// large arrays page through the rest instead of laying out hundreds of
// columns (and firing hundreds of per-element DAP round trips worth of DOM)
// at once. Still resizable/scrollable like any other node on top of that.
var ARRAY_COLUMNS_PER_PAGE = 15;
var ARRAY_COLUMN_WIDTH = 70;
var ARRAY_PAGE_BAR_HEIGHT = 18;
function arrayPageBarHeight(n) {
return n.arrayTable && n.arrayTable.columns.length > ARRAY_COLUMNS_PER_PAGE ? ARRAY_PAGE_BAR_HEIGHT : 0;
}
// ---- 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 = (a.width + b.width) / 2 + 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 };
}
// Manually moved/resized nodes should survive a refresh (step, hit a
// breakpoint again, ...) instead of snapping back to the auto layout --
// render() below is called fresh on every refresh (it does
// `target.innerHTML = ""` and rebuilds everything), so this has to live at
// module scope, outside render(), to actually survive across those calls.
// Keyed by node id -- a real pointer address for most nodes, so stable
// across steps as long as the underlying object doesn't move/get freed.
// Synthetic ids (used only for pointerless scalar roots) are a simple
// per-render counter, so they aren't guaranteed unique across two different
// simultaneously-open node-table-graph panels; this is scoped to the common
// case of one panel stepping through time, not airtight isolation between
// panels.
//
// Never pruned on a node's id being merely absent from one refresh's
// data.nodes -- e.g. watching a `current`-style cursor pointer means each
// step's graph only includes whatever's reachable from it right now, so an
// earlier node can drop out and back in as the cursor walks forward.
// Pruning on "not in this snapshot" was tried and reverted: it deleted a
// still-relevant node's saved layout the instant it was transiently out of
// view, which is indistinguishable here from the node actually having been
// freed. Letting this grow for the life of the panel is the safer failure
// mode.
var savedNodeLayouts = {};
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) {
// +1 row for the array table's own column-index header row -- it
// scrolls together with the member rows below it rather than being
// pinned, same simple single-scroll-region approach as a regular
// node's field table.
var rowCount = n.arrayTable ? n.arrayTable.memberNames.length + 1 : n.fields.length;
var visibleRows = Math.min(rowCount, MAX_VISIBLE_ROWS + (n.arrayTable ? 1 : 0));
boxHeights[n.id] = HEADER_HEIGHT + arrayPageBarHeight(n) + 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 saved = savedNodeLayouts[n.id];
// headerExtra always comes from the *current* data, even for a node
// with a saved layout -- whether the array-pagination bar is present
// can change between refreshes (e.g. the array grew past the page
// threshold), independent of the size the user dragged the box to.
var headerExtra = arrayPageBarHeight(n);
if (saved) {
return {
id: n.id,
x: saved.x,
y: saved.y,
vx: 0,
vy: 0,
fixed: true,
userPinned: true,
height: saved.height,
width: saved.width,
headerExtra: headerExtra,
};
}
var p = seed[n.id];
// Wide enough for a full page of columns by default (capped, so a
// huge array doesn't produce a huge initial box) -- still resizable
// smaller/larger by hand, and scrollable within a page either way.
var width = NODE_WIDTH;
if (n.arrayTable) {
var visibleCols = Math.min(n.arrayTable.columns.length, ARRAY_COLUMNS_PER_PAGE);
width = Math.max(NODE_WIDTH, 90 + visibleCols * ARRAY_COLUMN_WIDTH);
}
return {
id: n.id,
x: p.x,
y: p.y,
vx: 0,
vy: 0,
fixed: false,
height: boxHeights[n.id],
width: width,
headerExtra: headerExtra,
};
});
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 };
});
// ---- editable-value cells (double-click a scalar to change it) ----
// Only wired up when the extension host has been patched to expose
// the write-back bridge -- otherwise the cell just stays plain text,
// same as before this feature existed.
var canEdit = typeof globalThis.debugVisualizerSetValue === "function";
function makeValueEditable(valueCell, currentText, buildExpression) {
if (!canEdit) return;
valueCell.style.cursor = "text";
valueCell.title = (valueCell.title ? valueCell.title + " -- " : "") + "double-click to edit";
valueCell.addEventListener("mousedown", function (e) {
e.stopPropagation(); // don't start a box-drag on either click of the double-click
});
valueCell.addEventListener("dblclick", function (e) {
e.stopPropagation(); // don't start a box-drag
var input = el("input", {
width: "100%",
boxSizing: "border-box",
font: "inherit",
color: "inherit",
background: colors.bodyBg,
border: "1px solid " + colors.pointerValue,
});
input.value = currentText;
valueCell.textContent = "";
valueCell.appendChild(input);
input.focus();
input.select();
function restore(text, errorMessage) {
valueCell.textContent = text;
valueCell.style.outline = errorMessage ? "1px solid " + colors.pointerValue : "";
valueCell.title = errorMessage || "double-click to edit";
}
function commit() {
var expression = buildExpression(input.value);
globalThis.debugVisualizerSetValue(expression).then(function (reply) {
if (reply && reply.success) {
currentText = input.value;
restore(currentText, null);
if (typeof globalThis.debugVisualizerRefresh === "function") {
globalThis.debugVisualizerRefresh();
}
} else {
restore(currentText, (reply && reply.message) || "Edit failed.");
}
});
}
input.addEventListener("keydown", function (e) {
if (e.key === "Enter") {
e.preventDefault();
commit();
} else if (e.key === "Escape") {
e.preventDefault();
restore(currentText, null);
}
});
input.addEventListener("blur", function () {
// only revert on blur -- Enter's own commit() already
// replaced the input, so there's nothing left to blur
if (input.isConnected) restore(currentText, null);
});
input.addEventListener("mousedown", function (e) {
e.stopPropagation();
});
});
}
var boxes = {};
var bodies = {};
var toggles = {};
// ---- resize handles (edges + corners) -----------------------------
// Dragging a handle changes that node's own width/height (stored on
// its nodeList entry, read by layout/collision/edge code below instead
// of the NODE_WIDTH constant) and, for a "w"/"n"-ish handle, shifts the
// node's center so the *opposite* edge stays put -- the same anchoring
// a native window resize gives you, rather than the box growing only
// to one side regardless of which handle you grabbed.
//
// sizeKind says which of width/height is this handle's "thickness" (as
// opposed to the dimension that just spans the box's edge) -- world is
// scaled by camera.zoom (see applyCamera/updateResizeHandleSizes
// below), so a handle's *world-space* thickness has to grow at low zoom
// and shrink at high zoom to keep a constant on-screen hit target.
// Measured directly: a busy 25-node graph fits at ~0.5x zoom, which
// alone shrinks a naively-fixed 6px handle to ~3 screen px -- already
// unreliable to click, and at MIN_ZOOM (0.15x) it's under 1px. This is
// almost certainly what "resize stopped working" reports were actually
// hitting once real graphs (not single-node test cases) forced any
// noticeable zoom-out.
var RESIZE_DIRS = [
{ key: "n", cursor: "ns-resize", sizeKind: "edgeH", style: { top: "0", left: "0", right: "0", height: RESIZE_EDGE_SIZE + "px" } },
{ key: "s", cursor: "ns-resize", sizeKind: "edgeH", style: { bottom: "0", left: "0", right: "0", height: RESIZE_EDGE_SIZE + "px" } },
{ key: "e", cursor: "ew-resize", sizeKind: "edgeV", style: { top: "0", right: "0", bottom: "0", width: RESIZE_EDGE_SIZE + "px" } },
{ key: "w", cursor: "ew-resize", sizeKind: "edgeV", style: { top: "0", left: "0", bottom: "0", width: RESIZE_EDGE_SIZE + "px" } },
{ key: "ne", cursor: "nesw-resize", sizeKind: "corner", style: { top: "0", right: "0", width: RESIZE_CORNER_SIZE + "px", height: RESIZE_CORNER_SIZE + "px" } },
{ key: "nw", cursor: "nwse-resize", sizeKind: "corner", style: { top: "0", left: "0", width: RESIZE_CORNER_SIZE + "px", height: RESIZE_CORNER_SIZE + "px" } },
{ key: "se", cursor: "nwse-resize", sizeKind: "corner", style: { bottom: "0", right: "0", width: RESIZE_CORNER_SIZE + "px", height: RESIZE_CORNER_SIZE + "px" } },
{ key: "sw", cursor: "nesw-resize", sizeKind: "corner", style: { bottom: "0", left: "0", width: RESIZE_CORNER_SIZE + "px", height: RESIZE_CORNER_SIZE + "px" } },
];
// Every handle ever created, across every node -- updateResizeHandleSizes()
// (called from applyCamera(), so it stays current through zoom/pan/fit)
// walks this flat list rather than needing a per-node lookup.
var resizeHandleEls = [];
function updateResizeHandleSizes() {
var edgePx = RESIZE_EDGE_SIZE / camera.zoom;
var cornerPx = RESIZE_CORNER_SIZE / camera.zoom;
resizeHandleEls.forEach(function (h) {
if (h.sizeKind === "edgeH") {
h.el.style.height = edgePx + "px";
} else if (h.sizeKind === "edgeV") {
h.el.style.width = edgePx + "px";
} else {
h.el.style.width = cornerPx + "px";
h.el.style.height = cornerPx + "px";
}
});
}
function addResizeHandles(box, nodeId) {
RESIZE_DIRS.forEach(function (d) {
var handle = el(
"div",
Object.assign(
{ position: "absolute", zIndex: "5", cursor: d.cursor, borderRadius: "2px" },
d.style
)
);
// The handle itself has no permanent visual weight (a border or
// fill on every corner/edge of every box would be a lot of
// visual noise on a busy graph) -- it only reveals itself, via a
// solid accent fill, on hover/while actively being dragged. That
// was the actual "handles aren't showing" bug: there was never
// any visual indicator at all, only a cursor change over an
// easy-to-miss few-pixel-wide strip.
handle.addEventListener("mouseenter", function () {
handle.style.background = colors.pointerValue;
});
handle.addEventListener("mouseleave", function () {
if (!resizeDrag || resizeDrag.node !== byId[nodeId] || resizeDrag.dir !== d.key) {
handle.style.background = "";
}
});
handle.addEventListener("mousedown", function (e) {
e.stopPropagation(); // don't start a box-drag or a pan
e.preventDefault();
var node = byId[nodeId];
node.fixed = true;
node.userPinned = true;
resizeDrag = {
node: node,
dir: d.key,
startX: e.clientX,
startY: e.clientY,
startWidth: node.width,
startHeight: node.height,
startNodeX: node.x,
startNodeY: node.y,
handleEl: handle,
};
});
// Appended after the box's own content, and corners after
// edges, so corner handles win the hit-test in the small
// squares where an edge strip and a corner square overlap.
box.appendChild(handle);
resizeHandleEls.push({ el: handle, sizeKind: d.sizeKind });
});
}
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", {
// Height is set explicitly (from the node's own, possibly
// user-resized, height) by redraw() below -- not a fixed
// maxHeight -- so growing a box via its resize handles reveals
// more rows instead of just adding empty scrollable space.
overflowY: "auto",
overflowX: n.arrayTable ? "auto" : "hidden",
});
var table = el("table", { borderCollapse: "collapse", width: n.arrayTable ? "auto" : "100%" });
if (n.arrayTable) {
// One row per member, one column per array element -- a compact
// alternative to a chain of near-identical per-element boxes.
// Member names are a plain first column (not a sticky header)
// same single-scroll-region simplicity as a regular node's table.
// Shown a page (ARRAY_COLUMNS_PER_PAGE columns) at a time --
// rebuildArrayTableRows() replaces just these rows when the user
// pages, everything else about the box is untouched.
var page = 0;
function pageCount() {
return Math.max(1, Math.ceil(n.arrayTable.columns.length / ARRAY_COLUMNS_PER_PAGE));
}
function rebuildArrayTableRows() {
table.innerHTML = "";
var start = page * ARRAY_COLUMNS_PER_PAGE;
var pageColumns = n.arrayTable.columns.slice(start, start + ARRAY_COLUMNS_PER_PAGE);
var headerRow = el("tr");
headerRow.appendChild(el("td", { padding: "2px 8px", height: ROW_HEIGHT - 4 + "px" }));
pageColumns.forEach(function (col) {
var th = el("td", {
padding: "2px 8px",
color: colors.fieldType,
fontWeight: "bold",
whiteSpace: "nowrap",
textAlign: "center",
});
th.textContent = col.id;
headerRow.appendChild(th);
});
table.appendChild(headerRow);
n.arrayTable.memberNames.forEach(function (memberName, rowIndex) {
var row = el("tr");
var nameCell = el("td", {
padding: "2px 8px",
color: colors.fieldName,
whiteSpace: "nowrap",
height: ROW_HEIGHT - 4 + "px",
});
nameCell.appendChild(document.createTextNode(memberName));
row.appendChild(nameCell);
pageColumns.forEach(function (col) {
var f = col.values[rowIndex];
var valueCell = el("td", {
padding: "2px 8px",
color: f.isPointer ? colors.pointerValue : colors.fieldValue,
whiteSpace: "nowrap",
textAlign: "center",
});
valueCell.textContent = f.value;
if (f.typeHint) valueCell.title = memberName + ": " + f.typeHint + " = " + f.value;
row.appendChild(valueCell);
});
table.appendChild(row);
});
}
rebuildArrayTableRows();
if (n.arrayTable.columns.length > ARRAY_COLUMNS_PER_PAGE || n.arrayTable.truncated) {
var pageBar = el("div", {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "4px",
padding: "0 6px",
height: ARRAY_PAGE_BAR_HEIGHT + "px",
borderBottom: "1px solid " + colors.border,
font: "10px var(--vscode-editor-font-family, monospace)",
color: colors.fieldType,
});
var prevBtn = el("span", { cursor: "pointer", padding: "0 4px", userSelect: "none" });
prevBtn.textContent = "‹";
var nextBtn = el("span", { cursor: "pointer", padding: "0 4px", userSelect: "none" });
nextBtn.textContent = "›";
var pageLabel = el("span", { whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" });
function updatePageBar() {
var pc = pageCount();
var label = "Page " + (page + 1) + "/" + pc;
if (n.arrayTable.truncated) {
label += " (" + n.arrayTable.columns.length + " of " + n.arrayTable.totalCount + " fetched)";
}
pageLabel.textContent = label;
prevBtn.style.opacity = page <= 0 ? "0.35" : "1";
nextBtn.style.opacity = page >= pc - 1 ? "0.35" : "1";
}
[prevBtn, nextBtn].forEach(function (b) {
b.addEventListener("mousedown", function (e) {
e.stopPropagation(); // don't start a box-drag
});
});
prevBtn.addEventListener("click", function (e) {
e.stopPropagation();
if (page <= 0) return;
page--;
rebuildArrayTableRows();
updatePageBar();
});
nextBtn.addEventListener("click", function (e) {
e.stopPropagation();
if (page >= pageCount() - 1) return;
page++;
rebuildArrayTableRows();
updatePageBar();
});
updatePageBar();
pageBar.appendChild(prevBtn);
pageBar.appendChild(pageLabel);
pageBar.appendChild(nextBtn);
box.appendChild(pageBar);
}
} else {
// 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");
// Always available on hover, not just when typeHint is set --
// the name itself is now truncatable too (see nameCell below).
row.title = f.name + (f.typeHint ? ": " + f.typeHint : "") + " = " + f.value;
var nameCell = el("td", {
padding: "2px 8px",
color: colors.fieldName,
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
// Leave at least MIN_VALUE_CH worth of the row for the
// value column -- see valueCell's matching minWidth below.
maxWidth: "calc(100% - " + MIN_VALUE_CH + "ch)",
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",
minWidth: MIN_VALUE_CH + "ch",
width: "100%",
});
valueCell.textContent = f.value;
if (!f.isPointer) {
makeValueEditable(valueCell, f.value, function (newText) {
return buildFieldAssignmentExpression(data.lang || "cpp", n, f.name, newText);
});
}
row.appendChild(nameCell);
row.appendChild(valueCell);
table.appendChild(row);
});
}
body.appendChild(table);
box.appendChild(body);
addResizeHandles(box, n.id);
world.appendChild(box);
boxes[n.id] = box;
bodies[n.id] = body;
});
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;
// Only the "value" row, and only when it doesn't look like a
// pointer (e.g. a null/unresolved root) -- reassigning a
// pointer-typed variable safely needs a cast on the new
// value too, which is out of scope for this version.
if (row.name === "value" && !/^0x[0-9a-f]+$/i.test(row.value)) {
makeValueEditable(valueCell, row.value, function (newText) {
return buildVariableAssignmentExpression(data.lang || "cpp", r.name, newText);
});
}
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 + ")";
// Keeps every resize handle a constant on-screen size regardless of
// zoom -- called from here (rather than only once at setup) since
// zoomBy()/fitToView() are exactly the things that change camera.zoom.
updateResizeHandleSizes();
}
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 - n.width / 2);
maxX = Math.max(maxX, n.x + n.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();
});
// Records a manually moved/resized node's current x/y/width/height into
// the module-level savedNodeLayouts cache so the *next* render() call
// (the next debug-step refresh) picks it back up instead of resetting
// to the auto layout -- see savedNodeLayouts' own comment above.
function saveNodeLayout(node) {
savedNodeLayouts[node.id] = {
x: node.x,
y: node.y,
width: node.width,
height: node.height,
};
}
// ---- node resize (via the edge/corner handles added above) ----
var resizeDrag = null;
// ---- 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 (resizeDrag) {
var dx = (e.clientX - resizeDrag.startX) / camera.zoom;
var dy = (e.clientY - resizeDrag.startY) / camera.zoom;
var dir = resizeDrag.dir;
var node = resizeDrag.node;
var newWidth = resizeDrag.startWidth;
var newHeight = resizeDrag.startHeight;
// Each active axis recomputes both the new size *and* the
// node's center, so the edge/corner opposite the one being
// dragged stays anchored in place instead of the whole box
// sliding along with the cursor.
if (dir.indexOf("e") !== -1) {
newWidth = Math.max(MIN_NODE_WIDTH, resizeDrag.startWidth + dx);
node.x = resizeDrag.startNodeX + (newWidth - resizeDrag.startWidth) / 2;
} else if (dir.indexOf("w") !== -1) {
newWidth = Math.max(MIN_NODE_WIDTH, resizeDrag.startWidth - dx);
node.x = resizeDrag.startNodeX - (newWidth - resizeDrag.startWidth) / 2;
}
if (dir.indexOf("s") !== -1) {
newHeight = Math.max(MIN_NODE_HEIGHT, resizeDrag.startHeight + dy);
node.y = resizeDrag.startNodeY + (newHeight - resizeDrag.startHeight) / 2;
} else if (dir.indexOf("n") !== -1) {
newHeight = Math.max(MIN_NODE_HEIGHT, resizeDrag.startHeight - dy);
node.y = resizeDrag.startNodeY - (newHeight - resizeDrag.startHeight) / 2;
}
node.width = newWidth;
node.height = newHeight;
saveNodeLayout(node);
redraw();
} 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;
saveNodeLayout(nodeDrag.node);
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 (resizeDrag) {
// mouseleave already skipped clearing this handle's highlight
// while the drag was in progress (see addResizeHandles) -- the
// mouse is very likely no longer over it by the time you let go.
resizeDrag.handleEl.style.background = "";
resizeDrag = null;
}
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.width = n.width + "px";
box.style.left = n.x - n.width / 2 + "px";
box.style.top = n.y - n.height / 2 + "px";
var body = bodies[n.id];
// Body height = box height minus the (fixed-height) header
// (and, for a paged array table, its page-nav bar too); box
// itself is never given an explicit height, it just grows to
// fit header + body -- so this is the one place a resize
// actually changes what's rendered.
if (body) body.style.height = Math.max(ROW_HEIGHT, n.height - HEADER_HEIGHT - (n.headerExtra || 0)) + "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, a.width / 2, a.height / 2, b.x, b.y);
var end = boxEdgePoint(b.x, b.y, b.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);
}
}
globalVisualizationFactory.addVisualizer(
createVisualizer({
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() };
},
};
},
})
);