6b: Force diagrams

Objectives

Learn to build force diagrams and network maps in D3.

Basic template

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's with the data array?

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.

Force simulation

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?


Force layout: parameters

Fun radial layouts

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?

Add drag interactivity


...
.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.

3 classes of nodes

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?

Different centre position

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);
});
                    

Force change on the fly!




                    

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.

Add links data

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.

Draw the links

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");
                    

Update force simulation

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.

Link parameters

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?

Questions?

Chi-Loong | V/R