Skip to content

Instantly share code, notes, and snippets.

@benoitguigal
Last active April 27, 2023 14:07
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save benoitguigal/e11a791079318b7ff6ecde9a6464801d to your computer and use it in GitHub Desktop.
Save benoitguigal/e11a791079318b7ff6ecde9a6464801d to your computer and use it in GitHub Desktop.
Force Directed Graph with smooth transitions
license: gpl-3.0
height: 600

The force directed graph uses web workers, d3 update pattern and d3 transitions to modify the layout of the graph when nodes are added/removed. It makes it easy to keep track of the positions of the nodes as the shape of the layout evolves.

This pattern is used in graph-explorer, an open source project that allows exploring complex graphs of money transactions.

importScripts("https://d3js.org/d3-collection.v1.min.js");
importScripts("https://d3js.org/d3-dispatch.v1.min.js");
importScripts("https://d3js.org/d3-quadtree.v1.min.js");
importScripts("https://d3js.org/d3-timer.v1.min.js");
importScripts("https://d3js.org/d3-force.v1.min.js");
onmessage = function(event) {
var nodes = event.data.nodes,
links = event.data.links,
center = event.data.center;
var simulation = d3.forceSimulation(nodes)
.force("link", d3.forceLink(links).id(d => d.id))
.force("charge", d3.forceManyBody())
.force("center", d3.forceCenter(center.x, center.y))
.stop();
for (var i = 0, n = Math.ceil(Math.log(simulation.alphaMin()) / Math.log(1 - simulation.alphaDecay())); i < n; ++i) {
simulation.tick();
}
postMessage({nodes: nodes, links: links});
};
<!DOCTYPE html>
<head>
<meta charset="utf-8">
<style>
.link {
stroke: black;
stroke-width: 1;
}
.node {
fill: #27AE60;
}
</style>
</head>
<body>
<svg id="svg" width="960" height="600">
<defs></defs>
<g class="graph">
<g class="arrowheads"></g>
<g class="links"></g>
<g class="nodes"></g>
</g>
</svg>
<script src="https://d3js.org/d3.v4.min.js"></script>
<script>
// Store common selections into a variable
var select = {
svg: d3.select("svg"),
graph: d3.select(".graph"),
arrowheads: d3.select('defs').selectAll('.arrowhead'),
links: d3.select(".links").selectAll(".link"),
circles: d3.select(".nodes").selectAll(".circle"),
nodelabels: d3.select('.nodelabels').selectAll(".nodelabel")
}
// Create a zoom and set initial zoom level to 2.5
var zoom = d3.zoom().scaleExtent([1, 16]).on("zoom", zoomed);
select.svg.call(zoom).on("dblclick.zoom", null);
select.svg.call(zoom.transform, d3.zoomIdentity);
zoom.scaleTo(select.svg.transition(), 2.5);
function zoomed() {
select.graph.attr("transform", d3.event.transform);
}
var width = select.svg.attr('width')
height = select.svg.attr('height');
// Create variables and functions to store and access graph data
var nodes = [],
links = [],
fixedNodeId = null;
function node(nodeId) {
return nodes.find(function (node) {
return node.id == nodeId;
})
}
function fixedNode() {
return node(fixedNodeId);
}
// Register a worker in which the force layout calculation will be executed
var worker = new Worker('force.worker.js');
worker.onmessage = function (event) {
nodes = event.data.nodes;
links = event.data.links;
draw();
};
updateGraph();
var transitionDuration = 2000;
/**
* Draw the svg elements on the canvas.
* It uses d3 update pattern to add and remove data from the graph
* Nice transitions are used to keep track of the positions of the
* nodes and the links as the layout of the graph evolves
* */
function draw() {
// Defines node and link keys that will be used in data binding
// see https://bost.ocks.org/mike/constancy/
function nodeKey(n) {
return n.id;
}
function linkKey(d) {
return d.source.id + '-' + d.target.id;
}
var fNode = fixedNode();
// draw arrow heads used as marker-end to the links
select.arrowheads = select.arrowheads.data(links, linkKey);
select.arrowheads
.exit()
.transition()
.duration(transitionDuration)
.attr("opacity", 0)
.remove();
var newArrowHeads = select.arrowheads.enter()
.append("marker")
.attr("class", "arrowhead")
.attr("viewBox", "-0 -5 10 10")
.attr("refX", "20")
.attr("refY", "0")
.attr("orient", "auto")
.attr("markerWidth", "3")
.attr("markerHeight", "3")
.attr("xoverflow", "visible")
newArrowHeads.append("path")
.attr("d", "M 0,-5 L 10, 0 L 0, 5")
newArrowHeads
.attr("opacity", 0)
.transition()
.duration(transitionDuration)
.attr("opacity", 0.5)
select.arrowheads = newArrowHeads.merge(select.arrowheads);
select.arrowheads.attr('id', d => `arrowhead${linkKey(d)}`)
// draw links between nodes
select.links = select.links.data(links, linkKey);
select.links
.exit()
.transition()
.duration(transitionDuration)
.attr("x1", d => node(d.source.id) ? node(d.source.id).x : d.source.x)
.attr("y1", d => node(d.source.id) ? node(d.source.id).y : d.source.y)
.attr("x2", d => node(d.target.id) ? node(d.target.id).x : d.target.x)
.attr("y2", d => node(d.target.id) ? node(d.target.id).y : d.target.y)
.attr("stroke-opacity", 0)
.remove();
var newLinks = select.links.enter()
.append("line")
.attr("class", "link")
.attr("x1", d => fNode.x)
.attr("y1", d => fNode.y)
.attr("x2", d => fNode.x)
.attr("y2", d => fNode.y)
select.links = newLinks.merge(select.links);
select.links.attr('marker-end', d => `url(#arrowhead${linkKey(d)})`)
select.links
.transition()
.duration(transitionDuration)
.attr("x1", d => d.source.x)
.attr("y1", d => d.source.y)
.attr("x2", d => d.target.x)
.attr("y2", d => d.target.y)
.attr("stroke-opacity", 0.2)
// draw nodes as circles
select.circles = select.circles.data(nodes, nodeKey);
select.circles
.exit()
.transition()
.duration(transitionDuration)
.style("opacity", 0)
.remove();
var newCircles = select.circles.enter()
.append("circle")
newCircles
.attr("class", "node")
.attr("cx", d => fNode.x)
.attr("cy", d => fNode.y)
.attr("r", 3)
select.circles = newCircles.merge(select.circles);
select.circles
.transition()
.duration(transitionDuration)
.attr("cx", d => d.x)
.attr("cy", d => d.y);
}
/**
* Update the graph every 3 seconds by adding or removing
* nodes and links
* */
function updateGraph() {
var steps = [
{
fix: 19336,
expand: {
nodes: [19336, 22628, 24534, 19976, 18581, 29496, 19335],
links: [
[22628, 19336],
[19335, 19336],
[29496, 19336],
[18581, 19336],
[19976, 19336],
[24534, 19336]
]
},
},
{
fix: 19976,
expand: {
nodes: [26539, 23892, 18582],
links: [
[19976, 26539],
[19976, 23892],
[19976, 18582],
],
}
},
{
fix: 19976,
delete: {
nodes: [26539, 18582],
links: [
[19976, 26539],
[19976, 18582]
]
}
},
{
clear: "all"
}
]
function iterStep(currentStep) {
var step = steps[currentStep]
if (step.expand) {
step.expand.nodes.forEach(function (n) {
var nodeToAdd = { id: n };
nodes.push(nodeToAdd);
});
step.expand.links.forEach(function (l) {
var linkToAdd = { source: l[0], target: l[1] };
links.push(linkToAdd);
})
}
if (step.delete) {
step.delete.nodes.forEach(function (n) {
nodes = nodes.filter(function (node) {
return node.id != n
})
})
step.delete.links.forEach(function (l) {
links = links.filter(function (link) {
return !(link.source.id == l[0] && link.target.id == l[1])
})
})
}
if (step.clear) {
nodes = [];
links = [];
}
if (step.fix) {
fixedNodeId = step.fix;
var fNode = fixedNode();
if (fNode && fNode.x && fNode.y) {
fNode.fx = fNode.x;
fNode.fy = fNode.y;
}
}
worker.postMessage({
nodes: nodes,
links: links,
center: { x: width / 2, y: height / 2 }
});
// release fix nodes
nodes.forEach(function (node) {
node.fx = null;
node.fy = null;
})
var nextStep = (currentStep + 1) % steps.length;
setTimeout(function () {
iterStep(nextStep)
}, 3000)
}
iterStep(0)
}
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment