957 lines
28 KiB
JavaScript
957 lines
28 KiB
JavaScript
// 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 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(), {}),
|
||
});
|
||
|
||
var sNode = sj.sOpenObject({
|
||
id: sj.sString(),
|
||
fields: sj.sArrayOf(sField),
|
||
});
|
||
|
||
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(), {}),
|
||
});
|
||
|
||
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;
|
||
var DAMPING = 0.82;
|
||
var GRAVITY = 0.01;
|
||
|
||
var byId = {};
|
||
nodeList.forEach(function (n) {
|
||
byId[n.id] = n;
|
||
});
|
||
|
||
function step() {
|
||
var fx = {};
|
||
var fy = {};
|
||
nodeList.forEach(function (n) {
|
||
fx[n.id] = 0;
|
||
fy[n.id] = 0;
|
||
});
|
||
|
||
for (var i = 0; i < nodeList.length; i++) {
|
||
for (var j = i + 1; j < nodeList.length; j++) {
|
||
var a = nodeList[i];
|
||
var b = nodeList[j];
|
||
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 force = REPULSION / distSq;
|
||
var ux = dx / dist;
|
||
var uy = dy / dist;
|
||
fx[a.id] += ux * force;
|
||
fy[a.id] += uy * force;
|
||
fx[b.id] -= ux * force;
|
||
fy[b.id] -= uy * force;
|
||
}
|
||
}
|
||
|
||
edges.forEach(function (e) {
|
||
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 };
|
||
}
|
||
|
||
// ---- 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",
|
||
pointerValue: "#ce9178",
|
||
edge: "#8a8a8a",
|
||
viewportBg: "#1e1e1e",
|
||
}
|
||
: {
|
||
border: "#b8b8b8",
|
||
headerBg: "#eeeeee",
|
||
headerFg: "#1a1a1a",
|
||
bodyBg: "#ffffff",
|
||
fieldName: "#0451a5",
|
||
fieldValue: "#1a1a1a",
|
||
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.textContent = n.id;
|
||
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%" });
|
||
|
||
n.fields.forEach(function (f) {
|
||
var row = el("tr");
|
||
var nameCell = el("td", {
|
||
padding: "2px 8px",
|
||
color: colors.fieldName,
|
||
whiteSpace: "nowrap",
|
||
height: ROW_HEIGHT - 4 + "px",
|
||
});
|
||
nameCell.textContent = f.name;
|
||
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];
|
||
var badge = el("div", {
|
||
position: "absolute",
|
||
width: NODE_WIDTH / 2 + "px",
|
||
height: ROOT_BADGE_HEIGHT + "px",
|
||
lineHeight: ROOT_BADGE_HEIGHT + "px",
|
||
textAlign: "center",
|
||
borderRadius: "11px",
|
||
border: "1px solid " + colors.border,
|
||
background: matched ? colors.headerBg : "transparent",
|
||
color: matched ? colors.headerFg : colors.pointerValue,
|
||
fontFamily: "var(--vscode-editor-font-family, monospace)",
|
||
fontSize: "12px",
|
||
fontWeight: "bold",
|
||
whiteSpace: "nowrap",
|
||
overflow: "hidden",
|
||
textOverflow: "ellipsis",
|
||
});
|
||
badge.title = r.name + " = " + r.value;
|
||
badge.textContent = r.name + (matched ? "" : " (" + r.value + ")");
|
||
world.appendChild(badge);
|
||
var line = null;
|
||
if (matched) {
|
||
line = document.createElementNS(svgNs, "path");
|
||
line.setAttribute("fill", "none");
|
||
line.setAttribute("stroke", colors.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: matched, x: 0 };
|
||
});
|
||
var nextUnmatchedX = 0;
|
||
var badgesPerTarget = {};
|
||
|
||
viewport.appendChild(world);
|
||
target.appendChild(viewport);
|
||
|
||
// ---- toolbar ----
|
||
var toolbar = el("div", {
|
||
position: "absolute",
|
||
right: "8px",
|
||
top: "8px",
|
||
display: "flex",
|
||
gap: "6px",
|
||
zIndex: "10",
|
||
});
|
||
var autoBtn = button("Auto-arrange", colors);
|
||
var layeredBtn = button("Layered", colors);
|
||
var 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);
|
||
});
|
||
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();
|
||
});
|
||
});
|
||
|
||
window.addEventListener("mousemove", function (e) {
|
||
if (panDrag) {
|
||
camera.x = panDrag.camX + (e.clientX - panDrag.startX);
|
||
camera.y = panDrag.camY + (e.clientY - panDrag.startY);
|
||
applyCamera();
|
||
} else if (nodeDrag) {
|
||
nodeDrag.node.x = nodeDrag.nodeX + (e.clientX - nodeDrag.startX) / camera.zoom;
|
||
nodeDrag.node.y = nodeDrag.nodeY + (e.clientY - nodeDrag.startY) / camera.zoom;
|
||
redraw();
|
||
}
|
||
});
|
||
window.addEventListener("mouseup", function () {
|
||
if (panDrag) {
|
||
panDrag = null;
|
||
viewport.style.cursor = "grab";
|
||
}
|
||
if (nodeDrag) {
|
||
var box = boxes[nodeDrag.node.id];
|
||
if (box) box.style.cursor = "grab";
|
||
nodeDrag = null;
|
||
}
|
||
});
|
||
|
||
// ---- per-frame redraw ----
|
||
function redraw() {
|
||
nodeList.forEach(function (n) {
|
||
var box = boxes[n.id];
|
||
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";
|
||
}
|
||
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 x1 = a.x, y1 = a.y, x2 = b.x, y2 = b.y;
|
||
var midX = (x1 + x2) / 2;
|
||
var midY = (y1 + y2) / 2;
|
||
ep.line.setAttribute(
|
||
"d",
|
||
"M" + x1 + "," + y1 + " C " + midX + "," + y1 + " " + midX + "," + y2 + " " + x2 + "," + y2
|
||
);
|
||
if (ep.text) {
|
||
ep.text.setAttribute("x", String(midX));
|
||
ep.text.setAttribute("y", String(midY - 4));
|
||
}
|
||
});
|
||
|
||
nextUnmatchedX = 0;
|
||
badgesPerTarget = {};
|
||
rootBadges.forEach(function (rb) {
|
||
if (!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.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 = "";
|
||
rb.x = nextUnmatchedX;
|
||
nextUnmatchedX += NODE_WIDTH / 2 + 20;
|
||
rb.badge.style.left = rb.x + "px";
|
||
rb.badge.style.top = "0px";
|
||
}
|
||
});
|
||
}
|
||
|
||
// ---- animation loop ----
|
||
var lastKinetic = Infinity;
|
||
|
||
// Force-directed layouts need many iterations to go from the seed
|
||
// positions to something settled; fitting the camera to the seed
|
||
// shape (then letting physics keep running) means nodes drift well
|
||
// outside whatever we framed. So run the simulation synchronously
|
||
// up front -- like vis-network's stabilization pass -- and only
|
||
// fit/paint once it's essentially settled.
|
||
function stabilize(maxSteps) {
|
||
var k = Infinity;
|
||
for (var i = 0; i < maxSteps; i++) {
|
||
k = sim.step();
|
||
if (k < 0.01) break;
|
||
}
|
||
lastKinetic = k;
|
||
running = k >= 0.01;
|
||
}
|
||
|
||
function frame() {
|
||
if (!document.body.contains(target)) return; // this render() call was superseded
|
||
if (layoutMode === "auto" && running) {
|
||
var kinetic = sim.step();
|
||
if (kinetic < 0.01 && lastKinetic < 0.01) {
|
||
running = false;
|
||
}
|
||
lastKinetic = kinetic;
|
||
}
|
||
redraw();
|
||
requestAnimationFrame(frame);
|
||
}
|
||
|
||
stabilize(500);
|
||
redraw();
|
||
fitToView();
|
||
requestAnimationFrame(frame);
|
||
|
||
// 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() };
|
||
},
|
||
};
|
||
},
|
||
});
|
||
};
|