Xiphias
Xiphias

Reputation: 4716

Position circles on a horizontal axis without overlapping using force layout

I would like to position circles on a d3 scale and relax them in such a way that they do not overlap. I know that this decreases accuracy, but that's okay for the type of chart that I would like to generate.

This is my minimum (non-)working example: https://jsfiddle.net/wmxh0gpb/1/

<body>
  <div id="content">
    <svg width="700" height="200">
      <g transform="translate(50, 100)"></g>
    </svg>
  </div>

  <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.2.2/d3.min.js"></script>

  <script>
var width = 600, height = 400;
var xScale = d3.scaleLinear().domain([0, 1]).range([0, 300]);

var numNodes = 5;
var nodes = d3.range(numNodes).map(function(d, i) {
  return {
    value: Math.random()
  }
});

var simulation = d3.forceSimulation(nodes)
  .force('x', d3.forceX().strength(0.5).x(function(d) {
    return xScale(d.value);
  }))
  .force('collision', d3.forceCollide().strength(1).radius(50))
  .on('tick', ticked);

function ticked() {
  var u = d3.select('svg g')
    .selectAll('circle')
    .data(nodes);

  u.enter()
    .append('circle')
    .attr('r', function(d) {
      return 25;
    })
    .style('fill', function(d) {
      return "black";
    })
    .merge(u)
    .attr('cx', function(d) {
      return d.x;
    })
    .attr('cy', function(d) {
      return 0
    })
    .attr("opacity", 0.5)

  u.exit().remove();
}
  </script>
</body>

The circles are positioned using the forceX force and collision should be prevented using forceCollide. However, the circles seem to find a stable position regardless of the overlap instead of avoiding it.

What am I doing wrong?

Upvotes: 1

Views: 876

Answers (2)

Gerardo Furtado
Gerardo Furtado

Reputation: 102194

The technical name for this is beeswarm chart: only one axis contains meaningful information, the other one is used only to separate the nodes.

For creating a beeswarm chart in D3 you have to pass the y position to the force (as d3.forceY) as well, in this case with 0 (since you're already translating the group), like:

var simulation = d3.forceSimulation(nodes)
    .force('x', d3.forceX(function(d) {
        return xScale(d.value);
    }).strength(0.8))
    .force('y', d3.forceY(0).strength(0.2))

As you can see, the forceX and forceY have different strength values. You have to play with them until you find a combination that suits you: after all, a beeswarm chart is a trade-off between accuracy and avoiding overlap the nodes.

Not related to the question, but very important: remove everything from the ticked function that is not related to repositioning the nodes. The ticked function will run dozens of times per second, normally running 300 times before the simulation cools down. There is no sense in updating, entering and exiting selections 300 times!

Here is your code with those changes:

<body>
  <div id="content">
    <svg width="700" height="200">
      <g transform="translate(50, 100)"></g>
    </svg>
  </div>

  <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.2.2/d3.min.js"></script>

  <script>
    var width = 600,
      height = 400;
    var xScale = d3.scaleLinear().domain([0, 1]).range([0, 300]);

    var numNodes = 5;
    var nodes = d3.range(numNodes).map(function(d, i) {
      return {
        value: Math.random()
      }
    });

    var simulation = d3.forceSimulation(nodes)
      .force('x', d3.forceX(function(d) {
        return xScale(d.value);
      }).strength(0.8))
      .force('y', d3.forceY(0).strength(0.2))
      .force('collision', d3.forceCollide().strength(1).radius(25))
      .on('tick', ticked);

    var u = d3.select('svg g')
      .selectAll('circle')
      .data(nodes);

    u = u.enter()
      .append('circle')
      .attr('r', function(d) {
        return 25;
      })
      .style('fill', function(d) {
        return "black";
      })
      .merge(u)
      .attr("opacity", 0.5)

    u.exit().remove();

    function ticked() {
      u.attr('cx', function(d) {
          return d.x;
        })
        .attr('cy', function(d) {
          return d.y
        })
    }

  </script>
</body>

Upvotes: 3

rioV8
rioV8

Reputation: 28663

Because you ignore the y coord of the force simulation

Add this as the last line of the tick function. Now you force the nodes to be at y==0

nodes.forEach(e => { e.fy = 0 });

And set the radius of the collision force to the real radius (25)

.force('collision', d3.forceCollide().strength(1).radius(25))

Upvotes: 0

Related Questions