John Arrowwood John Arrowwood - 16 days ago 7
Javascript Question

Make two instances of d3.forceCollide() play nice together

I want two instances of

d3.forceCollide()
. In one, every node is pushed away from one another to prevent overlap. In the second, only a subset of nodes are pushed away from one another, with a much bigger radius.

To accomplish the second force, I tweak the initialize method to filter the incoming nodes, like so:

function selective(force,filter){
var init = force.initialize;
force.initialize = function(_){return init(_.filter(filter));};
return force;
}

var dpi = 90; // approximate pixels per inch in SVG
var size = dpi * (1/4); // quarter-inch unit size

var universally_applied =
d3.forceCollide()
.radius(size)
.strength(1);

var selectively_applied =
selective(
d3.forceCollide(),
function(d){return d.id === color;}
)
.radius(size*5)
.strength(1);
}


Now, this ALMOST works. I created a fiddle to see it in action: https://jsfiddle.net/jarrowwx/0dax43ue/38/ - every colored circle is supposed to repel every other circle of the same color, from a distance. Every other color, it just bumps into and pushes it out of the way.

If I do not change the order in which things are defined, then the selectively applied force is ONLY applied to the first color (red). If I shuffle the
data
array before applying forces, it is difficult to define exactly what happens, but the force is applied to some circles and not most of the others, even among the same color.

Any ideas what is going on here, or how to fix it?

Answer

As it turns out the solution for applying a force to only a subset of nodes as suggested by my answer to "Partial forces on nodes in D3.js" is not equally applicable to all forces. The reason, the solution worked out for that particular problem, is due to the fact, that d3.forceX and d3.forceY act on each node individually without taking any other node into account. If you are dealing with a force (Collision, Links, or Many-Body) which is not restricted this way, the approach breaks because it interferes with the inner workings of that force.

Analysis

What all these forces have in common is the fact, that they maintain an index value corresponding to their position / index in the data array bound to the selection. The documentation also mentions this:

Each node must be an object. The following properties are assigned by the simulation:

  • index - the node’s zero-based index into nodes

Digging into the source code of the forces one notices, that violating the consistency between the index value and the real index messes up the calculations. This leads to only a part of the nodes being included in the actual calculations, because other nodes are not accessible by means of the index value.

This also explains the observation made by Rothrock that the effect does not show up when removing the shuffling of nodes. In the example 'red' happens to be the first color in the array and, thus, the red nodes' index values will correspond to the nodes' positions in the array just because of this fragile setup putting them in the leading position. They will still hold their position when filtered to be a subset of red nodes. But, this will all break apart once you shuffle or put 'red' at any position other than the first while still filtering for red nodes.

Solution: 1st attempt—not working

My first approach to work around this was to do the filtering and modify the index values for the subset of nodes. This did not work out as expected because it ignores the fact that the nodes are also acted upon by the universal force. For the same reasons as before this broke the universal force which allowed some of the other nodes to overlap each other.

Solution: 2nd attempt—working

The solution to all this is rather simple: instead of filtering the nodes and supplying only a subset to the force's initialize() function you need to call it with a copy of the original array having the same size. This array will only contain references to the red nodes while filling up the holes with empty objects, whereby keeping the index values consistent. This also does not manipulate the index value itself allowing the node to be shared by multiple forces without any unwanted side-effects.

function selective(force, filter) {
  var init = force.initialize;
  force.initialize = function(nodes) {
    return init(
      nodes.map(function(n){       // Make a copy of array maintaining a consistent index...
        return filter(n) ? n : {}; // ...by filling up with empty objects.
      })  
    );
  };

  return force;
}

The following snippet is based on your code and demonstrates a fully functional implementation.

var dpi=90;
var width=dpi*6;
var height=dpi*3;
var size=dpi*(1/4);

var data = [];
['red','orange','yellow','yellow-green','green','green-blue','cyan','blue-green','blue','indigo','violet','red-blue'].map(function(color){
  var count = Math.floor(Math.random() * 10) + 1;
  while ( count ) {
    data.push({
      id: color,
      x: Math.random()*size*10,
      y: Math.random()*size*10
    });
    count--;
  }
});

d3.shuffle(data);

var svg = d3.select('body')
  .append('svg')
    .attr('width',width)
    .attr('height',height)
    .attr('viewBox',[0,0,width,height].join(' '));
      
var node = svg
  .append('g')
    .attr('id','nodes')
  .selectAll('.node')
    .data(data)
      .enter()
      .append('circle')
      .attr('class',function(d){return 'node ' + d.id; })
      .attr('cx',function(d){return d.x})
      .attr('cy',function(d){return d.y})
      .attr('r',size)
      .call(d3.drag()
      	.on("start", drag_start )
        .on("drag",  drag_move )
        .on("end",   drag_end )
      )
      ;
      
function selective(force, filter) {
  var init = force.initialize;
  force.initialize = function(nodes) {
    return init(
      nodes.map(function(n){       // Make a copy of array maintaining a consistent index...
        return filter(n) ? n : {}; // ...by filling up with empty objects.
      })  
    );
  };
  
  return force;
}

function updateDisplay() {

  var min_x = width,
  	min_y = height,
    max_x = 0,
    max_y = 0;
      
  // position nodes AND calculate extents
  node
  	.attr('cx',function(d) {
      if ( d.x < min_x ) min_x = d.x;
      if ( d.x > max_x ) max_x = d.x;
      return d.x;
    })
    .attr('cy',function(d) {
      if ( d.y < min_y ) min_y = d.y;
      if ( d.y > max_y ) max_y = d.y;
      return d.y;
    });
    
  // scale viewport to fit contents
  var margin = dpi * (1/2);
  min_x -= margin;
  max_x += margin;
  min_y -= margin;
  max_y += margin;
  width = max_x - min_x + size;
  height = max_y - min_y + size;
  svg.attr('viewBox', [min_x-(size/2),min_y-(size/2),width+(size/2),height+(size/2)].join(' '));
}

var universally_applied = 
  d3.forceCollide()
    .radius(size)
    .strength(1);

var selectively_applied =
  selective(
    d3.forceCollide(),
    function(d) { return d.id === 'red'; }
  )
  .radius(size*5)
  .strength(1);
var simulation =  
  d3.forceSimulation()
    .nodes( data )
    .force("everybody_else", universally_applied )
    .force("red", selectively_applied )
      .on('tick',updateDisplay);

function drag_start(d) {
  if ( ! d3.event.active )
  	simulation.alphaTarget(0.3).restart();
  d.fx = d.x;
  d.fy = d.y;
}

function drag_move(d) {
  d.fx = d3.event.x;
  d.fy = d3.event.y;
}

function drag_end(d) {
  if ( ! d3.event.active )
    simulation.alphaTarget(0);
  d.fx = null;
  d.fy = null;
}
svg {
  display: inline-block;
  width: 100%;
  height: 100%;
}
circle.red          { fill: rgba(255,  0,  0,1.0); }
circle.orange       { fill: rgba(255,128,  0,0.5); }
circle.yellow       { fill: rgba(255,255,  0,0.5); }
circle.yellow-green { fill: rgba(128,255,  0,0.5); }
circle.green        { fill: rgba(  0,255,  0,0.5); }
circle.green-blue   { fill: rgba(  0,255,128,0.5); }
circle.cyan         { fill: rgba(  0,255,255,0.5); }
circle.blue-green   { fill: rgba(  0,128,255,0.5); }
circle.blue         { fill: rgba(  0,  0,255,0.5); }
circle.indigo       { fill: rgba(128,  0,255,0.5); }
circle.violet       { fill: rgba(255,  0,255,0.5); }
circle.red-blue     { fill: rgba(255,  0,128,0.5); }
<script src="https://d3js.org/d3.v4.js"></script>