2

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

Andrew Reid
  • 37,021
  • 7
  • 64
  • 83
cri_pava
  • 170
  • 11
  • D3 is balancing different forces - to mandate that links must be a certain distance is difficult (and, in some cases, not geometrically possible). [This](https://stackoverflow.com/q/38253560/7106086) answer looks at ensuring specified link distances in a force layout - but the addition of a drag function likely requires a very different solution. – Andrew Reid Dec 14 '20 at 20:14

1 Answers1

1

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);
Andrew Reid
  • 37,021
  • 7
  • 64
  • 83
  • Thanks, that looks way better than mine. As you said, and I thought, the best way is to create a force myself that suits my need. I'm not sure I have enough coding skills to do it, but I will try. Your solution is almost perfect. The only thing is that I should find a way to define a maximum distance between the two green node, and anyway, they should remain connected together. I will let you once I finish wich is exactly the solution I choose. If anyone have other ideas feel free to share – cri_pava Dec 15 '20 at 22:24
  • 1
    @cri_pava, One addition you could make to the above is to apply the link distance multiple times, I didn't check empirically, but it seemed to make a difference visually: `simulation.force("a",distance).force("b",distance)....` – Andrew Reid Dec 16 '20 at 00:09
  • sorry , what do you mean with link ? d3.forceLink().distance() ? – cri_pava Dec 16 '20 at 00:34
  • 1
    @cri_pava, added clarification to the bottom of the answer - hopefully it makes sense of my last comment. – Andrew Reid Dec 16 '20 at 23:14
  • Sorry if I get back just know. So the solution you put above is almost perfect. The only thing is that the two green points should stay "connected" even when there is a drag. I tried to apply a forceCenter to the second green point with the x and y pointing to the first green point but it seems that the force is applied to all the points and not just to the second green – cri_pava Dec 27 '20 at 17:27