Skip to content

Instantly share code, notes, and snippets.

@isaacplmann
Last active January 3, 2022 07:19
Show Gist options
  • Save isaacplmann/a20dfe530b83dd0db3b022123040b981 to your computer and use it in GitHub Desktop.
Save isaacplmann/a20dfe530b83dd0db3b022123040b981 to your computer and use it in GitHub Desktop.
D3 Diagram with left/right aligned links

Dependency Diagram

  • Nodes have calculated width/height based on text content
  • Groups can be expanded/collapsed by clicking on them
  • Node links can be left or right aligned
  • Tooltips appear when clicking on a node or a link
#chart text {
fill: white;
font: 10px Helvetica;
text-anchor: end;
}
line {
/*stroke: black;*/
}
line:first-child.notify,
line:first-child.precheck,
line:first-child.recover {
stroke-dasharray: 5, 5;
}
line:first-child {
marker-end: url(#markerArrow);
}
line:first-child.reverse {
marker-end: none;
marker-start: url(#markerArrowReverse);
}
line:first-child.notify {
marker-end: url(#markerSquare);
}
line:first-child.notify.reverse {
marker-end: none;
marker-start: url(#markerSquareReverse);
}
line:first-child.recover {
marker-end: url(#markerCircle);
}
line:first-child.recover.reverse {
marker-end: none;
marker-start: url(#markerCircleReverse);
}
.single > g,
.single > line,
.single > path,
.hull > rect,
.hull > text {
display:none;
}
.d3-tip {
line-height: 1;
font-weight: bold;
padding: 12px;
background: rgba(0, 0, 0, 0.8);
color: #fff;
border-radius: 2px;
}
/* Creates a small triangle extender for the tooltip */
.d3-tip:after {
box-sizing: border-box;
display: inline;
font-size: 10px;
width: 100%;
line-height: 1;
color: rgba(0, 0, 0, 0.8);
content: "\25BC";
position: absolute;
text-align: center;
}
/* Style northward tooltips differently */
.d3-tip.n:after {
margin: -1px 0 0 0;
top: 100%;
left: 0;
}
var data = {
"nodes": [
{"name": "main", expanded: true, fixed: true, type: 'job', childNodes: [
{"name": "Job 1", type: 'job'},
{"name": "Job 2.1", type: 'job'},
{"name": "Job 2.2", type: 'job'},
{"name": "Job 3", type: 'job'},
], childLinks: [
{"source": 0, "target": 1, type: 'step'},
{"source": 0, "target": 2, type: 'step'},
{"source": 1, "target": 3, type: 'step'},
{"source": 2, "target": 3, type: 'step'},
]},
{"name": "Job 4", type: 'job'},
{"name": "Job 5", type: 'job'},
{"name": "Job 6", type: 'job'},
{"name": "Job 7", type: 'job'},
{"name": "Job 8", type: 'job'},
{"name": "Job 9", type: 'job'},
{"name": "Job 10", type: 'job'},
{"name": "Job 11", type: 'job'},
{"name": "Resource", type: 'resource'},
{"name": "Variable", type: 'variable'},
{"name": "File", type: 'file'},
],
"links": [
{"source": 0, "target": 1, type: 'dependency', reverseArrow: true},
{"source": 0, "target": 2, type: 'precheck', reverseArrow: true},
{"source": 0, "target": 3, type: 'notify'},
{"source": 0, "target": 4, type: 'recover'},
{"source": 0, "target": 5, type: 'notify', reverseDirection: true},
{"source": 0, "target": 6, type: 'recover', reverseDirection: true},
{"source": 0, "target": 7, type: 'precheck', reverseArrow: true, reverseDirection: true},
{"source": 0, "target": 8, type: 'dependency', reverseArrow: true, reverseDirection: true},
{"source": 0, "target": 9, type: 'dependency', reverseArrow: true, reverseDirection: true},
{"source": 0, "target": 10, type: 'dependency', reverseArrow: true, reverseDirection: true},
{"source": 0, "target": 11, type: 'dependency', reverseArrow: true, reverseDirection: true},
]
}
var tip = d3.tip()
.attr('class', 'd3-tip')
.offset([-10, 0])
.html(function(d) {
return `<strong>Name:</strong> <span style='color:red'>${d.name}</span><br><br>
<button>Details</button> <button>Diagram</button>`;
});
var lineTip = d3.tip()
.attr('class', 'd3-tip')
.offset([-10, 0])
.html(function(d) {
return `<strong>Type:</strong> <span style='color:red'>${d.type}</span>`;
});
var width = 500,
height = 500,
padding = 10,
off = 15,
nodeHeight = 10,
expand = {}, // expanded clusters
net, hullg, hull, linkg, link, nodeg, node;
function initializeNodeData(d, isChild) {
d.x = (isChild ? 0 : width/2) + Math.random();
d.y = (isChild ? 0 : height/2) + Math.random();
d.width = d.name.length;
if (d.childNodes) {
d.childNodes.forEach(function(child) {initializeNodeData(child, true)});
if (d.childLinks) {
initializeDepth(d.childLinks, d.childNodes);
}
}
}
data.nodes.forEach(initializeNodeData);
function initializeDepth(links, nodes) {
while (assignDepths(links, nodes)) {}
var minDepth, maxDepth;
nodes.forEach(function(node) {
if (node.depth !== undefined &&
(minDepth === undefined || node.depth < minDepth)) {
minDepth = node.depth;
}
if (node.depth !== undefined &&
(maxDepth === undefined || node.depth > maxDepth)) {
maxDepth = node.depth;
}
});
var averageDepth = Math.floor((minDepth + maxDepth)/2);
nodes.forEach(function(node) {
node.depth -= averageDepth;
node.depth = Math.abs(node.depth);
});
}
function assignDepths(links, nodes) {
var updated = [];
links.forEach(function(link) {
var source = nodes[link.source],
target = nodes[link.target],
dir = link.reverseDirection ? -1 : 1;
if (source.depth === undefined) {
source.depth = (target.depth === undefined) ? 0 : target.depth + dir;
updated.push(link.source);
}
if (target.depth === undefined) {
target.depth = source.depth + dir;
updated.push(link.target);
}
if (target.depth !== source.depth + dir) {
if (updated.indexOf(link.target) === -1) {
target.depth = source.depth + dir;
updated.push(link.target);
} else if (updated.indexOf(link.source) === -1) {
source.depth = target.depth - dir;
updated.push(link.source);
}
}
});
return updated.length > 0;
}
initializeDepth(data.links, data.nodes);
var fill = d3.scale.category20(); // d3.scaleOrdinal(d3.schemeCategory20);
var force = d3.layout.force()
.charge(-400)
.friction(0.1)
.size([width, height]);
var curve = d3.svg.line()
.interpolate("cardinal-closed")
.tension(.85);
var svg = d3.select("#chart")
.attr("width", width)
.attr("height", height)
.call(lineTip)
.call(tip);
var link = svg.selectAll("line")
.data(data.links)
.enter().append("g");
link.append('line')
.style('stroke', 'black')
.attr('class', function(d) { return d.type + (d.reverseArrow ? ' reverse' : '') })
link.append('line')
.style('stroke-width', 10)
.style('stroke', 'transparent')
.on('click', function(d) {
if (d3.event.defaultPrevented) return; // click suppressed
tip.hide();
lineTip.show(d);
event.stopPropagation();
})
// .on('mouseout', lineTip.hide)
;
var node = svg.selectAll("g.node")
.data(data.nodes)
.enter().append("g")
.style("fill", function(d) { return fill(d.type); })
.style("stroke", function(d) { return d3.rgb(fill(d.type)).darker(); });
node.filter(function(d){ return d.childNodes !== undefined})
.on('click', function(d) { if (d3.event.defaultPrevented) return; d.expanded = !d.expanded; update() });
node.filter(function(d) { return !d.childNodes})
.on('click', function(d) {
if (d3.event.defaultPrevented) return; // click suppressed
lineTip.hide();
tip.show(d);
event.stopPropagation();
})
.call(force.drag);
svg.on('click', function() {
lineTip.hide();
tip.hide();
});
function update() {
node.attr('class', function(d) { return d.childNodes && d.expanded ? 'node hull' : 'node single' });
node
.selectAll('.hull > rect')
.attr('x', function(d) { return -d.width/2 })
.attr("width", function(d) { return d.width })
;
force.start();
}
var hull = node.filter(function(d){ return d.childNodes !== undefined;});
hull.append('path')
.style("fill", function(d) { return d3.rgb(fill(d.type)).brighter(0.8); });
hull.forEach(function(el) {
if (!el[0])
return;
var d = el[0].__data__,
subForce = d3.layout.force().charge(-200);
var subLink = d3.select(el[0]).selectAll("line")
.data(d.childLinks)
.enter().append("g");
subLink.append('line')
.style('stroke', 'black')
.attr('class', function(d) { return d.type + (d.reverseArrow ? ' reverse' : '') });
subLink.append('line')
.style('stroke-width', 10)
.style('stroke', 'transparent')
.on('click', function(d) {
tip.hide();
lineTip.show(d);
event.stopPropagation();
})
// .on('mouseout', lineTip.hide)
var subNode = d3.select(el[0]).selectAll('g.node.'+d.name)
.data(d.childNodes)
.enter().append("g")
.attr('class', 'node single '+d.name)
.style("fill", function(d) { return fill(d.type); })
.style("stroke", function(d) { return d3.rgb(fill(d.type)).darker(); })
.call(subForce.drag)
.on('click', function(d) {
lineTip.hide();
tip.show(d);
event.stopPropagation();
});
subNode.append('rect')
.attr('y', '-0.4em')
.attr("height", '1em');
subNode.append('text')
.text(function(d) { return d.name })
.attr('dy', '0.5em')
.attr('dx', function(d) { d.width = this.clientWidth + padding; d.height = 16; return this.clientWidth/2 })
.style("stroke", function(d) { return d3.rgb(fill(d.type)).darker(3); })
;
subNode.select('rect')
.attr('x', function(d) { return -d.width/2 })
.attr("width", function(d) { return d.width })
;
subForce
.nodes(d.childNodes)
.links(d.childLinks)
.on("tick", function(e) {
tick(e, d.childNodes, subForce, subNode, subLink, undefined, d);
force.start();
force.alpha(e.alpha);
})
.start();
});/**/
node
.append('rect')
.attr('y', '-0.4em')
.attr("height", '1em')
;
node.append('text')
.text(function(d) { return d.name })
.attr('dy', '0.5em')
.attr('dx', function(d) { d.width = this.clientWidth + padding; d.height = nodeHeight + padding; return this.clientWidth/2 })
.style("stroke", function(d) { return d3.rgb(fill(d.type)).darker(3); })
;
node
.select('rect')
.attr('x', function(d) { return -d.width/2 })
.attr("width", function(d) { return d.width })
;
force
.nodes(data.nodes)
.links(data.links)
.on("tick", function(e) {
tick(e, data.nodes, force, node, link, {
xmax: width,
xmin: 0,
ymax: height,
ymin: 0,
})
});
update();
function linkLength(link) {
return link.source.width/2 + link.target.width/2 + 20;
}
function linkStart(link) {
return link.reverseDirection ? rectIntersection(link.target, link.source) : rectIntersection(link.source, link.target);
}
function linkEnd(link) {
return link.reverseDirection ? rectIntersection(link.source, link.target) : rectIntersection(link.target, link.source);
}
function rectIntersection(rect, point) {
var halfWidth = rect.width/2, halfHeight = rect.height/2;
return pointOnRect(point.x, point.y, rect.x - halfWidth, rect.y - halfHeight, rect.x + halfWidth, rect.y + halfHeight);
}
function rectRight(rect) {
return {
x: rect.x + rect.width/2,
y: rect.y,
};
}
function rectLeft(rect) {
return {
x: rect.x - rect.width/2,
y: rect.y,
};
}
/**
* Finds the intersection point between
* * the rectangle
* with parallel sides to the x and y axes
* * the half-line pointing towards (x,y)
* originating from the middle of the rectangle
*
* Note: the function works given min[XY] <= max[XY],
* even though minY may not be the "top" of the rectangle
* because the coordinate system is flipped.
*
* @param (x,y):Number point to build the line segment from
* @param minX:Number the "left" side of the rectangle
* @param minY:Number the "top" side of the rectangle
* @param maxX:Number the "right" side of the rectangle
* @param maxY:Number the "bottom" side of the rectangle
* @param check:boolean (optional) whether to treat point inside the rect as error
* @return an object with x and y members for the intersection
* @throws if check == true and (x,y) is inside the rectangle
* @author TWiStErRob
* @see <a href="http://stackoverflow.com/a/31254199/253468">source</a>
* @see <a href="http://stackoverflow.com/a/18292964/253468">based on</a>
*/
function pointOnRect(x, y, minX, minY, maxX, maxY, check) {
//assert minX <= maxX;
//assert minY <= maxY;
if (check && (minX <= x && x <= maxX) && (minY <= y && y <= maxY))
throw "Point " + [x,y] + "cannot be inside "
+ "the rectangle: " + [minX, minY] + " - " + [maxX, maxY] + ".";
var midX = (minX + maxX) / 2;
var midY = (minY + maxY) / 2;
// if (midX - x == 0) -> m == ±Inf -> minYx/maxYx == x (because value / ±Inf = ±0)
var m = (midY - y) / (midX - x);
if (x <= midX) { // check "left" side
var minXy = m * (minX - x) + y;
if (minY < minXy && minXy < maxY)
return {x: minX, y: minXy};
}
if (x >= midX) { // check "right" side
var maxXy = m * (maxX - x) + y;
if (minY < maxXy && maxXy < maxY)
return {x: maxX, y: maxXy};
}
if (y <= midY) { // check "top" side
var minYx = (minY - y) / m + x;
if (minX < minYx && minYx < maxX)
return {x: minYx, y: minY};
}
if (y >= midY) { // check "bottom" side
var maxYx = (maxY - y) / m + x;
if (minX < maxYx && maxYx < maxX)
return {x: maxYx, y: maxY};
}
// Should never happen :) If it does, please tell me!
throw "Cannot find intersection for " + [x,y]
+ " inside rectangle " + [minX, minY] + " - " + [maxX, maxY] + ".";
}
function getGroup(n) {
return n.group;
}
function convexHulls(nodes, index, offset) {
var hulls = {};
// create point sets
for (var k=0; k<nodes.length; ++k) {
var n = nodes[k];
var i = index(n);
if (i === undefined) continue;
var l = hulls[i] || (hulls[i] = []);
l.push([n.x - (n.width/2 + offset), n.y - (nodeHeight/2 + offset)]);
l.push([n.x - (n.width/2 + offset), n.y + (nodeHeight/2 + offset)]);
l.push([n.x + (n.width/2 + offset), n.y - (nodeHeight/2 + offset)]);
l.push([n.x + (n.width/2 + offset), n.y + (nodeHeight/2 + offset)]);
}
// create convex hulls
var hullset = [];
for (i in hulls) {
hullset.push({group: i, path: d3.geom.hull(hulls[i])});
}
return hullset;
}
function drawCluster(d) {
var hulls = convexHulls(d.childNodes, function(){return 'group'}, off);
if (hulls.length === 0)
return;
return curve(hulls[0].path); // 0.8
}
function collide(node) {
return function(quad, x1, y1, x2, y2) {
var updated = false;
if (quad.point && (quad.point !== node) && node.group === quad.point.group) {
var x = node.x - quad.point.x,
y = node.y - quad.point.y,
xSpacing = (quad.point.width + node.width) / 2,
ySpacing = (quad.point.height + node.height) / 2,
absX = Math.abs(x),
absY = Math.abs(y),
l,
lx,
ly;
if (absX < xSpacing && absY < ySpacing) {
l = Math.sqrt(x * x + y * y);
lx = (absX - xSpacing) / l;
ly = (absY - ySpacing) / l;
// the one that's barely within the bounds probably triggered the collision
if (Math.abs(lx) > Math.abs(ly)) {
lx = 0;
} else {
ly = 0;
}
node.x -= x *= lx;
node.y -= y *= ly;
quad.point.x += x;
quad.point.y += y;
updated = true;
}
}
return updated;
};
}
function tick(e, nodes, force, node, link, bounds, parent) {
var k = 10 * e.alpha;
var q = d3.geom.quadtree(nodes),
i = 0,
nodesCount = nodes.length;
// if (!hull.empty()) {
// hull.data(convexHulls(force.nodes(), getGroup, off))
// .attr("d", drawCluster);
// }
while (++i < nodesCount) q.visit(collide(nodes[i]));
force.links().forEach(function(link) {
var r = (1-link.source.depth)*linkLength(link), dx, dy, lx = linkLength(link),
dir = link.reverseDirection ? -1 : 1;
// #1: constraint all nodes to the visible screen:
//d.x = Math.min(width - r, Math.max(r, d.x));
//d.y = Math.min(height - r, Math.max(r, d.y));
// Attempt at weak left and right alignment
// link.target.px -= dir * (link.source.depth * lx + r) * k / 4;
// #1.0: hierarchy: same level nodes have to remain with a 1 LX band vertically:
var px = link.source.x;
link.target.px = link.target.x = px + dir * (link.source.depth * lx + r);
// #2: hierarchy means targets must be to the right of sources in X direction:
if (link.reverseDirection) {
link.target.x = Math.min(link.target.x, link.source.x - lx);
link.target.px = Math.min(link.target.px, link.source.px - lx)/2;
} else {
link.target.x = Math.max(link.target.x, link.source.x + lx);
link.target.px = Math.max(link.target.px, link.source.px + lx)/2;
}
});
if (bounds) {
force.nodes().forEach(function(d) {
var lx = 30;
// #1a: constraint all nodes to the visible screen: links
dx = Math.min(0, bounds.xmax - d.width/2 - d.x) + Math.max(0, d.width/2 - d.x - bounds.xmin);
dy = Math.min(0, bounds.ymax - nodeHeight - d.y) + Math.max(0, nodeHeight - d.y - bounds.ymin);
d.x += 2 * Math.max(-lx, Math.min(lx, dx));
d.y += 2 * Math.max(-lx, Math.min(lx, dy));
// #1b: constraint all nodes to the visible screen: charges ('repulse')
dx = Math.min(0, bounds.xmax - d.width/2 - d.px) + Math.max(0, d.width/2 - d.px - bounds.xmin);
dy = Math.min(0, bounds.ymax - nodeHeight - d.py) + Math.max(0, nodeHeight - d.py - bounds.ymin);
d.px += 2 * Math.max(-lx, Math.min(lx, dx));
d.py += 2 * Math.max(-lx, Math.min(lx, dy));
});
}
link
.selectAll('line')
.attr("x1", function(d) { return linkStart(d).x; })
.attr("y1", function(d) { return linkStart(d).y; })
.attr("x2", function(d) { return linkEnd(d).x; })
.attr("y2", function(d) { return linkEnd(d).y; });
node
.filter(function(d){
if(d.childNodes !== undefined && d.childNodes.length > 0) {
// Move cluster based on average of child nodes
var xsum = 0, ysum = 0;
d.childNodes.forEach(function(child) {
xsum += child.x;
ysum += child.y;
});
var xave = xsum/d.childNodes.length,
yave = ysum/d.childNodes.length;
// d.x += xave;
d.y += yave;
// d.px += xave;
d.py += yave * k / 500;
d.childNodes.forEach(function(child) {
child.x -= xave;
child.y -= yave;
child.px -= xave * k / 500;
child.py -= yave * k / 500;
});
d.width = this.getBBox().width;
d.height = this.getBBox().height;
return true;
}
return false;
})
.select('path')
.attr('d', drawCluster)
;
node
// .filter(function(d){ return d.groupNode === undefined;})
.attr('transform', function(d) { return 'translate(' + d.x + ',' + d.y + ')'; })
;
}
<!DOCTYPE html>
<html>
<head>
<title>D3 Diagram</title>
<link rel="stylesheet" href="diagram.css">
<script src="//d3js.org/d3.v3.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/d3-tip/0.6.7/d3-tip.min.js"></script>
</head>
<body>
<svg id="chart">
<defs>
<marker id="markerSquare" markerWidth="8" markerHeight="8" refX="8" refY="4"
orient="auto">
<rect x="0" y="0" width="8" height="8" style="stroke: none; fill:#000000;"/>
</marker>
<marker id="markerSquareReverse" markerWidth="8" markerHeight="8" refX="0" refY="4"
orient="auto">
<rect x="0" y="0" width="8" height="8" style="stroke: none; fill:#000000;"/>
</marker>
<marker id="markerCircle" markerWidth="8" markerHeight="8" refX="8" refY="4"
orient="auto">
<circle cx="4" cy="4" r="4" style="stroke: none; fill:#000000;"/>
</marker>
<marker id="markerCircleReverse" markerWidth="8" markerHeight="8" refX="0" refY="4"
orient="auto">
<circle cx="4" cy="4" r="4" style="stroke: none; fill:#000000;"/>
</marker>
<marker id="markerArrow" markerWidth="13" markerHeight="13" refX="8" refY="7"
orient="auto">
<path d="M2,2 L2,13 L8,7 L2,2" />
</marker>
<marker id="markerArrowReverse" markerWidth="13" markerHeight="13" refX="2" refY="7"
orient="auto">
<path d="M2,7 L8,13 L8,2 L2,7" />
</marker>
</defs>
</svg>
<script src="./diagram.js"></script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment