javascriptd3.jsd3-force-directed

D3 adjust linkText position


I got two issues with my forced graph. The first problem refers to the position of the link text. I added an offSet of 50%, to make sure that each linkText will be centered. This works pretty well if the linkText isn´t long. But it completely looks

akward as soon as the description is longer.

I am not sure if it would be possible to calculate the length of the needed linkText space and somehow subtract it from the given offSet. In general the amount of needed space is there, no clue if it could be used for further calculations.

enter image description here

My second problem relates to the link curves. I added those to be able to visualize bi-directional links. Otherwise those would be on top of each other. The thing is, as soon as you play with a target node and drag them around in a way, that the target node X-posiiton is smaller than the x-position of the source node, the linkText curved wrongly.

enter image description here

Maybe you guys got an idea or hint.

       console.log("D3 Forced Layout ready.")

////////////////////////////////////////////////////////////
//////////////////// D3 Forced Graph ///////////////////////
////////////////////////////////////////////////////////////

    var data = {
        "nodes": [
            { "id": 1 }, 
            { "id": 2 }, 
            { "id": 3 }, 
            { "id": 4 }, 
            { "id": 5 }
        ],
        "links": [
            { "source": 1, "target": 2, "text": "this description is not centered"},
            { "source": 2, "target": 1, "text": "Shorter description" },
            { "source": 2, "target": 3, "text": "Shorter description" },
            { "source": 3, "target": 4, "text": "even shorter" },
            { "source": 4, "target": 5, "text": "shorter" },
            { "source": 5, "target": 1, "text": "short" }
        ]
    }

    initForceLayout()

function initForceLayout() {
    let vw = 800
    let vh = 800

    const svg = d3.select("#chart").append("svg")
                .attr("width", vw)
                .attr("height", vh)
            
    const forceLayout = svg.append("g")
                .attr("id", "forceLayout")
                .call(d3.zoom().on("zoom", function (event) {
                    svg.attr("transform", event.transform)
                }))
                .on("dblclick.zoom", null)

    linksContainer = forceLayout.append("g").attr("class", "linkscontainer")
    nodesContainer = forceLayout.append("g").attr("class", "nodesContainer")
                                
    var simulation = d3.forceSimulation()
        .force("link", d3.forceLink().id(function (d) { return d.id; }).distance(300))
        .force('charge', d3.forceManyBody().strength(-400))
        .force('center', d3.forceCenter(vw / 2, vh / 2))
    
    link = linksContainer.selectAll("g")
        .data(data.links)
        .join("g")
        .attr("cursor", "pointer")

    linkLine = linksContainer.selectAll(".linkPath")
        .data(data.links)
        .join("path")
        .attr("id", function(_,i) {
            return "path" + i
        })
        .attr("stroke", "black")
        .attr("opacity", 0.75)
        .attr("stroke-width", 3)
        .attr("fill", "transparent")

    linkText = linksContainer.selectAll(".linkLabel")
        .data(data.links)
        .join("text")
        .attr("dy", -10)
        .attr("class", "linkLabel")
        .attr("id", function (d, i) {return "linkLabel" + i })
        .text("")

    linkText.append("textPath")
        .attr("xlink:href", function (_, i) {
            return "#path" + i
        })
        .attr("startOffset", "50%")
        .attr("opacity", 0.75)
        .attr("cursor", "pointer")
        .attr("class", "linkText")
        .text(function (d) {
            return d.text
        })
    
    node = nodesContainer.selectAll(".node")
        .data(data.nodes, d => d.id)
        .join("g")
        .attr("class", "node")
        .call(d3.drag()
            .on("start", function(event, d) {
                if (!event.active) simulation.alphaTarget(0.3).restart();
                d.fx = d.x;
                d.fy = d.y;
            })
            .on("drag", function(event, d) {
                d.fx = event.x;
                d.fy = event.y;
            })
            .on("end", function(event, d) {
                if (!event.active) simulation.alphaTarget(0);
                d.fx = undefined;
                d.fy = undefined;       
            })
        )
    
    node.selectAll("circle")
        .data(d => [d])
        .join("circle")
        .attr("r", 30)
        .attr("fill", "whitesmoke")
        .attr("stroke", "white")
        .attr("stroke-width", 2)

    simulation
        .nodes(data.nodes)
        .on("tick", function () {
            // update link positions
            linkLine.attr("d", function (d) {
                if (d.target.x > d.source.x) {    
                    var dx = (d.target.x - d.source.x),
                        dy = (d.target.y - d.source.y),
                        dr = Math.sqrt(dx * dx + dy * dy)
                    return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y;
                } else if (d.target.x < d.source.x) {
                    var dx = (d.target.x - d.source.x),
                        dy = (d.target.y - d.source.y),
                        dr = Math.sqrt(dx * dx + dy * dy)
                    return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y;    
                }
            });
            
            // update node positions
            node
                .attr("transform", function (d) {
                    return "translate(" + d.x + ", " + d.y + ")";
                });

            linkText.attr("transform", function (d) {
                if (d.target.x < d.source.x) {
                    var bbox = this.getBBox();
    
                    rx = bbox.x + bbox.width / 2;
                    ry = bbox.y + bbox.height / 2;
                    return 'rotate(180 ' + rx + ' ' + ry + ')';
                } else if (d.target.x > d.source.x) {
                    var bbox = this.getBBox();
    
                    rx = bbox.x + bbox.width / 2;
                    ry = bbox.y + bbox.height / 2;
                    return 'rotate(0 ' + rx + ' ' + ry + ')';
                }
            })
        })
    
    simulation
        .force("link")
        .links(data.links)

}
    :root {
        --bs-gradient-dark-right: #141727;
        --bs-gradient-dark-left: #3a416f;
    }

    html, body {
        height: 100%;
        width: 100%;
        margin: 0;
        padding: 0;
    }

    body {
        background-color: lightgray;
        overflow: hidden;
    }

    .border-radius-lg {
       border-radius: 0.75rem;
    }

    .svg-container {
        display: inline-block;
        position: relative;
        width: 100%;
        padding-bottom: 100%; /* aspect ratio */
        vertical-align: top;
        overflow: hidden;
    }

    #svg-content-responsive {
        display: inline-block;
        position: absolute;
        top: 10px;
        left: 0;
    }

    svg .rect {
        fill: gold;
        stroke: steelblue;
        stroke-width: 5px;
    }
<!DOCTYPE html>
<html>

<head>
    <meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
    <title>linkText</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <!-- D3.js -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.6.1/d3.min.js" charset="utf-8"></script>    
</head>

<body>
    <div id="chart"></div>
</body>
</html>


Solution

  • Solution

    1. aligning the text to center: added text-anchor:middle property to <text /> is enough to centering the text.

      although it is not necessary, I've also added a text length calculation to textPath element as data attribute. with this, manually aligning the text is also possible.
    textLink./* ... */.each(function(d,i) {
      // ANSWER EDIT: precalculate text width
      var thisWidth = this.getComputedTextLength()
      d3.select(this).attr('data-text-width', () => thisWidth)
    })
    
    1. fixing 180 degree rotation with textPath: to properly swap the Y axis of <textPath /> text, it is better to draw the path that starts from the opposite direction, rather than using transform:rotate(180deg).

      complex transformation does not seem to work properly with <textPath />.

      in this answer, I've swapped the source and target of path to express the 180 degree text rotation.

    EDIT: to avoid two lines collide each other, the sweep flag for SVG path arc has been modified. the flag indicates arc's direction.

    console.log("D3 Forced Layout ready.")
    
    ////////////////////////////////////////////////////////////
    //////////////////// D3 Forced Graph ///////////////////////
    ////////////////////////////////////////////////////////////
    
    var data = {
      "nodes": [{
          "id": 1
        },
        {
          "id": 2
        },
        {
          "id": 3
        },
        {
          "id": 4
        },
        {
          "id": 5
        }
      ],
      "links": [{
          "source": 1,
          "target": 2,
          "text": "this description is not centered"
        },
        {
          "source": 2,
          "target": 1,
          "text": "Shorter description"
        },
        {
          "source": 2,
          "target": 3,
          "text": "Shorter description"
        },
        {
          "source": 3,
          "target": 4,
          "text": "even shorter"
        },
        {
          "source": 4,
          "target": 5,
          "text": "shorter"
        },
        {
          "source": 5,
          "target": 1,
          "text": "short"
        }
      ]
    }
    
    initForceLayout()
    
    function initForceLayout() {
      let vw = 800
      let vh = 800
    
      const svg = d3.select("#chart").append("svg")
        .attr("width", vw)
        .attr("height", vh)
    
      const forceLayout = svg.append("g")
        .attr("id", "forceLayout")
        .call(d3.zoom().on("zoom", function(event) {
          svg.attr("transform", event.transform)
        }))
        .on("dblclick.zoom", null)
    
      linksContainer = forceLayout.append("g").attr("class", "linkscontainer")
      nodesContainer = forceLayout.append("g").attr("class", "nodesContainer")
    
      var simulation = d3.forceSimulation()
        .force("link", d3.forceLink().id(function(d) {
          return d.id;
        }).distance(300))
        .force('charge', d3.forceManyBody().strength(-400))
        .force('center', d3.forceCenter(vw / 2, vh / 2))
    
      link = linksContainer.selectAll("g")
        .data(data.links)
        .join("g")
        .attr("cursor", "pointer")
    
      linkLine = linksContainer.selectAll(".linkPath")
        .data(data.links)
        .join("path")
        .attr("id", function(_, i) {
          return "path" + i
        })
        .attr("stroke", "black")
        .attr("opacity", 0.75)
        .attr("stroke-width", 3)
        .attr("fill", "transparent")
    
      linkText = linksContainer.selectAll(".linkLabel")
        .data(data.links)
        .join("text")
        .attr("dy", -10)
        .attr("class", "linkLabel")
        .attr("id", function(d, i) {
          return "linkLabel" + i
        })
        // ANSWER EDIT: added text-anchor middle property
        // so that the text is centered properly
        .attr('text-anchor', 'middle')
        .text("")
    
      linkText.append("textPath")
        .attr("xlink:href", function(_, i) {
          return "#path" + i
        })
        .attr("opacity", 0.75)
        .attr("cursor", "pointer")
        .attr("class", "linkText")
        .attr('startOffset', '50%') 
        .text(function(d) {
          return d.text
        }).each(function(d,i) {
            // ANSWER EDIT: precalculate text width
            var thisWidth = this.getComputedTextLength()
            d3.select(this).attr('data-text-width', () => thisWidth)
        })
    
      node = nodesContainer.selectAll(".node")
        .data(data.nodes, d => d.id)
        .join("g")
        .attr("class", "node")
        .call(d3.drag()
          .on("start", function(event, d) {
            if (!event.active) simulation.alphaTarget(0.3).restart();
            d.fx = d.x;
            d.fy = d.y;
          })
          .on("drag", function(event, d) {
            d.fx = event.x;
            d.fy = event.y;
          })
          .on("end", function(event, d) {
            if (!event.active) simulation.alphaTarget(0);
            d.fx = undefined;
            d.fy = undefined;
          })
        )
    
      node.selectAll("circle")
        .data(d => [d])
        .join("circle")
        .attr("r", 30)
        .attr("fill", "whitesmoke")
        .attr("stroke", "white")
        .attr("stroke-width", 2)
    
      simulation
        .nodes(data.nodes)
        .on("tick", function() {
          // update link positions
          linkLine.attr("d", function(d) {
            const shouldInvert = d.target.x < d.source.x
            if (!shouldInvert) {
              var dx = (d.target.x - d.source.x),
                dy = (d.target.y - d.source.y),
                dr = Math.sqrt(dx * dx + dy * dy)
    
              return "M" + d.source.x + "," + d.source.y + "A" + dr + "," + dr + " 0 0,1 " + d.target.x + "," + d.target.y;
            } else {
              var dx = (d.target.x - d.source.x),
                dy = (d.target.y - d.source.y),
                dr = Math.sqrt(dx * dx + dy * dy)
              // ANSWER EDIT: swapped source and target
              // ANSWER EDIT2: changed sweep flag 1 to 0
              return "M" + d.target.x + "," + d.target.y + "A" + dr + "," + dr + " 0 0,0 " + d.source.x + "," + d.source.y;
            }
          });
    
          // update node positions
          node
            .attr("transform", function(d) {
              return "translate(" + d.x + ", " + d.y + ")";
            });
          // ANSWER EDIT: removed 180 degree transform
          // this was redundant
        })
    
      simulation
        .force("link")
        .links(data.links)
    
    }
    :root {
      --bs-gradient-dark-right: #141727;
      --bs-gradient-dark-left: #3a416f;
    }
    
    html,
    body {
      height: 100%;
      width: 100%;
      margin: 0;
      padding: 0;
    }
    
    body {
      background-color: lightgray;
      overflow: hidden;
    }
    
    .border-radius-lg {
      border-radius: 0.75rem;
    }
    
    .svg-container {
      display: inline-block;
      position: relative;
      width: 100%;
      padding-bottom: 100%;
      /* aspect ratio */
      vertical-align: top;
      overflow: hidden;
    }
    
    #svg-content-responsive {
      display: inline-block;
      position: absolute;
      top: 10px;
      left: 0;
    }
    
    svg .rect {
      fill: gold;
      stroke: steelblue;
      stroke-width: 5px;
    }
    <!DOCTYPE html>
    <html>
    
    <head>
      <meta http-equiv="Content-Type" content="text/html;charset=utf-8" />
      <title>linkText</title>
      <meta name="viewport" content="width=device-width, initial-scale=1">
    
      <!-- D3.js -->
      <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/7.6.1/d3.min.js" charset="utf-8"></script>
    </head>
    
    <body>
      <div id="chart"></div>
    </body>
    
    </html>