Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion _includes/dependency-graph.html
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,4 @@
<div class="dependency-graph" data-graph="{{ include.slug | default: page.slug }}.graph.json"></div>
<p class="dg-json-link"><a href="{{ include.slug | default: page.slug }}.graph.json">Raw dependency graph data (JSON)</a></p>
<script src="https://cdn.jsdelivr.net/npm/d3@5.16.0/dist/d3.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dagre-d3@0.6.4/dist/dagre-d3.min.js"></script>
<script src="{{ '/assets/js/dependency-graph.js' | relative_url }}"></script>
23 changes: 23 additions & 0 deletions assets/css/dependency-graph.css
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,29 @@
opacity: 0.85;
}

/* Edges (see assets/js/dependency-graph.js buildForceLayout/draw). Plain SVG <line>s since the
force layout has no routed paths to follow, unlike the old dagre-d3 renderer. Kept light/thin
so a dense graph's edges read as texture rather than competing with the node labels. */
.dependency-graph .dg-edge {
fill: none;
opacity: 0.4;
}
.dependency-graph .dg-edge-explicit {
stroke: #ccc;
stroke-width: 1px;
}
.dependency-graph .dg-edge-implicit {
stroke: #ddd;
stroke-width: 1px;
stroke-dasharray: 4, 3;
}

/* Set on an edge connected to the hovered node (see highlight() in dependency-graph.js). Three
class selectors so it outranks .dg-edge-explicit/-implicit's stroke without needing !important. */
.dependency-graph .dg-edge.dg-edge-active {
stroke: #000;
}

.dependency-graph .dg-legend {
display: flex;
flex-wrap: wrap;
Expand Down
213 changes: 158 additions & 55 deletions assets/js/dependency-graph.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/*
* Renders <slug>.graph.json (see prompts/stack-report/stack-report.md, Artifact 4) as an
* interactive dependency graph: a top-to-bottom DAG (dagre-d3 + d3 v5, loaded via CDN in
* interactive dependency graph: a d3-force layout (d3 v5, loaded via CDN in
* _includes/dependency-graph.html -- no build step), with zoom/pan, click-a-node to focus its
* ancestor+descendant subgraph, hover to highlight neighbors, a search box, and a legend.
*
Expand Down Expand Up @@ -116,28 +116,80 @@
return tip;
}

function buildDagreGraph(ds, showExternal) {
var g = new dagreD3.graphlib.Graph({ directed: true })
.setGraph({ nodesep: 40, ranksep: 70, rankdir: 'TB' })
.setDefaultEdgeLabel(function () { return {}; });
// ---- node sizing (a plain <canvas> text measurement, since there is no DOM layout pass
// before the force simulation needs a collision radius for every node) --------------------

var NODE_FONT = '12px Helvetica, Arial, sans-serif';
var NODE_H = 30;
var NODE_PAD_X = 12;
var measureCanvas = null;
function textWidth(text) {
if (!measureCanvas) measureCanvas = document.createElement('canvas');
var ctx = measureCanvas.getContext('2d');
ctx.font = NODE_FONT;
return ctx.measureText(text).width;
}
function nodeSize(name) {
return { w: Math.max(textWidth(name) + NODE_PAD_X * 2, 44), h: NODE_H };
}

// Where a line from a node's center towards (x2,y2) exits that node's rectangle -- used so an
// edge's arrowhead lands on the target's border instead of being buried at its center.
function clipToRectBorder(x1, y1, x2, y2, w, h) {
var dx = x1 - x2, dy = y1 - y2;
if (dx === 0 && dy === 0) return { x: x2, y: y2 };
var hw = w / 2, hh = h / 2;
var scale = Math.min(
dx !== 0 ? hw / Math.abs(dx) : Infinity,
dy !== 0 ? hh / Math.abs(dy) : Infinity
);
return { x: x2 + dx * scale, y: y2 + dy * scale };
}

ds.allNodeIds.forEach(function (id) {
// Runs a d3-force simulation (link + charge + collision + weak centering) to completion
// synchronously (a fixed number of ticks, no animation), then returns plain layout data --
// node positions/sizes and resolved links -- for the caller to draw. Re-run on every redraw
// (selection change, external-dependency toggle) since the visible node set can change.
function buildForceLayout(ds, showExternal) {
var visibleIds = ds.allNodeIds.filter(function (id) {
var n = ds.nodeMap[id].data;
if (!showExternal && n.in_scope === false) return;
g.setNode(id, { label: n.name, rx: 5, ry: 5, padding: 10, class: nodeClass(n) });
return showExternal || n.in_scope !== false;
});
var visibleSet = new Set(visibleIds);

var nodes = visibleIds.map(function (id) {
var n = ds.nodeMap[id].data;
var size = nodeSize(n.name);
return { id: id, data: n, w: size.w, h: size.h };
});

var links = [];
var connected = new Set();
Object.keys(ds.edgeMap).forEach(function (eid) {
var e = ds.edgeMap[eid];
if (!g.hasNode(e.source) || !g.hasNode(e.target)) return;
g.setEdge(e.source, e.target, {
edgeId: eid,
style: e.type === 'implicit'
? 'stroke: #888; stroke-width: 1.5px; stroke-dasharray: 4,3; fill: none;'
: 'stroke: #555; stroke-width: 1.5px; fill: none;',
arrowheadStyle: 'fill: #555;',
});
if (!visibleSet.has(e.source) || !visibleSet.has(e.target)) return;
links.push({ edgeId: eid, type: e.type, source: e.source, target: e.target });
connected.add(e.source);
connected.add(e.target);
});
return g;

// A node with no edges at all (rare, but real -- e.g. a leaf with no discovered
// dependencies) has nothing else pulling it back in, so charge repulsion alone flings it
// arbitrarily far from everything else. Only isolated nodes get this pull-to-center spring,
// so it never fights "spread out more" for anything that actually has a link.
function isolatedStrength(d) { return connected.has(d.id) ? 0 : 0.3; }

var simulation = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(function (d) { return d.id; }).distance(130).strength(0.15))
.force('charge', d3.forceManyBody().strength(-520))
.force('collide', d3.forceCollide().radius(function (d) { return Math.hypot(d.w, d.h) / 2 + 16; }).iterations(2))
.force('center', d3.forceCenter(0, 0))
.force('x', d3.forceX(0).strength(isolatedStrength))
.force('y', d3.forceY(0).strength(isolatedStrength))
.stop();
for (var i = 0; i < 400; i++) simulation.tick();

return { nodes: nodes, links: links };
}

// ---- one graph instance per container ---------------------------------------------------
Expand All @@ -154,7 +206,7 @@
'<input type="text" class="dg-search" placeholder="Search node...">' +
'<div class="dg-search-results" hidden></div>' +
'</div>' +
'<label class="dg-toggle"><input type="checkbox" class="dg-toggle-external" checked> Show external dependencies</label>' +
'<label class="dg-toggle"><input type="checkbox" class="dg-toggle-external"> Show external dependencies</label>' +
'<button type="button" class="dg-reset" hidden>Reset view</button>' +
'</div>' +
'<div class="dg-canvas"><svg></svg></div>' +
Expand All @@ -168,6 +220,27 @@
renderLegend(wrap.querySelector('.dg-legend'));

var svg = d3.select(wrap.querySelector('svg'));
var defs = svg.append('defs');
// A <marker>'s fill/opacity is fixed by its own definition, not by the CSS opacity of the
// <line> that references it via marker-end -- so a hovered/highlighted edge (whose line the
// "highlight" function below brings to full opacity) needs a second, fully-opaque marker to
// switch to, or its arrowhead stays faint even while the line itself is highlighted.
function defineArrowhead(id, fill, fillOpacity) {
defs.append('marker')
.attr('id', id)
.attr('viewBox', '0 -5 10 10')
.attr('refX', 9)
.attr('refY', 0)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M0,-5L10,0L0,5')
.attr('fill', fill)
.attr('fill-opacity', fillOpacity);
}
defineArrowhead('dg-arrowhead', '#ccc', 0.7);
defineArrowhead('dg-arrowhead-active', '#000', 1);
var svgGroup = svg.append('g');
var canvas = wrap.querySelector('.dg-canvas');
var searchInput = wrap.querySelector('.dg-search');
Expand All @@ -190,18 +263,31 @@
var fullDs = buildGraphDataStructure(graphJson);
var selected = null;

function fitToView(g) {
var gw = g.graph().width || 100, gh = g.graph().height || 100;
function fitToView(layout) {
var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
layout.nodes.forEach(function (n) {
minX = Math.min(minX, n.x - n.w / 2); maxX = Math.max(maxX, n.x + n.w / 2);
minY = Math.min(minY, n.y - n.h / 2); maxY = Math.max(maxY, n.y + n.h / 2);
});
if (!isFinite(minX)) { minX = 0; maxX = 100; minY = 0; maxY = 100; }
var gw = (maxX - minX) || 100, gh = (maxY - minY) || 100;
var cw = canvas.clientWidth || 600, ch = canvas.clientHeight || 500;
var scale = Math.min(cw / gw, ch / gh, 1) * 0.9;
var tx = (cw - gw * scale) / 2, ty = (ch - gh * scale) / 2;
var tx = cw / 2 - ((minX + maxX) / 2) * scale;
var ty = ch / 2 - ((minY + maxY) / 2) * scale;
svg.call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
}

function highlight(ds, nodeId) {
if (!nodeId) {
svgGroup.selectAll('g.node').style('opacity', 1);
svgGroup.selectAll('g.edgePath').style('opacity', 1);
svgGroup.selectAll('g.dg-node').style('opacity', 1);
// Clear the inline opacity override entirely (not reset to 1) so the CSS baseline
// opacity (.dg-edge, dependency-graph.css) takes back over; setting it to 1 here
// would leave every edge permanently at full opacity after the first hover.
svgGroup.selectAll('line.dg-edge')
.style('opacity', null)
.classed('dg-edge-active', false)
.attr('marker-end', 'url(#dg-arrowhead)');
return;
}
var n = ds.nodeMap[nodeId];
Expand All @@ -210,55 +296,72 @@
var relatedEdges = new Set();
(n.incoming || []).forEach(function (eid) { relatedEdges.add(eid); related.add(ds.edgeMap[eid].source); });
(n.outgoing || []).forEach(function (eid) { relatedEdges.add(eid); related.add(ds.edgeMap[eid].target); });
svgGroup.selectAll('g.node').style('opacity', function (id) { return related.has(id) ? 1 : 0.15; });
svgGroup.selectAll('g.edgePath').style('opacity', function () {
var eid = d3.select(this).attr('data-edge-id');
return relatedEdges.has(eid) ? 1 : 0.1;
});
svgGroup.selectAll('g.dg-node').style('opacity', function (d) { return related.has(d.id) ? 1 : 0.15; });
svgGroup.selectAll('line.dg-edge')
.style('opacity', function (d) { return relatedEdges.has(d.edgeId) ? 1 : 0.1; })
.classed('dg-edge-active', function (d) { return relatedEdges.has(d.edgeId); })
.attr('marker-end', function (d) { return relatedEdges.has(d.edgeId) ? 'url(#dg-arrowhead-active)' : 'url(#dg-arrowhead)'; });
}

function draw() {
var ds = focusedSubgraph(selected, fullDs);
var showExternal = externalToggle.checked;
var g = buildDagreGraph(ds, showExternal);
var layout = buildForceLayout(ds, showExternal);

svgGroup.selectAll('*').remove();
var renderFn = new dagreD3.render();
renderFn(svgGroup, g);

// Correlate rendered edgePath groups back to our edge ids by render order (dagre-d3
// renders g.edges() in order), so hover-highlight can look edges up by id.
var edgeObjs = g.edges();
svgGroup.selectAll('g.edgePath').each(function (d, i) {
var edgeObj = edgeObjs[i];
if (!edgeObj) return;
var label = g.edge(edgeObj);
d3.select(this).attr('data-edge-id', label.edgeId);
});

svgGroup.selectAll('g.node')
.attr('data-node-id', function (id) { return id; })
.style('cursor', 'pointer')
.each(function (id) {
d3.select(this).append('title').text(nodeTooltip(ds.nodeMap[id].data));
})
.on('mouseenter', function (id) { highlight(ds, id); })
var edgeG = svgGroup.append('g').attr('class', 'edgePaths');
var nodeG = svgGroup.append('g').attr('class', 'nodes');

edgeG.selectAll('line.dg-edge')
.data(layout.links)
.enter().append('line')
.attr('class', function (d) { return 'dg-edge dg-edge-' + d.type; })
.attr('data-edge-id', function (d) { return d.edgeId; })
.attr('x1', function (d) { return d.source.x; })
.attr('y1', function (d) { return d.source.y; })
.attr('x2', function (d) { return clipToRectBorder(d.source.x, d.source.y, d.target.x, d.target.y, d.target.w, d.target.h).x; })
.attr('y2', function (d) { return clipToRectBorder(d.source.x, d.source.y, d.target.x, d.target.y, d.target.w, d.target.h).y; })
.attr('marker-end', 'url(#dg-arrowhead)');

var nodeSel = nodeG.selectAll('g.dg-node')
.data(layout.nodes, function (d) { return d.id; })
.enter().append('g')
.attr('class', function (d) { return nodeClass(d.data); })
.attr('data-node-id', function (d) { return d.id; })
.attr('transform', function (d) { return 'translate(' + d.x + ',' + d.y + ')'; })
.style('cursor', 'pointer');

nodeSel.append('rect')
.attr('x', function (d) { return -d.w / 2; })
.attr('y', function (d) { return -d.h / 2; })
.attr('width', function (d) { return d.w; })
.attr('height', function (d) { return d.h; })
.attr('rx', 5).attr('ry', 5);

nodeSel.append('text')
.attr('text-anchor', 'middle')
.attr('dy', '0.32em')
.text(function (d) { return d.data.name; });

nodeSel.append('title').text(function (d) { return nodeTooltip(d.data); });

nodeSel
.on('mouseenter', function (d) { highlight(ds, d.id); })
.on('mouseleave', function () { highlight(ds, null); })
.on('click', function (id) {
.on('click', function (d) {
d3.event.stopPropagation();
var n = ds.nodeMap[id].data;
if (selected === id) {
if (selected === d.id) {
// Always the project-report URL, even if that page doesn't exist yet (404) --
// never silently fall back to repo/home.
if (n.report) window.open(resolveReportUrl(n.report), '_blank');
if (d.data.report) window.open(resolveReportUrl(d.data.report), '_blank');
return;
}
selected = id;
selected = d.id;
resetBtn.hidden = false;
draw();
});

fitToView(g);
fitToView(layout);
}

svg.on('click', function () {
Expand Down
Loading
Loading