Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

D3 - How to update force simulation when data values change

I'm stuck on a small problem regarding force simulation in D3.

I have data representing poverty rates for each country, from 1998 to 2008. It's a bubble chart that's split into three clusters, representing poor countries, not-poor countries, and countries with no information.

When the app is initially loaded, it's loaded with the 1998 data. However, I have some buttons at the top, that, when clicked, will change the year, and subsequently the bubbles should rearrange themselves. All I've been able to do, is when the button is clicked, I change a variable year. However, there are functions and variables that use year throughout the code. When year changes, I want to recalculate all the node properties and force parameters that are depending on year

Here's my code. I've included all of it in case you want to try it out. The data file is at the end of this post.

 async function init() {
    // Set up the canvas
    var height = 1000,  width = 2000;
    var svg = d3.select("#panel1").append("svg")
        .attr("height", height)
        .attr("width", width)
        .attr("class", "bubblePanel");

    var canvas = svg.append("g")
        .attr("transform", "translate(0,0)");

    // Choose what year to look at, based on button clicks.
    var year = "X1998"
    d3.select("#b1998").on("click", function() { 
        year = "X1998"
        console.log(year)
        // NOTIFY SIMULATION OF CHANGE //
    })
    d3.select("#b1999").on("click", function() { 
        year = "X1999"
        console.log(year)
        // NOTIFY SIMULATION OF CHANGE //
    })
    d3.select("#b2000").on("click", function() { 
        year = "X2000"
        console.log(year)
        // NOTIFY SIMULATION OF CHANGE //
    })

    // Implement the physics of the elements. Three forces act according to the poverty level (poor, not poor, and no info)
    var simulation = d3.forceSimulation()
        .force("x", d3.forceX(function(d) {
            if (parseFloat(d[year]) >= 10) {
                return 1700
            } else if (parseFloat(d[year]) === 0) {
                return 1000
            } else {
                return 300
            }
            }).strength(0.05))
        .force("y", d3.forceY(300).strength(0.05))
        .force("collide", d3.forceCollide(function(d) {
            return radiusScale(d[year])
        }));

    // Function to pick colour of circles according to region
    function pickColor(d) {
        if (d === "East Asia & Pacific") { 
            return "red" 
        } else if (d === "Europe & Central Asia") {
            return "orange"
        } else if (d === "Latin America & Caribbean") {
            return "yellow"
        } else if (d === "Middle East & North Africa") {
            return "green"
        } else if (d === "North America") {
            return "blue"
        } else if (d === "South Asia") {
            return "indigo"
        } else {
            return "violet"
        }
    }

    // Set the scales for bubble radius, and text size.
    var radiusScale = d3.scaleSqrt().domain([0, 50]).range([20,80]);
    var labelScale = d3.scaleSqrt().domain([0,50]).range([10,40]);

    // Read the data
    await d3.csv("wd3.csv").then(function(data) {
        // Assign each data point to a circle that is colored according to region and has radius according to its poverty level
        var bubbles = svg.selectAll("circle")
            .data(data)
            .enter().append("circle")
            .attr("cx", 100)
            .attr("cy", 100)
            .attr("fill", function(d) {
                return pickColor(d.Region)
            })
            .attr("r", function(d) {
                return radiusScale(d[year])
            });
            // Assign each ddata point to a text element that shows the counry code of the data point. The text is scaled according to the poverty level
        var labels = svg.selectAll("text")
            .data(data)
            .enter().append("text")
            .attr("x", 100)
            .attr("y", 100)
            .attr("dominant-baseline", "central")
            .text(function(d) { return d.XCountryCode })
            .style("stroke", "black")
            .style("text-anchor", "middle")
            .style("font-size", function(d) { return labelScale(d[year]); });


        // Code to handle the physics of the bubble and the text
        simulation.nodes(data)
                .on("tick", ticked)
        function ticked() {
            bubbles.attr("transform", function(d) {
                var k = "translate(" + d.x + "," + d.y + ")";
                    return k;
            })
            labels.attr("transform", function(d) {
                var k = "translate(" + d.x + "," + d.y + ")";
                    return k;
            })

        }
    });
}

When year changes, the data values will change for each country. I want the following parts of my code to be updated.

The x forces on the nodes: Countries can go from poor in one year to not-poor in another year, so their cluster will change

The radius of the circles: The radius represents poverty level. These change from year to year, so the size of the circles will change when a button is clicked

The coordinates of the country labels: These labels are attached to the data as well. So when the x forces on the circles causes the circles to move, the labels should move as well.

I'd greatly appreciate the help.

The data file can be found here. I accidentally named it povertyCSV, but in the code, it's referenced as "wd3.csv"

like image 633
Zuhaib Ahmed Avatar asked Jul 30 '19 17:07

Zuhaib Ahmed


1 Answers

If I understand the question correctly:

Re-initializing Forces

The functions provided to set parameters of d3 forces such as forceX or forceCollision are executed once per node at initialization of the simulation (when nodes are originally assigned to the layout). This saves a lot of time once the simulation starts: we aren't recalculating force parameters every tick.

However, if you have an existing force layout and want to modify forceX with a new x value or new strength, or forceCollision with a new radius, for example, we can re-initialize the force to perform the recalculation:

 // assign a force to the force diagram:
 simulation.force("someForce", d3.forceSomeForce().someProperty(function(d) { ... }) )

// re-initialize the force
simulation.force("someForce").initialize(nodes);

This means if we have a force such as:

simulation.force("x",d3.forceX().x(function(d) { return fn(d["year"]); }))

And we update the variable year, all we need to do is:

year = "newValue";

simulation.force("x").initialize(nodes);

Positioning

If the forces are re-initialized (or re-assigned), there is no need to touch the tick function: it'll update the nodes as needed. Labels and circles will continue to be updated correctly.

Also, non-positional things such as color need to be updated in the event handler that also re-initializes the forces. Other than radius, most things should either be updated via the force or via modifying the elements directly, not both.

Radius is a special case:

  • With d3.forceCollide, radius affects positioning
  • Radius, however, does not need to be updated every tick.

Therefore, when updating the radius, we need to update the collision force and modify the r attribute of each circle.

If looking for a smooth transition of radius that is reflected graphically and in the collision force, this should be a separate question.

Implementation

I've borrowed from your code to make a fairly generic example. The below code contains the following event listener for some buttons where each button's datum is a year:

buttons.on("click", function(d) {
  // d is the year:
  year = d;

  // reheat the simulation:
  simulation
    .alpha(0.5)
    .alphaTarget(0.3)
    .restart();

  // (re)initialize the forces
  simulation.force("x").initialize(data);
  simulation.force("collide").initialize(data);

  // update altered visual properties:
  bubbles.attr("r", function(d) { 
      return radiusScale(d[year]);
    }).attr("fill", function(d) {
      return colorScale(d[year]);
    })
})

The following snippet uses arbitrary data and due to its size may not allow for nodes to re-organize perfectly every time. For simplicity, position, color, and radius are all based off the same variable. Ultimately, it should address the key part of the question: When year changes, I want to update everything that uses year to set node and force properties.

var data = [
  {year1:2,year2:1,year3:3,label:"a"},
  {year1:3,year2:4,year3:5,label:"b"},
  {year1:5,year2:9,year3:7,label:"c"},
  {year1:8,year2:16,year3:11,label:"d"},
  {year1:13,year2:25,year3:13,label:"e"},
  {year1:21,year2:36,year3:17,label:"f"},
  {year1:34,year2:1,year3:19,label:"g"},
  {year1:2,year2:4,year3:23,label:"h"},
  {year1:3,year2:9,year3:29,label:"i"},
  {year1:5,year2:16,year3:31,label:"j"},
  {year1:8,year2:25,year3:37,label:"k"},
  {year1:13,year2:36,year3:3,label:"l"},
  {year1:21,year2:1,year3:5,label:"m"}
];

// Create some buttons:
var buttons = d3.select("body").selectAll("button")
  .data(["year1","year2","year3"])
  .enter()
  .append("button")
  .text(function(d) { return d; })
  
  
// Go about setting the force layout:
var svg = d3.select("body")
  .append("svg")
  .attr("width", 500)
  .attr("height", 300);

var radiusScale = d3.scaleSqrt()
   .domain([0, 40])
   .range([5,30]);

var colorScale = d3.scaleLinear()
   .domain([0,10,37])
   .range(["#c7e9b4","#41b6c4","#253494"]);  

var year = "year1";
  
var simulation = d3.forceSimulation()
   .force("x", d3.forceX(function(d) {
         if (parseFloat(d[year]) >= 15) {
            return 100
         } else if (parseFloat(d[year]) > 5) {
            return 250
         } else {
            return 400
        }
   }).strength(0.05))
   .force("y", d3.forceY(150).strength(0.05))
   .force("collide", d3.forceCollide()
      .radius(function(d) {
        return radiusScale(d[year])
   }));
   
var bubbles = svg.selectAll("circle")
  .data(data)
  .enter().append("circle")
  .attr("r", function(d) {
     return radiusScale(d[year])
  })
  .attr("fill", function(d) {
    return colorScale(d[year]);
  });
  
var labels = svg.selectAll("text")
  .data(data)
  .enter()
  .append("text")
  .text(function(d) {
    return d.label;
  })
  .style("text-anchor","middle");
 
simulation.nodes(data)
  .on("tick", ticked) 
 
 
function ticked() {

  bubbles.attr("cx", function(d) {
    return d.x;
  }).attr("cy", function(d) {
    return d.y;
  })
  
  labels.attr("x", function(d) {
    return d.x;
  })
  .attr("y", function(d) {
    return d.y +5;
  })
  
}

buttons.on("click", function(d) {
  // d is the year:
  year = d;
  
  simulation
    .alpha(0.5)
    .alphaTarget(0.3)
    .restart();
    
  simulation.force("x").initialize(data);
  simulation.force("collide").initialize(data);

  bubbles.attr("r", function(d) { 
      return radiusScale(d[year]);
    }).attr("fill", function(d) {
      return colorScale(d[year]);
    })
})
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
like image 85
Andrew Reid Avatar answered Nov 16 '22 17:11

Andrew Reid