feat(debug): enhance node table visualizer with force-directed layout and toolbar controls
This commit is contained in:
@@ -3,18 +3,20 @@
|
||||
// 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 cpp/main.cpp's LinkedList<T>::toDebugGraph():
|
||||
// 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..." }] -- external pointer variables
|
||||
// (e.g. a local `top`/`end` in the caller's stack frame); drawn as a
|
||||
// small pill above the graph with an arrow to whichever node's id
|
||||
// matches its value, since a node's own address equals its first
|
||||
// member's address.
|
||||
// small pill that tracks above whichever node's id matches its value.
|
||||
|
||||
module.exports = function (register, lib) {
|
||||
var sj = lib.semanticJson;
|
||||
@@ -54,17 +56,16 @@ module.exports = function (register, lib) {
|
||||
var NODE_WIDTH = 220;
|
||||
var H_GAP = 70;
|
||||
var V_GAP = 24;
|
||||
var PADDING = 20;
|
||||
var ROOT_ROW_HEIGHT = 50;
|
||||
var ROOT_BADGE_HEIGHT = 22;
|
||||
var ROOT_GAP = 30;
|
||||
var MIN_ZOOM = 0.15;
|
||||
var MAX_ZOOM = 3;
|
||||
|
||||
function layout(nodes, edges) {
|
||||
var ids = nodes.map(function (n) {
|
||||
return n.id;
|
||||
});
|
||||
// ---- seed layout: left-to-right BFS levels, also used as "Layered" mode ----
|
||||
function layeredPositions(nodeIds, edges, boxHeights) {
|
||||
var adjacency = {};
|
||||
var incoming = {};
|
||||
ids.forEach(function (id) {
|
||||
nodeIds.forEach(function (id) {
|
||||
adjacency[id] = [];
|
||||
incoming[id] = 0;
|
||||
});
|
||||
@@ -75,15 +76,15 @@ module.exports = function (register, lib) {
|
||||
|
||||
var level = {};
|
||||
var queue = [];
|
||||
ids.forEach(function (id) {
|
||||
nodeIds.forEach(function (id) {
|
||||
if (incoming[id] === 0) {
|
||||
level[id] = 0;
|
||||
queue.push(id);
|
||||
}
|
||||
});
|
||||
if (queue.length === 0 && ids.length > 0) {
|
||||
level[ids[0]] = 0;
|
||||
queue.push(ids[0]);
|
||||
if (queue.length === 0 && nodeIds.length > 0) {
|
||||
level[nodeIds[0]] = 0;
|
||||
queue.push(nodeIds[0]);
|
||||
}
|
||||
|
||||
var head = 0;
|
||||
@@ -102,7 +103,7 @@ module.exports = function (register, lib) {
|
||||
|
||||
var colCount = {};
|
||||
var positions = {};
|
||||
ids.forEach(function (id) {
|
||||
nodeIds.forEach(function (id) {
|
||||
var lvl = level[id];
|
||||
if (lvl === undefined) {
|
||||
maxLevel++;
|
||||
@@ -110,11 +111,90 @@ module.exports = function (register, lib) {
|
||||
}
|
||||
var col = colCount[lvl] || 0;
|
||||
colCount[lvl] = col + 1;
|
||||
positions[id] = { level: lvl, col: col };
|
||||
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 ----
|
||||
function createSimulation(nodeList, edges) {
|
||||
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];
|
||||
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) {
|
||||
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) {
|
||||
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 };
|
||||
}
|
||||
|
||||
function el(tag, style, attrs) {
|
||||
var e = document.createElement(tag);
|
||||
if (style) Object.assign(e.style, style);
|
||||
@@ -124,15 +204,32 @@ module.exports = function (register, lib) {
|
||||
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;
|
||||
}
|
||||
|
||||
function render(data, target, theme) {
|
||||
target.innerHTML = "";
|
||||
Object.assign(target.style, {
|
||||
position: "relative",
|
||||
overflow: "auto",
|
||||
overflow: "hidden",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
boxSizing: "border-box",
|
||||
padding: PADDING + "px",
|
||||
});
|
||||
|
||||
var dark = theme === "dark";
|
||||
@@ -146,6 +243,7 @@ module.exports = function (register, lib) {
|
||||
fieldValue: "#d4d4d4",
|
||||
pointerValue: "#ce9178",
|
||||
edge: "#8a8a8a",
|
||||
viewportBg: "#1e1e1e",
|
||||
}
|
||||
: {
|
||||
border: "#b8b8b8",
|
||||
@@ -156,16 +254,10 @@ module.exports = function (register, lib) {
|
||||
fieldValue: "#1a1a1a",
|
||||
pointerValue: "#a31515",
|
||||
edge: "#6b6b6b",
|
||||
viewportBg: "#fafafa",
|
||||
};
|
||||
|
||||
var positions = layout(data.nodes, data.edges);
|
||||
var roots = data.roots || [];
|
||||
var topOffset = roots.length > 0 ? ROOT_ROW_HEIGHT : 0;
|
||||
|
||||
var byId = {};
|
||||
data.nodes.forEach(function (n) {
|
||||
byId[n.id] = n;
|
||||
});
|
||||
|
||||
var boxHeights = {};
|
||||
data.nodes.forEach(function (n) {
|
||||
@@ -173,57 +265,46 @@ module.exports = function (register, lib) {
|
||||
boxHeights[n.id] = HEADER_HEIGHT + Math.max(visibleRows, 1) * ROW_HEIGHT;
|
||||
});
|
||||
|
||||
var pixelPos = {};
|
||||
var maxX = 0;
|
||||
var maxY = 0;
|
||||
Object.keys(positions).forEach(function (id) {
|
||||
var p = positions[id];
|
||||
var x = p.level * (NODE_WIDTH + H_GAP);
|
||||
var y = topOffset + p.col * (Math.max(boxHeights[id], HEADER_HEIGHT + ROW_HEIGHT) + V_GAP);
|
||||
pixelPos[id] = { x: x, y: y };
|
||||
maxX = Math.max(maxX, x + NODE_WIDTH);
|
||||
maxY = Math.max(maxY, y + boxHeights[id]);
|
||||
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;
|
||||
});
|
||||
|
||||
// Spread root badges above the nodes they point to; unmatched roots
|
||||
// (e.g. a pointer into the middle of a struct, or a stale/null value)
|
||||
// queue up along the top-left instead.
|
||||
var rootPos = {};
|
||||
var nextUnmatchedX = 0;
|
||||
var badgesPerTarget = {};
|
||||
roots.forEach(function (r) {
|
||||
var target = pixelPos[r.value];
|
||||
var x;
|
||||
if (target) {
|
||||
var stackIndex = badgesPerTarget[r.value] || 0;
|
||||
badgesPerTarget[r.value] = stackIndex + 1;
|
||||
x = target.x + stackIndex * 90;
|
||||
} else {
|
||||
x = nextUnmatchedX;
|
||||
nextUnmatchedX += NODE_WIDTH / 2 + 20;
|
||||
}
|
||||
rootPos[r.name] = { x: x, y: 0, matched: !!target };
|
||||
maxX = Math.max(maxX, x + NODE_WIDTH);
|
||||
var sim = createSimulation(nodeList, data.edges);
|
||||
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 canvas = el("div", {
|
||||
position: "relative",
|
||||
width: maxX + "px",
|
||||
height: maxY + "px",
|
||||
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");
|
||||
svg.setAttribute("width", String(maxX));
|
||||
svg.setAttribute("height", String(maxY));
|
||||
Object.assign(svg.style, {
|
||||
position: "absolute",
|
||||
left: "0",
|
||||
top: "0",
|
||||
overflow: "visible",
|
||||
pointerEvents: "none",
|
||||
});
|
||||
|
||||
Object.assign(svg.style, { position: "absolute", left: "0", top: "0", overflow: "visible", pointerEvents: "none" });
|
||||
var defs = document.createElementNS(svgNs, "defs");
|
||||
var marker = document.createElementNS(svgNs, "marker");
|
||||
marker.setAttribute("id", "dv-arrow");
|
||||
marker.setAttribute("markerWidth", "8");
|
||||
@@ -235,35 +316,20 @@ module.exports = function (register, lib) {
|
||||
arrowPath.setAttribute("d", "M0,0 L8,4 L0,8 Z");
|
||||
arrowPath.setAttribute("fill", colors.edge);
|
||||
marker.appendChild(arrowPath);
|
||||
var defs = document.createElementNS(svgNs, "defs");
|
||||
defs.appendChild(marker);
|
||||
svg.appendChild(defs);
|
||||
world.appendChild(svg);
|
||||
|
||||
data.edges.forEach(function (e) {
|
||||
var from = pixelPos[e.from];
|
||||
var to = pixelPos[e.to];
|
||||
if (!from || !to) return;
|
||||
var x1 = from.x + NODE_WIDTH;
|
||||
var y1 = from.y + boxHeights[e.from] / 2;
|
||||
var x2 = to.x;
|
||||
var y2 = to.y + boxHeights[e.to] / 2;
|
||||
|
||||
var edgePaths = data.edges.map(function (e) {
|
||||
var line = document.createElementNS(svgNs, "path");
|
||||
var midX = (x1 + x2) / 2;
|
||||
line.setAttribute(
|
||||
"d",
|
||||
"M" + x1 + "," + y1 + " C " + midX + "," + y1 + " " + midX + "," + y2 + " " + x2 + "," + y2
|
||||
);
|
||||
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) {
|
||||
var text = document.createElementNS(svgNs, "text");
|
||||
text.setAttribute("x", String(midX));
|
||||
text.setAttribute("y", String((y1 + y2) / 2 - 4));
|
||||
text = document.createElementNS(svgNs, "text");
|
||||
text.setAttribute("fill", colors.edge);
|
||||
text.setAttribute("font-size", "11");
|
||||
text.setAttribute("font-family", "monospace");
|
||||
@@ -271,16 +337,13 @@ module.exports = function (register, lib) {
|
||||
text.textContent = e.label;
|
||||
svg.appendChild(text);
|
||||
}
|
||||
return { edge: e, line: line, text: text };
|
||||
});
|
||||
|
||||
canvas.appendChild(svg);
|
||||
|
||||
var boxes = {};
|
||||
data.nodes.forEach(function (n) {
|
||||
var pos = pixelPos[n.id];
|
||||
var box = el("div", {
|
||||
position: "absolute",
|
||||
left: pos.x + "px",
|
||||
top: pos.y + "px",
|
||||
width: NODE_WIDTH + "px",
|
||||
border: "1px solid " + colors.border,
|
||||
borderRadius: "6px",
|
||||
@@ -289,6 +352,7 @@ module.exports = function (register, lib) {
|
||||
fontFamily: "var(--vscode-editor-font-family, monospace)",
|
||||
fontSize: "12px",
|
||||
overflow: "hidden",
|
||||
cursor: "grab",
|
||||
});
|
||||
|
||||
var header = el("div", {
|
||||
@@ -304,15 +368,8 @@ module.exports = function (register, lib) {
|
||||
header.textContent = n.id;
|
||||
box.appendChild(header);
|
||||
|
||||
var body = el("div", {
|
||||
maxHeight: MAX_VISIBLE_ROWS * ROW_HEIGHT + "px",
|
||||
overflowY: "auto",
|
||||
});
|
||||
|
||||
var table = el("table", {
|
||||
borderCollapse: "collapse",
|
||||
width: "100%",
|
||||
});
|
||||
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");
|
||||
@@ -340,39 +397,22 @@ module.exports = function (register, lib) {
|
||||
|
||||
body.appendChild(table);
|
||||
box.appendChild(body);
|
||||
canvas.appendChild(box);
|
||||
world.appendChild(box);
|
||||
boxes[n.id] = box;
|
||||
});
|
||||
|
||||
roots.forEach(function (r) {
|
||||
var pos = rootPos[r.name];
|
||||
if (pos.matched) {
|
||||
var targetPos = pixelPos[r.value];
|
||||
var x1 = pos.x + NODE_WIDTH / 2;
|
||||
var y1 = ROOT_BADGE_HEIGHT;
|
||||
var x2 = targetPos.x + NODE_WIDTH / 2;
|
||||
var y2 = targetPos.y;
|
||||
var line = document.createElementNS(svgNs, "path");
|
||||
line.setAttribute("d", "M" + x1 + "," + y1 + " L " + x2 + "," + y2);
|
||||
line.setAttribute("fill", "none");
|
||||
line.setAttribute("stroke", colors.edge);
|
||||
line.setAttribute("stroke-width", "1.5");
|
||||
line.setAttribute("stroke-dasharray", "3,3");
|
||||
line.setAttribute("marker-end", "url(#dv-arrow)");
|
||||
svg.appendChild(line);
|
||||
}
|
||||
|
||||
var rootBadges = roots.map(function (r) {
|
||||
var matched = !!byId[r.value];
|
||||
var badge = el("div", {
|
||||
position: "absolute",
|
||||
left: pos.x + "px",
|
||||
top: "0px",
|
||||
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: pos.matched ? colors.headerBg : "transparent",
|
||||
color: pos.matched ? colors.headerFg : colors.pointerValue,
|
||||
background: matched ? colors.headerBg : "transparent",
|
||||
color: matched ? colors.headerFg : colors.pointerValue,
|
||||
fontFamily: "var(--vscode-editor-font-family, monospace)",
|
||||
fontSize: "12px",
|
||||
fontWeight: "bold",
|
||||
@@ -381,11 +421,268 @@ module.exports = function (register, lib) {
|
||||
textOverflow: "ellipsis",
|
||||
});
|
||||
badge.title = r.name + " = " + r.value;
|
||||
badge.textContent = r.name + (pos.matched ? "" : " (" + r.value + ")");
|
||||
canvas.appendChild(badge);
|
||||
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.edge);
|
||||
line.setAttribute("stroke-width", "1.5");
|
||||
line.setAttribute("stroke-dasharray", "3,3");
|
||||
line.setAttribute("marker-end", "url(#dv-arrow)");
|
||||
svg.appendChild(line);
|
||||
}
|
||||
return { root: 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 fitBtn = button("Fit", colors);
|
||||
var zoomOutBtn = button("−", colors);
|
||||
var zoomInBtn = button("+", colors);
|
||||
[autoBtn, layeredBtn, fitBtn, zoomOutBtn, zoomInBtn].forEach(function (b) {
|
||||
toolbar.appendChild(b);
|
||||
});
|
||||
target.appendChild(toolbar);
|
||||
|
||||
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() {
|
||||
if (nodeList.length === 0) return;
|
||||
var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
nodeList.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();
|
||||
});
|
||||
|
||||
target.appendChild(canvas);
|
||||
// ---- 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];
|
||||
box.style.left = n.x - NODE_WIDTH / 2 + "px";
|
||||
box.style.top = n.y - n.height / 2 + "px";
|
||||
});
|
||||
|
||||
edgePaths.forEach(function (ep) {
|
||||
var a = byId[ep.edge.from];
|
||||
var b = byId[ep.edge.to];
|
||||
if (!a || !b) 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 (rb.matched) {
|
||||
var target = byId[rb.root.value];
|
||||
var stackIndex = badgesPerTarget[rb.root.value] || 0;
|
||||
badgesPerTarget[rb.root.value] = 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.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);
|
||||
}
|
||||
|
||||
register({
|
||||
|
||||
Reference in New Issue
Block a user