Ash
Ash

Reputation: 722

Adjust link start-end point according to node position in D3 graph

Here is my sample code that shows a simple d3 graph which supports node dragging without force layout:

<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>
<!DOCTYPE html>
<meta charset="utf-8">
<style>
   .link {
   stroke: #aaa;
   }
   .node text {
   stroke:#333;
   cursos:pointer;
   }
   .node circle{
   stroke:#fff;
   stroke-width:3px;
   fill:#555;
   }
</style>
<body>
<p id="first"><p>
<p id="second"><p>

   <script>
      var data = {
        "nodes": [{
          "id": "source1",
          "x": 33,
          "y": 133,
          "width": 50,
          "height": 50
        },
          {
            "id": "target1",
            "x": 166,
            "y": 66,
            "width": 50,
            "height": 50
          },
          {
            "id": "source2",
            "x": 250,
            "y": 40,
            "width": 50,
            "height": 50
          },
          {
            "id": "target2",
            "x": 350,
            "y": 133,
            "width": 50,
            "height": 50
          }
        ],
        "links": [{
          "source": "source1",
          "target": "target1",
          "weight": 1,
          "id": "abc"
        },
          {
            "source": "source2",
            "target": "target2",
            "weight": 3,
            "id": "xyz"
          }
        ]
      };

      var width = 1200,
          height = 500
      var svg = d3.select("body").append("svg")
          .attr("width", width)
          .attr("height", height);

      var counterxOrtho = 0;
      // Bootstrap the Drag Capability
      var drag = d3.behavior.drag()
          .on("dragstart", dragstarted)
          .on("drag", dragged)
          .on("dragend", dragended);
      var dragInitiated = false

      function dragstarted(d) {

          d3.selectAll(".node").each(function(d) {
              d3.select(this).classed("selectedNode", function(d) {
                  return d.selected = false;
              })
          })

          d3.select(this).classed("selectedNode", function(d) {
              d.previouslySelected = d.selected;
              return d.selected = true;
          });

          dragInitiated = true

      }

      function dragged(d, i) {

          if (dragInitiated) {
              d3.event.sourceEvent.stopPropagation();

              d3.selectAll(".linkInGraph").attr("d", function(l) {

                  var sourceNode = data.nodes.filter(function(d, i) {
                      return d.id == l.source
                  })[0];

                  var targetNode = data.nodes.filter(function(d, i) {
                      return d.id == l.target
                  })[0];
          
                  if (!(sourceNode.selected || targetNode.selected)) {

                      lineData.length = 0;

                      controlPointsArr = [];

                      l.controlPoints.forEach(function(d) {
                          controlPointsArr.push(d);
                      })

                      for (i = 0; i < controlPointsArr.length; i += 2) {
                          lineData.push({
                              "a": controlPointsArr[i],
                              "b": controlPointsArr[i + 1]
                          });
                      }

                      return lineFunction(lineData)
                  }

                  lineData.length = 0;
                  controlPointsArr = [];
                  var randomVal = 0;

                  randomVal = 25;

                  lineData.push({
                      "a": sourceNode.x + randomVal,
                      "b": sourceNode.y + 50
                  });
                  controlPointsArr.push(sourceNode.x + randomVal);
                  controlPointsArr.push(sourceNode.y + 50);

                  lineData.push({
                      "a": targetNode.x + randomVal,
                      "b": targetNode.y - 8
                  });

                  controlPointsArr.push(targetNode.x + randomVal);

                  controlPointsArr.push(targetNode.y - 8);

                  l.controlPoints = [];
                  for (i = 0; i < controlPointsArr.length; i++) {
                      l.controlPoints.push(controlPointsArr[i]);
                  }

                  return lineFunction(lineData)

              })

              nodes.filter(function(d) {
                      return d.selected;
                  })
                  .each(function(d) {
                      
                      d.x += d3.event.dx;
                      d.y += d3.event.dy;

                      var a = d.id;
                      var b = "\"";
                      var position = 0;
                      var output = [a.slice(0, position), b, a.slice(position)].join('');
                      output += "\"";
                      d3.select("[id=" + output + "]").attr("transform", "translate(" + (d.x) + "," + (d.y) + ")");

                  });

          }

      }

      function dragended(d) {
          if (d3.event.sourceEvent.which == 1) {
              dragInitiated = false;
          }

      }

      var nodes = svg.selectAll(".node")
          .data(data.nodes)
          .enter().append("g").attr("id", function(d) {
              return d.id
          })
          .attr("class", "node").call(drag).attr("transform", function(d, i) {

              return "translate(" + d.x + "," + d.y + ")";
          });

      nodes.append("rect")
          .attr("width", "50").attr("height", "50").attr("fill", "lime").attr("rx", "5")
          .attr("ry", "5").style("stroke", "grey").style("stroke-width", "1.5").style("stroke-dasharray", "").style("opacity", ".9");

      nodes.append("text")
          .attr("dx", 12)
          .attr("dy", ".35em").attr("x", -12).attr("y", 25)
          .text(function(d) {
              return d.id
          });

      var LinkCurve = "linear";

      var lineFunction = d3.svg.line()
          .x(function(d) {
              return d.a;
          })
          .y(function(d) {
              return d.b;
          })
          .interpolate(LinkCurve);

      // Marker elements for edges
      var pathMarker = svg.append("marker").attr("id", "pathMarkerHead").attr("orient", "auto").attr("markerWidth", "8").attr("markerHeight", "12").attr("refX", "0.1").attr("refY", "2");
      pathMarker.append("path").attr("d", "M0,0 V4 L7,2 Z").attr("fill", "navy")

      //

      //The data for our line
      var lineData = [];

      function setupPolyLinks() {

          d3.selectAll(".linkInGraph").remove();

          edges = svg.selectAll("linkInGraph")
              .data(data.links)
              .enter()
              .insert("path", ".node")
              .attr("class", "linkInGraph").attr("id", function(l) {
                  return l.id;
              }).attr("source", function(l) {
                  return l.source;
              }).attr("target", function(l) {
                  return l.target;
              }).attr("marker-end", "url(#pathMarkerHead)").attr("d", function(l) {
                  lineData.length = 0;
                  controlPointsArr = [];
                  var sourceNode = data.nodes.filter(function(d, i) {
                      return d.id == l.source
                  })[0];
                  var targetNode = data.nodes.filter(function(d, i) {
                      return d.id == l.target
                  })[0];

                  lineData.push({
                      "a": sourceNode.x + 25,
                      "b": sourceNode.y + 50
                  });
                  controlPointsArr.push(sourceNode.x + 25);
                  controlPointsArr.push(sourceNode.y + 50);

                  lineData.push({
                      "a": targetNode.x + 25,
                      "b": targetNode.y
                  });
                  controlPointsArr.push(targetNode.x + 25);
                  controlPointsArr.push(targetNode.y);
                  l.controlPoints = [];
                  for (i = 0; i < controlPointsArr.length; i++) {
                      l.controlPoints.push(controlPointsArr[i]);
                  }
                  return lineFunction(lineData)

              }).style("stroke-width", "2").attr("stroke", "blue").attr("fill", "none");
      }
      setupPolyLinks();
   </script>
   

In this graph, while dragging nodes, the associated link always starts and ends at static points i.e. in this case, the link starts from lower middle point of source and end at top middle point of target.

What I want to achieve is when dragging node, the links start and end point should auto adjust like:

-In a case where target is at top and source just below it, then link should start at from top middle of source and end at bottom middle of target.

But in my case it appears like this, which I don't want:

wrong case 1

-In a case where source and target are in a horizontal line where first is source and then target, then link should start at from right middle of source and end at left middle of target.

In my case it is like this:

wrong case 2

And more cases like this...

The idea is for a link to never overlap with its own node while dragging.

Upvotes: 1

Views: 1291

Answers (2)

Ash
Ash

Reputation: 722

Here is another solution I found myself, it uses the angle calculation of dragged nodes to find the best position of links.

<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>
<!DOCTYPE html>
<meta charset="utf-8">

<style>
   .link {
   stroke: #aaa;
   }
   .node text {
   stroke:#333;
   cursos:pointer;
   }
   .node circle{
   stroke:#fff;
   stroke-width:3px;
   fill:#555;
   }
</style>
<body>
<p id="first"><p>
<p id="second"><p>

   <script>
      var data = {
          "nodes": [{
                  "id": "source1",
                  "x": 200,
                  "y": 300,
                  "width": 50,
                  "height": 50
              },
              {
                  "id": "target1",
                  "x": 500,
                  "y": 200,
                  "width": 50,
                  "height": 50
              },
              {
                  "id": "source2",
                  "x": 600,
                  "y": 120,
                  "width": 50,
                  "height": 50
              },
              {
                  "id": "target2",
                  "x": 900,
                  "y": 300,
                  "width": 50,
                  "height": 50
              }
          ],
          "links": [{
                  "source": "source1",
                  "target": "target1",
                  "weight": 1,
                  "id": "abc"
              },
              {
                  "source": "source2",
                  "target": "target2",
                  "weight": 3,
                  "id": "xyz"
              }
          ]
      }

      var width = 1200,
          height = 500
      var svg = d3.select("body").append("svg")
          .attr("width", width)
          .attr("height", height);

      var counterxOrtho = 0;
      // Bootstrap the Drag Capability
      var drag = d3.behavior.drag()
          .on("dragstart", dragstarted)
          .on("drag", dragged)
          .on("dragend", dragended);
      var dragInitiated = false

      function dragstarted(d) {

          d3.selectAll(".node").each(function(d) {
              d3.select(this).classed("selectedNode", function(d) {
                  return d.selected = false;
              })
          })

          d3.select(this).classed("selectedNode", function(d) {
              d.previouslySelected = d.selected;
              return d.selected = true;
          });

          dragInitiated = true

      }

      function dragged(d, i) {

          if (dragInitiated) {
              d3.event.sourceEvent.stopPropagation();

              d3.selectAll(".linksOnUi").attr("d", function(l) {

                  var sourceNode = data.nodes.filter(function(d, i) {
                      return d.id == l.source
                  })[0];

                  var targetNode = data.nodes.filter(function(d, i) {
                      return d.id == l.target
                  })[0];

                  // Angle calculation to check the position of target/source node with respective of it's source/target node
                  // to find where the link should start/end to make it look better while dragging
                  var dy = targetNode.y - sourceNode.y;
                  var dx = targetNode.x - sourceNode.x;
                  var theta = Math.atan2(dy, dx);
                  theta *= 180 / Math.PI;

                  var SourceMX = 0;
                  var SourceMY = 0;
                  var TargetMX = 0;
                  var TargetMY = 0;

                  if (theta <= 170 && theta >= 10) {
                      SourceMX = 0;
                      SourceMY = 0;
                      TargetMX = 0;
                      TargetMY = 0;
                  } else if ((theta <= 180 && theta >= 170) || (theta <= -150 && theta >= -180)) {
                      SourceMX = -sourceNode.width / 2;
                      SourceMY = -sourceNode.height / 2;
                      TargetMX = targetNode.width / 2 + 8;
                      TargetMY = targetNode.height / 2;
                  } else if (theta <= -45 && theta >= -150) {
                      SourceMX = 0;
                      SourceMY = -sourceNode.height;
                      TargetMX = 0;
                      TargetMY = targetNode.height + 14;
                  } else {
                      SourceMX = sourceNode.width / 2;
                      SourceMY = -sourceNode.height / 2;
                      TargetMX = -targetNode.width / 2 - 8;
                      TargetMY = targetNode.height / 2;
                  }

                  if (!(sourceNode.selected || targetNode.selected)) {

                      lineData.length = 0;

                      controlPointsArr = [];

                      l.controlPoints.forEach(function(d) {
                          controlPointsArr.push(d);
                      })

                      for (i = 0; i < controlPointsArr.length; i += 2) {
                          lineData.push({
                              "a": controlPointsArr[i],
                              "b": controlPointsArr[i + 1]
                          });
                      }

                      return lineFunction(lineData)
                  }

                  lineData.length = 0;
                  controlPointsArr = [];
                  var randomVal = 0;

                  randomVal = 25;

                  lineData.push({
                      "a": sourceNode.x + randomVal + SourceMX,
                      "b": sourceNode.y + 50 + SourceMY
                  });
                  controlPointsArr.push(sourceNode.x + randomVal + SourceMX);
                  controlPointsArr.push(sourceNode.y + 50 + SourceMY);

                  lineData.push({
                      "a": targetNode.x + randomVal + TargetMX,
                      "b": targetNode.y - 8 + TargetMY
                  });

                  controlPointsArr.push(targetNode.x + randomVal + TargetMX);

                  controlPointsArr.push(targetNode.y - 8 + TargetMY);

                  counterxOrtho = counterxOrtho + .9;

                  if (counterxOrtho > 20) {
                      counterxOrtho = 20
                  }

                  l.controlPoints = [];
                  for (i = 0; i < controlPointsArr.length; i++) {
                      l.controlPoints.push(controlPointsArr[i]);
                  }

                  return lineFunction(lineData)

              })

              nodes.filter(function(d) {
                      return d.selected;
                  })
                  .each(function(d) {
                     
                      d.x += d3.event.dx;
                      d.y += d3.event.dy;

                      var a = d.id;
                      var b = "\"";
                      var position = 0;
                      var output = [a.slice(0, position), b, a.slice(position)].join('');
                      output += "\"";
                      d3.select("[id=" + output + "]").attr("transform", "translate(" + (d.x) + "," + (d.y) + ")");

                  });

          }

      }

      function dragended(d) {
          if (d3.event.sourceEvent.which == 1) {
              dragInitiated = false;
          }

      }

      var nodes = svg.selectAll(".node")
          .data(data.nodes)
          .enter().append("g").attr("id", function(d) {
              return d.id
          })
          .attr("class", "node").call(drag).attr("transform", function(d, i) {

              return "translate(" + d.x + "," + d.y + ")";
          });

      nodes.append("rect")
          .attr("width", "50").attr("height", "50").attr("fill", "lime").attr("rx", "5")
          .attr("ry", "5").style("stroke", "grey").style("stroke-width", "1.5").style("stroke-dasharray", "").style("opacity", ".9");

      nodes.append("text")
          .attr("dx", 12)
          .attr("dy", ".35em").attr("x", -12).attr("y", 25)
          .text(function(d) {
              return d.id
          });

      var LinkCurve = "linear";

      var lineFunction = d3.svg.line()
          .x(function(d) {
              return d.a;
          })
          .y(function(d) {
              return d.b;
          })
          .interpolate(LinkCurve);

      // Marker elements for edges
      var pathMarker = svg.append("marker").attr("id", "arrowHeadMarker").attr("orient", "auto").attr("markerWidth", "8").attr("markerHeight", "12").attr("refX", "0.1").attr("refY", "2");
      pathMarker.append("path").attr("d", "M0,0 V4 L7,2 Z").attr("fill", "navy")

      //

      //The data for our line
      var lineData = [];

      function linkSetupFuncn() {

          d3.selectAll(".linksOnUi").remove();

          edges = svg.selectAll("linksOnUi")
              .data(data.links)
              .enter()
              .insert("path", ".node")
              .attr("class", "linksOnUi").attr("id", function(l) {
                  return l.id;
              }).attr("source", function(l) {
                  return l.source;
              }).attr("target", function(l) {
                  return l.target;
              }).attr("marker-end", "url(#arrowHeadMarker)").attr("d", function(l) {
                  lineData.length = 0;
                  controlPointsArr = [];
                  var sourceNode = data.nodes.filter(function(d, i) {
                      return d.id == l.source
                  })[0];
                  var targetNode = data.nodes.filter(function(d, i) {
                      return d.id == l.target
                  })[0];

                  lineData.push({
                      "a": sourceNode.x + 25,
                      "b": sourceNode.y + 50
                  });
                  controlPointsArr.push(sourceNode.x + 25);
                  controlPointsArr.push(sourceNode.y + 50);

                  lineData.push({
                      "a": targetNode.x + 25,
                      "b": targetNode.y
                  });
                  controlPointsArr.push(targetNode.x + 25);
                  controlPointsArr.push(targetNode.y);
                  l.controlPoints = [];
                  for (i = 0; i < controlPointsArr.length; i++) {
                      l.controlPoints.push(controlPointsArr[i]);
                  }
                  return lineFunction(lineData)

              }).style("stroke-width", "2").attr("stroke", "blue").attr("fill", "none");
      }
      linkSetupFuncn();
   </script>

Upvotes: 0

Xavier Guihot
Xavier Guihot

Reputation: 61774

Here is a solution which attaches links to the right side of the node being dragged:

var data = {
  "nodes": [{
    "id": "source1",
    "x": 33,
    "y": 133,
    "width": 50,
    "height": 50
  },
    {
      "id": "target1",
      "x": 166,
      "y": 66,
      "width": 50,
      "height": 50
    },
    {
      "id": "source2",
      "x": 250,
      "y": 40,
      "width": 50,
      "height": 50
    },
    {
      "id": "target2",
      "x": 350,
      "y": 133,
      "width": 50,
      "height": 50
    }
  ],
  "links": [{
    "source": "source1",
    "target": "target1",
    "weight": 1,
    "id": "abc"
  },
    {
      "source": "source2",
      "target": "target2",
      "weight": 3,
      "id": "xyz"
    }
  ]
};

let svg = d3.select("svg").attr("width", 1200).attr("height", 500);

// nodes:

let nodes = svg.selectAll(".node")
  .data(data.nodes)
  .enter().append("g")
  .attr("id", d => d.id)
  .attr("class", "node")
  .attr("transform", d => "translate(" + d.x + "," + d.y + ")")
  .call(d3.drag().on("drag", dragged));

nodes.append("rect")
  .attr("width", 50).attr("height", 50)
  .attr("fill", "lime")
  .attr("rx", 5).attr("ry", 5)
  .style("stroke", "grey").style("stroke-width", "1.5").style("stroke-dasharray", "")
  .style("opacity", ".9")
  .style("cursor", "pointer");

nodes.append("text")
  .attr("x", -12).attr("y", 25)
  .attr("dx", 12).attr("dy", ".35em")
  .text(d => d.id)
  .style("cursor", "pointer");

// links:

var pathMarker = svg.append("marker").attr("id", "pathMarkerHead").attr("orient", "auto").attr("markerWidth", "8").attr("markerHeight", "12").attr("refX", "7").attr("refY", "2");
pathMarker.append("path").attr("d", "M0,0 V4 L7,2 Z").attr("fill", "navy");

svg.selectAll("linkInGraph")
  .data(data.links)
  .enter().append("path")
  .attr("class", "linkInGraph")
  .attr("id", d => d.id)
  .attr("d", moveLink)
  .style("stroke-width", "2").attr("stroke", "blue").attr("fill", "none")
  .attr("marker-end", "url(#pathMarkerHead)");

// drag behavior:

function dragged(n) {

  // Move the node:
  d3.select(this)
    .attr(
      "transform",
      d => "translate(" + (d.x = d3.event.x) + "," + (d.y = d3.event.y) + ")"
    );

  // Move the link:
  d3.selectAll(".linkInGraph")
    .filter(l => l.source == n.id || l.target == n.id)
    .attr("d", moveLink)
}

// link position:

function moveLink(l) {

  let nsid = data.nodes.filter(n => n.id == l.source)[0].id;
  let ndid = data.nodes.filter(n => n.id == l.target)[0].id;

  let ns = d3.select("#" + nsid).datum();
  let nd = d3.select("#" + ndid).datum();

  let min = Number.MAX_SAFE_INTEGER;
  let best = {};
  [[25, 0], [50, 25], [25, 50], [0, 25]].forEach(s =>
    [[25, 0], [50, 25], [25, 50], [0, 25]].forEach(d => {
      let dist = Math.hypot(
        (nd.x + d[0]) - (ns.x + s[0]),
        (nd.y + d[1]) - (ns.y + s[1])
      );
      if (dist < min) {
        min = dist;
        best = {
          s: { x: ns.x + s[0], y: ns.y + s[1] },
          d: { x: nd.x + d[0], y: nd.y + d[1] }
        };
      }
    })
  );

  var lineFunction = d3.line().x(d => d.x).y(d => d.y).curve(d3.curveLinear);

  return lineFunction([best.s, best.d]);
}
<script src="https://d3js.org/d3.v5.min.js"></script>
<svg></svg>

Since the goal consists in avoiding overlap between the dragged node and its link, we have to attach links to the appropriate side of its nodes.

For a given link, the optimal node's sides are simply the ones which minimize the length of the link.

The idea is thus to compute the 16 sizes the link can get if it was attached to all combinations of its couple of nodes' sides; which is in our case the cartesian product of [[25, 0], [50, 25], [25, 50], [0, 25]] with itself (where a node's width/height is 50 and each elements of this list is the coordinates of the middle of a node's side).


Note the change in the svg marker at the end of links. I had to translate it a bit within the link in order to have the head of the arrow coincide with the end of the link and thus avoid having the arrow within the node.


Also note that I switched to using d3v5 to avoid making one more d3v3 legacy example (the switch back to d3v3 shouldn't be that hard if necessary).

Upvotes: 3

Related Questions