d3 force directed layout – link distance priority

If I understand correctly, I believe there is a potential solution.

To get link distance to be accurate, you need to set charge and collision forces to zero, but as your image suggests, then nodes aren’t spaced in a way that accounts for other nodes, just those nodes which they share links with. As d3.force initializes nodes that do not have x,y values in a phyllotaxis arrangement, links and nodes will be clustered in unintended ways. But, if applying a repelling force throughout the simulation, the spacing is improved but the distances are distorted.

The possible solution is to use a repelling force initially because you need to separate nodes into recognizable clusters based on links. Then, after they are separated, reduce the repelling force to nothing so that the only force applied is in relation to the desired link distance.

This requires you to modify the forces in the tick function as the graph evolves. This also requires that all link distances are compatible with one another (a triangle of nodes can’t have two corners separated by 100 pixels and the remaining corner connected to the other two by 10 pixels).

Something like this might work within the tick function in simple situations:

var alpha = this.alpha();   // starts at 1 by default, simulation ends at zero

var chargeStrength; // a multiplier for charge strength

if ( alpha > 0.2 ) {
    chargeStrength = (alpha - 0.2 / 0.8); // decrease for the first portion of the simulation
}
else {
    chargeStrength = 0; // leave at zero and give the link distance force time to work without competing forces
}

For more complex visualizations, you could allow more time for cool down by decreasing alphaDecay, or increase it for simpler ones.

I’ve made a simple example here, at the end of the visualization distances are logged (I’ve increased alphaDecay in the snippet below to speed it up at the cost of precision, but it’s still pretty good) and referenced with desired distances.

var graph = {
  nodes: d3.range(15).map(Object),
  links: [
    {source:  0, target:  1, distance: 20 },
    {source:  0, target:  2, distance: 40},
    {source:  0, target:  3, distance: 80},
    {source:  1, target:  4, distance: 20},
    {source:  1, target:  5, distance: 40},
    {source:  1, target:  6, distance: 80},
    {source:  2, target:  7, distance: 12},
    {source:  2, target:  8, distance: 8},
    {source:  2, target:  9, distance: 6},
    {source:  3, target:  10, distance: 10},
    {source:  3, target:  11, distance: 10},
    {source:  3, target:  12, distance: 2},
	{source:  3, target:  13, distance: 2},
	{source:  3, target:  14, distance: 2}
  ]
};

var svg = d3.select("svg"),
    width = +svg.attr("width"),
    height = +svg.attr("height");

var color = d3.scaleOrdinal(d3.schemeCategory20);

var simulation = d3.forceSimulation()
    .force("charge", d3.forceManyBody().strength(-30 ))
	.force("link", d3.forceLink().distance(function(d) { return d.distance } ).strength(2) )
    .force("center", d3.forceCenter(width / 2, height / 2))
	.force("collide",d3.forceCollide().strength(0).radius(0))
	.alphaDecay(0.03)
    .velocityDecay(0.4);
	
	
	
  var link = svg.append("g")
      .attr("class", "links")
    .selectAll("line")
    .data(graph.links)
    .enter().append("line")
      .attr("stroke-width", 1);

  var node = svg.append("g")
     .attr("class", "nodes")
    .selectAll("circle")
    .data(graph.nodes)
    .enter().append("circle")
     .attr("r", 3)
	  
 simulation
      .nodes(graph.nodes)
      .on("tick", ticked);

  simulation.force("link")
      .links(graph.links);

  
  
	  
  function ticked() {
	
	var alpha = this.alpha();
	var chargeStrength;

    if ( alpha > 0.2 ) {
		chargeStrength = (alpha - 0.2 / 0.8);
	}
	else {
		chargeStrength = 0;
	}

	this.force("charge", d3.forceManyBody().strength( -30 * chargeStrength ))
	
	
    link
        .attr("x1", function(d) { return d.source.x; })
        .attr("y1", function(d) { return d.source.y; })
        .attr("x2", function(d) { return d.target.x; })
        .attr("y2", function(d) { return d.target.y; });

    node
        .attr("cx", function(d) { return d.x; })
        .attr("cy", function(d) { return d.y; });
		
	// validate:
	if (alpha < 0.001) {
		link.each(function(d,i) {
		
			var a = d.source.x - d.target.x;
			var b = d.source.y - d.target.y;
		    var c = Math.pow(a*a + b*b, 0.5);
			
			console.log("specified length: " + graph.links[i].distance + ", realized distance: " + c );
		})
	}
  }
.links line {
  stroke: #999;
  stroke-opacity: 0.6;
}

.nodes circle {
  stroke: #fff;
  stroke-width: 1.5px;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/4.10.0/d3.min.js"></script>
<svg width="500" height="300"></svg>

Depending on the complexity of the graph, you might need to tailor cool down time, the repelling force strength and how you alter it as alpha cools, velocityDecay (potentially modifying it in the tick function), and/or the distance force itself.

Leave a Comment