Set fixed distance between nodes d3-force

Viewed 861

I'm trying to reproduce with d3.js the behaviour of a pull cord light switch.

You can see the code running here or below in the snippet (best to view full screen).

My question is how can I set the distance between nodes to be always the same (as it is in a real cord)?

The only link that should stretch is the one between the two green nodes

I tried to add more strength to the forces but doesn't look good.

//create somewhere to put the force directed graph
  const svg = d3.select("svg"),
    width = +svg.attr("width"),
    height = +svg.attr("height");

  const nodes_data = [
    { name: "c1" },
    { name: "c2" },
    { name: "c3" },
    { name: "c4" },
    { name: "c5" },
    { name: "c6" },
  ];

  const links_data = [
    { source: "c1", target: "c2" },
    { source: "c2", target: "c3" },
    { source: "c3", target: "c4" },
    { source: "c4", target: "c5" },
    { source: "c5", target: "c6" },
  ];

  //set up the simulation

  const simulation = d3.forceSimulation().nodes(nodes_data);

  //add forces

  simulation.force(
    "manyBody",
    d3.forceManyBody().distanceMin(20).distanceMax(21)
  );

  const link_force = d3.forceLink(links_data).distance(40).strength(1);
  link_force.id(function (d) {
    return d.name;
  });

  simulation.force("links", link_force);
  simulation.force("centerx", d3.forceX(width / 2).strength(0.3));
  simulation.force(
    "centery",
    d3
      .forceY()
      .y(function (d, i) {
        return height / 10 + i * 35;
      })
      .strength(function (d, i) {
        return 0.4;
      })
  );

  //draw circles for the nodes
  const node = svg
    .append("g")
    .attr("class", "nodes")
    .selectAll("circle")
    .data(nodes_data)
    .enter()
    .append("circle")
    .attr("r", 10)
    .attr("fill", "red")
    .attr("draggable", "true");

  const circles = d3.selectAll("circle")._groups[0];
  const firstCircle = d3.select(circles[0]);
  const secondCircle = d3.select(circles[1]);
  const lastCircle = d3.select(circles[circles.length - 1]);
  firstCircle.attr("fill", "green").text(function (d) {
    d.fx = width / 2;
    d.fy = height / 10;
    console.log(d.fx, d.fy);
  });

  secondCircle.attr("fill", "green");
  lastCircle.attr("fill", "blue");

  //draw lines for the links
  const link = svg
    .append("g")
    .attr("class", "links")
    .selectAll("line")
    .data(links_data)
    .enter()
    .append("line")
    .attr("stroke-width", 2);

  // The complete tickActions() function
  function tickActions() {
    //update circle positions each tick of the simulation
    node
      .attr("cx", function (d) {
        return d.x;
      })
      .attr("cy", function (d) {
        return d.y;
      });

    //update link positions
    //simply tells one end of the line to follow one node around
    //and the other end of the line to follow the other node around
    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;
      });
  }

  simulation.on("tick", tickActions);

  const drag_handler = d3
    .drag()
    .on("start", drag_start)
    .on("drag", drag_drag)
    .on("end", drag_end);

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

  function drag_drag(event, d) {
    d.fx = event.x;
    d.fy = event.y;
  }

  function drag_end(event, d) {
    if (!event.active) simulation.alphaTarget(0);
    d.fx = null;
    d.fy = null;
    d3.forceY().strength(0.1);
    document.body.style.background == "black"
      ? (document.body.style.background = "white")
      : (document.body.style.background = "black");
    console.log(document.body.style.background == "black");
  }

  drag_handler(lastCircle);
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.2.0/d3.min.js"></script>
<svg width="400" height="400"></svg>

thanks

1 Answers

D3 isn't likely to create a perfect solution without modifying how the force layout works. Staying within the bounds of D3, I have a solution that achieves the desired result (with a minimal bit of elasticity, which may be acceptable).

As I noted in the comment, d3 is balancing a bunch of forces while the simulation runs. As a consequence, the resulting layout is a compromise between the forces. The solution I linked to in my comment gets links of a specified link by dialing down all the other forces as the simulation cools, allowing the other forces to influence the general layout, while the link distance force tweaks the result to ensure links are the proper length.

The same principle can be applied here, but without the benefit of multiple cycles to nudge the nodes to the precise location required.

First we declare all our forces, as usual:

  var manybody = d3.forceManyBody().distanceMin(20).distanceMax(21);
  var x =  d3.forceX(width / 6).strength(0.3)
  var y =  d3.forceY().y(function (d, i) { return height / 10 + i * 35; })
                      .strength(0.4)
  var distance = d3.forceLink(links_data.filter(function(d,i) { return i; }))
     .distance(35)
     .id(function(d) { return d.name; })
     .strength(1);

Then we apply them:

  simulation
      .force("centerx",x)
      .force("centery",y)
      .force("link", distance)
      .force("many", manybody);

Then in the drag start function, we remove all forces except for the link distance function. We also up the alpha and eliminate alpha decay to allow the force to move the nodes as close as possible in a single tick to their intended place:

  function drag_start(event, d) {
    if (!event.active) simulation.alphaTarget(0.3).restart();
    d.fx = d.x;
    d.fy = d.y;
    // Disable other forces:
    simulation.force("centerx",null)
      .force("centery",null)
      .force("many",null);
      
    // Juice the alpha:
    simulation.alpha(1)
      .alphaDecay(0)
    
  }

At the end of the drag, we undo the changes we made on drag start by reapplying the forces, decreasing alpha, and increasing alpha decay:

 function drag_end(event, d) {
    // Reapply forces:
    simulation.force("centerx",x)
      .force("centery",y)
      .force("many",manybody);  
    // De-juice the alpha:
    simulation.alpha(0.2)
      .alphaDecay(0.0228)    

 ...

There are a few idiosyncrasies in the code as compared with canonical D3, but I've just implemented the changes from above:

//create somewhere to put the force directed graph
  const svg = d3.select("svg"),
    width = +svg.attr("width"),
    height = +svg.attr("height");

  const nodes_data = [
    { name: "c1" },
    { name: "c2" },
    { name: "c3" },
    { name: "c4" },
    { name: "c5" },
    { name: "c6" },
  ];

  const links_data = [
    { source: "c1", target: "c2" },
    { source: "c2", target: "c3" },
    { source: "c3", target: "c4" },
    { source: "c4", target: "c5" },
    { source: "c5", target: "c6" },
  ];

  //set up the simulation
  const simulation = d3.forceSimulation().nodes(nodes_data);

  ////////////////////////
  // Changes start: (1/2)

  // Set up forces:
  var manybody = d3.forceManyBody().distanceMin(15).distanceMax(15);
  var x =  d3.forceX(width / 6).strength(0.3)
  var y =  d3.forceY().y(function (d, i) { return 0 + i * 35; })
                      .strength(0.4)
  var distance = d3.forceLink(links_data.filter(function(d,i) { return i; }))
     .distance(35)
     .id(function(d) { return d.name; })
     .strength(1);

  simulation
      .force("centerx",x)
      .force("centery",y)
      .force("link", distance)
      .force("many", manybody);
  // End Changes (1/2)
  /////////////////////////
 
 
  //draw circles for the nodes
  const node = svg
    .append("g")
    .attr("class", "nodes")
    .selectAll("circle")
    .data(nodes_data)
    .enter()
    .append("circle")
    .attr("r", 10)
    .attr("fill", "red")
    .attr("draggable", "true");

  const circles = d3.selectAll("circle")._groups[0];
  const firstCircle = d3.select(circles[0]);
  const secondCircle = d3.select(circles[1]);
  const lastCircle = d3.select(circles[circles.length - 1]);
  firstCircle.attr("fill", "green").text(function (d) {
    d.fx = width / 6;
    d.fy = 0;
    console.log(d.fx, d.fy);
  });

  secondCircle.attr("fill", "green");
  lastCircle.attr("fill", "blue");

  //draw lines for the links
  const link = svg
    .append("g")
    .attr("class", "links")
    .selectAll("line")
    .data(links_data)
    .enter()
    .append("line")
    .attr("stroke-width", 2);

  // The complete tickActions() function
  function tickActions() {
    //update circle positions each tick of the simulation
    node
      .attr("cx", function (d) {
        return d.x;
      })
      .attr("cy", function (d) {
        return d.y;
      });

    //update link positions
    //simply tells one end of the line to follow one node around
    //and the other end of the line to follow the other node around
    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;
      });
  }

  simulation.on("tick", tickActions);

  const drag_handler = d3
    .drag()
    .on("start", drag_start)
    .on("drag", drag_drag)
    .on("end", drag_end);

  ////////////////////////
  // Start changes (2/2)
  function drag_start(event, d) {
    if (!event.active) simulation.alphaTarget(0.3).restart();
    d.fx = d.x;
    d.fy = d.y;
    // Disable other forces:
    simulation.force("centerx",null)
      .force("centery",null)
      .force("many",null);
      
    // Juice the alpha:
    simulation.alpha(1)
      .alphaDecay(0)
    
  }

  function drag_drag(event, d) {
    d.fx = event.x;
    d.fy = event.y;
  }

  function drag_end(event, d) {
    // Reapply forces:
    simulation.force("centerx",x)
      .force("centery",y)
      .force("many",manybody);  
    // De-juice the alpha:
    simulation.alpha(0.2)
      .alphaDecay(0.0228)    

    if (!event.active) simulation.alphaTarget(0);
    d.fx = null;
    d.fy = null;
    d3.forceY().strength(0.1);
    document.body.style.background == "black"
      ? (document.body.style.background = "white")
      : (document.body.style.background = "black");
  }
  // End changes (2/2)
  ////////////////////////

  drag_handler(lastCircle);
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/6.2.0/d3.min.js"></script>
<svg width=400 height=200></svg>

Optional Addition

I haven't empirically tested it, but it appeared to make a slight improvement: the first parameter for simulation.force() is just a name, so that you can replace or remove individual forces, you could potentially apply a force several times if you applied with different names. In the case of link distance, this could nudge links a bit closer each tick:

 var distance = d3.forceLink(links_data.filter(function(d,i) { return i; }))
     .distance(35)
     .id(function(d) { return d.name; })
     .strength(1);

  simulation.force("a", distance);
  simulation.force("b", distance);
  simulation.force("c", distance);
Related