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

1032 lines
31 KiB
JavaScript
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.
// 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(), {}),
typeHint: sj.sOptionalProp(sj.sString(), {}),
});
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(), {}),
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;
}
// ---- hard rectangle-overlap resolution ----
// Repulsion/gravity alone only balance to *some* equilibrium distance;
// nothing about that equilibrium accounts for box size, and node boxes
// vary a lot in height (one row vs. MAX_VISIBLE_ROWS). So two boxes can
// settle closer than their combined half-heights and visibly overlap.
// This runs every simulation step and directly separates any pair of
// visible boxes whose padded bounding rects intersect, along whichever
// axis needs the smaller push -- a standard AABB de-overlap pass on top
// of the physics, not a replacement for it. Returns the total remaining
// overlap so callers can tell the simulation hasn't truly settled even
// if velocities alone look calm.
function resolveCollisions(nodeList, isVisible) {
var totalOverlap = 0;
for (var i = 0; i < nodeList.length; i++) {
for (var j = i + 1; j < nodeList.length; j++) {
var a = nodeList[i];
var b = nodeList[j];
if (!isVisible(a.id) || !isVisible(b.id)) continue;
if (a.fixed && b.fixed) continue;
var minDX = NODE_WIDTH + H_GAP;
var minDY = (a.height + b.height) / 2 + V_GAP;
var dx = b.x - a.x;
var dy = b.y - a.y;
var overlapX = minDX - Math.abs(dx);
var overlapY = minDY - Math.abs(dy);
if (overlapX <= 0 || overlapY <= 0) continue;
totalOverlap += Math.min(overlapX, overlapY);
var pushX = 0, pushY = 0;
if (dx === 0 && dy === 0) {
pushX = minDX; // exactly coincident: nudge apart deterministically
} else if (overlapX < overlapY) {
pushX = (dx < 0 ? -1 : 1) * overlapX;
} else {
pushY = (dy < 0 ? -1 : 1) * overlapY;
}
if (a.fixed) {
b.x += pushX;
b.y += pushY;
} else if (b.fixed) {
a.x -= pushX;
a.y -= pushY;
} else {
a.x -= pushX / 2;
a.y -= pushY / 2;
b.x += pushX / 2;
b.y += pushY / 2;
}
}
}
return totalOverlap;
}
// ---- simple O(n^2) force-directed layout, vis-network-style ----
// isVisible(id) lets collapsed-subtree nodes sit out of the simulation
// entirely (frozen, not just hidden) so they don't invisibly shove
// 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;
});
// Folded into the same "settled" signal as velocity: as long as
// boxes still overlap, the simulation isn't done, even if forces
// alone have gone quiet.
var overlap = resolveCollisions(nodeList, isVisible);
return kinetic + overlap;
}
return { 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",
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.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");
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];
var badge = el("div", {
position: "absolute",
width: NODE_WIDTH / 2 + "px",
height: ROOT_BADGE_HEIGHT + "px",
lineHeight: ROOT_BADGE_HEIGHT + "px",
textAlign: "center",
borderRadius: "11px",
border: "1px solid " + colors.border,
background: matched ? colors.headerBg : "transparent",
color: matched ? colors.headerFg : colors.pointerValue,
fontFamily: "var(--vscode-editor-font-family, monospace)",
fontSize: "12px",
fontWeight: "bold",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
});
badge.title = r.name + (r.type ? " : " + r.type : "") + " = " + r.value;
badge.textContent = r.name + (matched ? "" : " (" + r.value + ")");
world.appendChild(badge);
var line = null;
if (matched) {
line = document.createElementNS(svgNs, "path");
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.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 = "";
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() };
},
};
},
});
};