1765 lines
60 KiB
TypeScript
1765 lines
60 KiB
TypeScript
// @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() };
|
||
},
|
||
};
|
||
},
|
||
})
|
||
);
|