Beeswarm plot with force - add links to nodes

346 Views Asked by At

I've created a Beeswarm plot with d3v4 and d3.forceSimulation, and the points are where I want them to be:

var data = [
  { country: "Algeria", amount: 22, year: 2000 },
  { country: "Argentina", amount: 49, year: 1990 },
  { country: "Armenia", amount: 3, year: 1990 },
  { country: "Australia", amount: 9, year: 2010 },
  { country: "Austria", amount: 1, year: 2010 },
  { country: "Bahamas", amount: 5, year: 2018 },
  { country: "Bahrain", amount: 22, year: 2018 },
  { country: "Belarus", amount: 9, year: 2010 },
  { country: "Belgium", amount: 46, year: 2018 },
  { country: "Brazil", amount: 79, year: 1990 },
  { country: "Canada", amount: 12, year: 2000 },
  { country: "China", amount: 26, year: 2018 },
  { country: "Colombia", amount: 9, year: 2010 },
  { country: "Croatia", amount: 8, year: 2000 },
  { country: "Cuba", amount: 14, year: 1990 },
  { country: "Czech Republic", amount: 11, year: 2018 },
  { country: "Denmark", amount: 125, year: 2010 },
  { country: "Canada", amount: 124, year: 2018 },
  { country: "Bahrain", amount: 39, year: 2010 },
  { country: "Estonia", amount: 141, year: 2018 },
  { country: "Ethiopia", amount: 38, year: 1990 },
  { country: "France", amount: 4, year: 2018 },
  { country: "Germany", amount: 15, year: 2000 },
  { country: "Greece", amount: 16, year: 2010 },
  { country: "Grenada", amount: 241, year: 2010 },
  { country: "Hungary", amount: 135, year: 1990 },
  { country: "India", amount: 22, year: 1990 },
  { country: "Indonesia", amount: 31, year: 1990 },
  { country: "Iran", amount: 88, year: 2010 },
  { country: "Ireland", amount: 12, year: 2018 },
  { country: "Italy", amount: 128, year: 2000 },
  { country: "Jamaica", amount: 1, year: 2018 },
  { country: "Japan", amount: 41, year: 1990 },
  { country: "Jordan", amount: 137, year: 2010 },
  { country: "Iran", amount: 13, year: 1990 },
  { country: "Malaysia", amount: 25, year: 2018 },
  { country: "Mexico", amount: 59, year: 2010 },
  { country: "Moldova", amount: 71, year: 2000 },
  { country: "Mongolia", amount: 22, year: 2018 },
  { country: "Morocco", amount: 131, year: 1990 },
  { country: "Netherlands", amount: 129, year: 2018 },
  { country: "New Zealand", amount: 148, year: 2018 },
  { country: "Niger", amount: 1, year: 2010 },
  { country: "Nigeria", amount: 41, year: 1990 },
  { country: "Norway", amount: 14, year: 2010 },
  { country: "Philippines", amount: 15, year: 2018 },
  { country: "Poland", amount: 12, year: 2010 },
  { country: "Portugal", amount: 31, year: 2000 },
  { country: "Puerto Rico", amount: 51, year: 2000 },
  { country: "Romania", amount: 15, year: 2000 },
  { country: "Serbia", amount: 18, year: 2000 },
  { country: "South Africa", amount: 14, year: 2010 },
  { country: "Sweden", amount: 11, year: 2018 },
  { country: "Switzerland", amount: 7, year: 2010 },
  { country: "Thailand", amount: 61, year: 2018 },
  { country: "Trinidad and Tobago", amount: 12, year: 2018 },
  { country: "Tunisia", amount: 34, year: 2010 },
  { country: "Turkey", amount: 28, year: 2010 },
  { country: "Ukraine", amount: 11, year: 2010 },
  { country: "Uzbekistan", amount: 123, year: 2018 },
  { country: "Venezuela", amount: 23, year: 2018 },
  { country: "Iran", amount: 13, year: 2018 }
];

var width = 1000,
  height = 500;

var svg = d3.select("#chart")
  .append("svg")
  .attr("width", width)
  .attr("height", height);

var x = d3.scaleLinear()
  .range([95, 650]);

var y = d3.scaleLinear()
  .range([100, 450]);

  data.forEach(d => {
    d.amount = +d.amount;
  });

  var sort = data.sort((a, b) => d3.descending(a, b));

  y.domain(d3.extent(data, function(d) {
    return d.amount;
  }));
  x.domain(d3.extent(data, function(d) {
    return d.year;
  }));

  var simulation = d3.forceSimulation(data)
    .force("x", d3.forceX(function(d) {
      return x(d.year);
    }).strength(3))
    .force("y", d3.forceY(function(d) {
      return y(d.amount)
    }).strength(2))
    .force("collide", d3.forceCollide(7).strength(7))
    .stop();

  for (var i = 0; i < data.length * 2; ++i) simulation.tick();

  var circles = svg.selectAll(".circles")
    .data(data);

  var circlesEnter = circles.enter()
    .append("circle");

  circlesEnter.attr("r", 4)
    .attr("cx", function(d) {
      return d.x
    })
    .attr("cy", function(d) {
      return d.y
    })
    .attr("fill", function(d) {
      if (d.country == "Iran") {
        return "#FF0044"
      } else if (d.country == "Canada") {
        return "#00A9E9"
      } else if (d.country == "Bahrain") {
        return "#6BF4C6"
      } else {
        return '#333'
      }
    })
    .attr('class', function(d) {
      return d.amount + ' ' + d.year + ' ' + d.country
    })

  // connector lines

  var byCountry = d3.nest()
    .key(function(d) {
      return d.country;
    })
    .entries(data);

  var countryNames = d3.values(byCountry).map(function(d) {
    return d.values.map(function(v) {
      return v.country;
    }).join(', ');
  });

  for (i = 0; i < countryNames.length; i++) {
    eaco = countryNames[i].split(',')[0]

    const filterByCountry = (country, data) => item => item.country === country
    connectData = data.filter(filterByCountry(eaco))

    var linesGroup = svg.append("g")
      .attr("class", "connectors");

    var linec = d3.line()
      .x(function(d) {
        return x(d.year)
      })
      .y(function(d) {
        return y(d.amount)
      })

    // using below as the points does not work
    // .x(function(d) { return x(d.x)})
    // .y(function(d) { return y(d.y)})

    var lineGraph = linesGroup.selectAll('.connect')
      .data(connectData)
      .enter()
      .append("path")
      .attr('class', function(d) {
        return d.amount + ' ' + d.year + ' ' + d.country
      })
      .attr("d", linec(connectData))
      .attr("stroke", function(d) {
        if (d.country == "Iran") {
          return "#FF0044"
        } else if (d.country == "Canada") {
          return "#00A9E9"
        } else if (d.country == "Bahrain") {
          return "#6BF4C6"
        } else {
          return '#333'
        }
      })
      .attr("stroke-width", 1)
      .attr("fill", "none")
  }
<script src="https://d3js.org/d3.v4.min.js"></script>
<body><div id="chart"></div></body>

The x axis shows years. The y axis shows amount. Each point is a country.

I'd like to add connecting lines between points that are the same country over different years. I've color-coded these to clarify.

image showing lines that don't quite connect to the dots

The problem is, I can't get the x/y to match up with the points based on the force. I commented out what I thought would work. Any ideas?

1

There are 1 best solutions below

2
On BEST ANSWER

Once circles are created, it is possible to retrieve their exact coordinates. With these circle coordinates, it becomes very easy to set the extremities of the lines:

var data = [
  { country: "Algeria", amount: 22, year: 2000 },
  { country: "Argentina", amount: 49, year: 1990 },
  { country: "Armenia", amount: 3, year: 1990 },
  { country: "Australia", amount: 9, year: 2010 },
  { country: "Austria", amount: 1, year: 2010 },
  { country: "Bahamas", amount: 5, year: 2018 },
  { country: "Bahrain", amount: 22, year: 2018 },
  { country: "Belarus", amount: 9, year: 2010 },
  { country: "Belgium", amount: 46, year: 2018 },
  { country: "Brazil", amount: 79, year: 1990 },
  { country: "Canada", amount: 12, year: 2000 },
  { country: "China", amount: 26, year: 2018 },
  { country: "Colombia", amount: 9, year: 2010 },
  { country: "Croatia", amount: 8, year: 2000 },
  { country: "Cuba", amount: 14, year: 1990 },
  { country: "Czech Republic", amount: 11, year: 2018 },
  { country: "Denmark", amount: 125, year: 2010 },
  { country: "Canada", amount: 124, year: 2018 },
  { country: "Bahrain", amount: 39, year: 2010 },
  { country: "Estonia", amount: 141, year: 2018 },
  { country: "Ethiopia", amount: 38, year: 1990 },
  { country: "France", amount: 4, year: 2018 },
  { country: "Germany", amount: 15, year: 2000 },
  { country: "Greece", amount: 16, year: 2010 },
  { country: "Grenada", amount: 241, year: 2010 },
  { country: "Hungary", amount: 135, year: 1990 },
  { country: "India", amount: 22, year: 1990 },
  { country: "Indonesia", amount: 31, year: 1990 },
  { country: "Iran", amount: 88, year: 2010 },
  { country: "Ireland", amount: 12, year: 2018 },
  { country: "Italy", amount: 128, year: 2000 },
  { country: "Jamaica", amount: 1, year: 2018 },
  { country: "Japan", amount: 41, year: 1990 },
  { country: "Jordan", amount: 137, year: 2010 },
  { country: "Iran", amount: 13, year: 1990 },
  { country: "Malaysia", amount: 25, year: 2018 },
  { country: "Mexico", amount: 59, year: 2010 },
  { country: "Moldova", amount: 71, year: 2000 },
  { country: "Mongolia", amount: 22, year: 2018 },
  { country: "Morocco", amount: 131, year: 1990 },
  { country: "Netherlands", amount: 129, year: 2018 },
  { country: "New Zealand", amount: 148, year: 2018 },
  { country: "Niger", amount: 1, year: 2010 },
  { country: "Nigeria", amount: 41, year: 1990 },
  { country: "Norway", amount: 14, year: 2010 },
  { country: "Philippines", amount: 15, year: 2018 },
  { country: "Poland", amount: 12, year: 2010 },
  { country: "Portugal", amount: 31, year: 2000 },
  { country: "Puerto Rico", amount: 51, year: 2000 },
  { country: "Romania", amount: 15, year: 2000 },
  { country: "Serbia", amount: 18, year: 2000 },
  { country: "South Africa", amount: 14, year: 2010 },
  { country: "Sweden", amount: 11, year: 2018 },
  { country: "Switzerland", amount: 7, year: 2010 },
  { country: "Thailand", amount: 61, year: 2018 },
  { country: "Trinidad and Tobago", amount: 12, year: 2018 },
  { country: "Tunisia", amount: 34, year: 2010 },
  { country: "Turkey", amount: 28, year: 2010 },
  { country: "Ukraine", amount: 11, year: 2010 },
  { country: "Uzbekistan", amount: 123, year: 2018 },
  { country: "Venezuela", amount: 23, year: 2018 },
  { country: "Iran", amount: 13, year: 2018 }
];

var width = 1000,
  height = 500;

var svg = d3.select("#chart")
  .append("svg")
  .attr("width", width)
  .attr("height", height);

var x = d3.scaleLinear()
  .range([95, 650]);

var y = d3.scaleLinear()
  .range([100, 450]);

data.forEach(d => {
  d.amount = +d.amount;
});

var sort = data.sort((a, b) => d3.descending(a, b));

y.domain(d3.extent(data, function(d) {
  return d.amount;
}));
x.domain(d3.extent(data, function(d) {
  return d.year;
}));

var simulation = d3.forceSimulation(data)
  .force("x", d3.forceX(function(d) {
    return x(d.year);
  }).strength(3))
  .force("y", d3.forceY(function(d) {
    return y(d.amount)
  }).strength(2))
  .force("collide", d3.forceCollide(7).strength(7))
  .stop();

for (var i = 0; i < data.length * 2; ++i) simulation.tick();

var circles = svg.selectAll(".circles").data(data);

var circlesEnter = circles.enter().append("circle");

circlesEnter.attr("r", 4)
  .attr("cx", function(d) { return d.x; })
  .attr("cy", function(d) { return d.y; })
  .attr("fill", function(d) {
    if (d.country == "Iran") {
      return "#FF0044"
    } else if (d.country == "Canada") {
      return "#00A9E9"
    } else if (d.country == "Bahrain") {
      return "#6BF4C6"
    } else {
      return '#333'
    }
  })
  .attr('class', function(d) {
    return d.country + '-' + d.year + '-' + d.amount
  });

// connector lines

var byCountry = d3.nest().key(function(d) { return d.country; }).entries(data);

var countryNames = d3.values(byCountry).map(function(d) {
  return d.values.map(function(v) {
    return v.country;
  }).join(', ');
});

for (i = 0; i < countryNames.length; i++) {
  eaco = countryNames[i].split(',')[0]

  const filterByCountry = (country, data) => item => item.country === country
  connectData = data.filter(filterByCountry(eaco))

  if (connectData.length >= 2) {

    var linesGroup = svg.append("g")
      .attr("class", "connectors");

    var linec = d3.line()
      .x(d => d3.select("circle." + d.country + "-" + d.year + "-" + d.amount).attr("cx"))
      .y(d => d3.select("circle." + d.country + "-" + d.year + "-" + d.amount).attr("cy"));

    linesGroup
      .datum(connectData)
      .append("path")
      .attr('class', d => d[0].amount + '-' + d[0].year + '-' + d[0].country)
      .attr("d", linec)
      .attr("stroke", function(d) {
        if (d[0].country == "Iran") return "#FF0044";
        else if (d[0].country == "Canada") return "#00A9E9";
        else if (d[0].country == "Bahrain") return "#6BF4C6";
        else return '#333';
      })
      .attr("stroke-width", 1)
      .attr("fill", "none");
  }
}
<script src="https://d3js.org/d3.v5.min.js"></script>
<body><div id="chart"></div></body>

This way, lines extremities exactly match the coordinates of the circles.

To do so, we can select the circles corresponding to the extremities of the line in question using:

d3.select("circle." + d.country + "-" + d.year + "-" + d.amount)

on which we can retrieve the cx and cy attributes (x and y position of the circle):

d3.select("circle." + d.country + "-" + d.year + "-" + d.amount).attr("cx")

to finally create the line with these coordinates:

var linec = d3.line()
  .x(d => d3.select("circle." + d.country + "-" + d.year + "-" + d.amount).attr("cx"))
  .y(d => d3.select("circle." + d.country + "-" + d.year + "-" + d.amount).attr("cy"));

Side notes:

Notice how I modified the classes you used to name circles. As the class names contained spaces, we couldn't select them; I used - instead. In addition we apparently can't select a class which starts with a number, so I changed the class names to start with the country instead of the amount.

Since, here, classes are used to uniquely define circles and lines, it would probably make more sense to set ids rather than classes.

As noticed by @Gerardo, each line is actually created several times (once per circle; such that a line joining 3 circles would be created 3 times). Here is one possible way you can fix that:

var linec = d3.line()
  .x(d => d3.select("circle." + d.country + "-" + d.year + "-" + d.amount).attr("cx"))
  .y(d => d3.select("circle." + d.country + "-" + d.year + "-" + d.amount).attr("cy"));

linesGroup.datum(connectData).append("path").attr("d", linec)...