Learn to build force diagrams and network maps in D3.
As usual, let's start with a simple HTML template.
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style></style>
</head>
<body>
<svg></svg>
<script src="https://d3js.org/d3.v7.min.js"></script>
</body>
</html>
Add in this D3 code.
let width = 800,
height = 800;
let svg = d3.select("svg")
.attr("width", width)
.attr("height", height);
let data = [];
for (let i=0; i < 20; i++) {
let obj = {};
data.push(obj);
}
let node = svg.append("g")
.attr("id", "nodes")
.selectAll("circle")
.data(data)
.enter()
.append("circle")
.attr("r", 25)
.style("fill", "steelblue");
We created a bunch of circles, but we need to space them using D3's force simulation.
let simulation = d3.forceSimulation()
.nodes(data)
.force("x", d3.forceX().strength(0.1).x( width / 2 ))
.force("y", d3.forceY().strength(0.1).y( height /2 ))
.force("charge", d3.forceManyBody().strength(-30))
.force("collide", d3.forceCollide().strength(0.1).radius(30))
.on("tick", d => {
node
.attr("cx", d => d.x)
.attr("cy", d => d.y);
});
What sorcery is this? We didn't add x and y values to the data array, so how are the cx and cy attributes updated?
Console.log the data array.
Lookup the D3 force documentation.
Play with the values in the force simulation to get an intuitive feel of what the code does.
Can you tweak the amount of cicles to 100, reduce radius of circles, move the center of gravity, change the collision radius?
Why do the circles seem to move from the (0,0) position? How do we fix this?
Comment out the old simulation parameters and try this one!
let simulation = d3.forceSimulation()
.nodes(data)
.force("charge", d3.forceManyBody().strength(10))
.force("collide", d3.forceCollide().strength(1).radius(10))
.force("r", d3.forceRadial(200, width /2, height /2).strength(0.1))
.alphaMin(0.1)
.on("tick", d => {
node
.attr("cx", d => d.x)
.attr("cy", d => d.y);
});
Experiment with the parameters! What does alphaMin do?
...
.call(d3.drag()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended));
function dragstarted(event, d) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event, d) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event, d) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
Note: The call function is added to the appended circles. With drag added, should be easier to see what some of the parameters do, like d3.forceManyBody.
Let's say our data has 3 classes of nodes. Randomly generating them:
for (let i=0; i < 20; i++) {
let obj = {};
obj.class = Math.floor(Math.random() * 3);
data.push(obj);
}
Can you give each type its own color?
Let's define a different centre x position for each class of nodes.
let xPosition = d3.scaleOrdinal()
.domain([0, 1, 2])
.range([150, 400, 650]);
let simulation = d3.forceSimulation()
.nodes(data)
.force("x", d3.forceX().strength(0.5).x( d => xPosition(d.class) ))
.force("y", d3.forceY().strength(0.2).y( height /2 ))
.force("charge", d3.forceManyBody().strength(0))
.force("collide", d3.forceCollide().strength(0.1).radius(15))
.on("tick", d => {
node
.attr("cx", d => d.x)
.attr("cy", d => d.y);
});
d3.select("#group1").on("click", function() {
simulation
.force("x", d3.forceX().strength(0.5).x(d => xPosition(d.class)))
.force("y", d3.forceY().strength(0.2).y( height /2 ))
.alphaTarget(0.3)
.restart();
})
d3.select("#group2").on("click", function() {
simulation
.force("x", d3.forceX().strength(0.1).x(400))
.force("y", d3.forceY().strength(0.1).y(400))
.alphaTarget(0.3)
.restart();
})
Force changes are how simulations like this are done.
We'll need to give each node an id, and specify a source and target for each link.
let data = [];
for (let i=0; i < 20; i++) {
let obj = {x: width/2, y: height/2};
obj.id = "node" + i;
obj.class = Math.floor(Math.random() * 3);
data.push(obj);
}
let links = [];
for (let i=0; i < 10; i++) {
let obj = {};
obj.source = "node" + Math.floor(Math.random() * 20);
obj.target = "node" + Math.floor(Math.random() * 20);
links.push(obj);
}
Console.log data and links arrays to inspect the structure.
We'll add the link paths to the SVG. No path data yet, which we'll update in the simulation on tick function.
let linkpath = svg.append("g")
.attr("id", "links")
.selectAll("path")
.data(links)
.enter()
.append("path")
.attr("fill", "none")
.attr("stroke", "black");
Putting everything together.
let simulation = d3.forceSimulation()
.nodes(data)
.force("x", d3.forceX().strength(0.5).x( width /2 ))
.force("y", d3.forceY().strength(0.2).y( height /2 ))
.force("link", d3.forceLink(links).id(d => d.id))
.force("charge", d3.forceManyBody().strength(20))
.force("collide", d3.forceCollide().strength(1).radius(30))
.on("tick", d => {
node
.attr("cx", d => d.x)
.attr("cy", d => d.y);
linkpath
.attr("d", d => "M" + d.source.x + "," + d.source.y + " " + d.target.x + "," + d.target.y);
});
Console.log simulation.nodes() and simulation.force("link").links() to inspect the structure.
Links can have distances and strengths. Play around with the properties.
...
.force("link", d3.forceLink(links)
.id(d => d.id)
.distance(50)
.strength(0.5)
)
...
Can you change the link path to be a curved path?
Chi-Loong | V/R