Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Horizontal link labels in d3 force network

I have a d3 (v3) force network with curved links that looks like this:

enter image description here

What I'm trying to accomplish is to have the links' textPath elements be horizontal since they're numbers and "81" needs to look different from "18". I also would like to have some sort of white shadow/outer glow/background since I'm placing them directly on the links. I have a white stroke in there right now, but it doesn't work quite right since sometimes one digit's stroke intrudes onto the digit next to it.

There is a reproducible example here, which I admittedly have cobbled together from other SO answers: https://jsfiddle.net/2gbekL7m/

The relevant part of the code is:

var link_label = svg.selectAll(".link_label")
  .data(links)
  .enter()
  .append("text")
  .attr("class", "link_label")
  .attr("paint-order", "stroke")
  .attr("stroke", "white")
  .attr("stroke-width", 4)
  .attr("stroke-opacity", 1)
  .attr("stroke-linecap", "butt")
  .attr("stroke-linejoin", "miter")
  .style("fill", "black")
  .attr("dy", 5)
  .append("textPath")
  .attr("startOffset", "50%")
  .attr("xlink:href", function(d, i) {
    return "#link_" + i;
  })
  .text(function(d, i) {
    return d.n;
  });

Does anyone know how I could improve the readability of my link labels by fixing the orientation and adding a background box?

like image 745
Devin Avatar asked May 15 '18 21:05

Devin


2 Answers

First of all, since you didn't post your code, my answer will address the code in the JSFiddle you shared.

Let's tackle your two issues separately:

Positioning the labels

It seems to me that you want to put the labels at the middle of the links, and always horizontal, as a regular text. That being the case, the solution is dropping the textPath, which will actually make your selection simpler:

var link_label = svg.selectAll(".link_label")
    .data(links)
    .enter()
    .append("text")
    .text(function(d, i) {
        return d.n;
    });

Now it's just a matter of getting the middle position of those paths, which we can do using getTotalLength() and getPointAtLength() inside the tick function:

link_label.attr("x", function(d, i) {
        var pathLength = d3.select("#link_" + i).node().getTotalLength();
        d.point = d3.select("#link_" + i).node().getPointAtLength(pathLength / 2);
        return d.point.x
    })
    .attr("y", function(d) {
        return d.point.y
    })

Here I'm storing the value in a property, since it will be the same for both x and y position. That way we avoid unnecessary repeated calculations. Also, someone could argue that the calculation should be placed outside the tick function, to get the path's length only once: unfortunately that's not the case here, because during the simulation the path's length keeps changing constantly.

Text shadows

There are several different approaches here. A simple one, probably not the most beautiful one, is using text-shadow. Here is a simple white shadow up, right, down and left:

.shadow {
    text-shadow: 2px 2px 0 #fff, 2px -2px 0 #fff, -2px 2px 0 #fff, -2px -2px 0 #fff
}

That CSS works on Chrome and FireFox, but not on Safari.

All together, here is the demo:

var nodes = [{
      "ix": 0
    },
    {
      "ix": 1
    },
    {
      "ix": 2
    },
    {
      "ix": 3
    }
  ];

  var links = [{
      "source": 0,
      "target": 2,
      "n": 12
    },
    {
      "source": 0,
      "target": 1,
      "n": 34
    },
    {
      "source": 1,
      "target": 2,
      "n": 56
    },
    {
      "source": 1,
      "target": 0,
      "n": 78
    },
    {
      "source": 0,
      "target": 3,
      "n": 90
    }
  ];

  var w = 400,
    h = 400;

  var force = d3.layout.force()
    .size([w, h])
    .nodes(nodes)
    .links(links)
    .gravity(1)
    .linkDistance(30)
    .charge(-20000)
    .linkStrength(1);

  force.start();

  var svg = d3.select("body").append("svg")
    .attr("width", w)
    .attr("height", h)
    .attr("preserveAspectRatio", "xMinYMin meet")
    .attr("viewBox", "0 0 " + w + " " + h)
    .append("g");

  var marker = svg.selectAll("marker")
    .append("svg:defs")
    .data(["end-arrow"])
    .enter()
    .append("svg:marker")
    .attr("id", String)
    .attr("viewBox", "0 -3 6 6")
    .attr("refX", 11.3)
    .attr("refY", -0.2)
    .attr("markerWidth", 6)
    .attr("markerHeight", 6)
    .attr("orient", "auto")
    .append("svg:path")
    .attr("d", "M0,-3L6,0L0,3");

  var link = svg.selectAll("line.link")
    .data(links)
    .enter()
    .append("svg:path")
    .attr("id", function(d, i) {
      return "link_" + i;
    })
    .style("stroke", "black")
    .style("stroke-width", 2)
    .style("fill", "none")
    .attr("marker-end", "url(#end-arrow)");

  var link_label = svg.selectAll(".link_label")
    .data(links)
    .enter()
    .append("text")
    .style("text-anchor", "middle")
    .style("dominant-baseline", "central")
    .attr("class", "shadow")
    .text(function(d, i) {
      return d.n;
    });

  var node = svg.selectAll(".node")
    .data(force.nodes())
    .enter()
    .append("svg:g")
    .attr("class", "node");

  node.append("svg:circle")
    .attr("r", 10)
    .style("fill", "black");

  node.call(force.drag);

  force.on("tick", function() {
    link.attr("d", function(d) {
      var dx = d.target.x - d.source.x;
      var dy = d.target.y - d.source.y;
      var 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;
    });

    link_label.attr("x", function(d, i) {
        var pathLength = d3.select("#link_" + i).node().getTotalLength();
        d.point = d3.select("#link_" + i).node().getPointAtLength(pathLength / 2);
        return d.point.x
      })
      .attr("y", function(d) {
        return d.point.y
      })
    node.attr("transform", function(d) {
      return ("translate(" + d.x + "," + d.y + ")");
    });
  });
.shadow {
  text-shadow: 2px 2px 0 #fff, 2px -2px 0 #fff, -2px 2px 0 #fff, -2px -2px 0 #fff
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>

Edit: have a look at Xavier's answer, the white circles look way batter than the text shadow.

like image 60
Gerardo Furtado Avatar answered Oct 13 '22 10:10

Gerardo Furtado


Re-using Gerardo's answer with the usage of getPointAtLength(), here is an alternative based on a white circle for the second part of the question, concerning the shadow/background of labels:

var nodes = [{
      "ix": 0
    },
    {
      "ix": 1
    },
    {
      "ix": 2
    },
    {
      "ix": 3
    }
  ];

  var links = [{
      "source": 0,
      "target": 2,
      "n": 12
    },
    {
      "source": 0,
      "target": 1,
      "n": 34
    },
    {
      "source": 1,
      "target": 2,
      "n": 56
    },
    {
      "source": 1,
      "target": 0,
      "n": 78
    },
    {
      "source": 0,
      "target": 3,
      "n": 90
    }
  ];

  var w = 400,
    h = 400;

  var force = d3.layout.force()
    .size([w, h])
    .nodes(nodes)
    .links(links)
    .gravity(1)
    .linkDistance(30)
    .charge(-20000)
    .linkStrength(1);

  force.start();

  var svg = d3.select("body").append("svg")
    .attr("width", w)
    .attr("height", h)
    .attr("preserveAspectRatio", "xMinYMin meet")
    .attr("viewBox", "0 0 " + w + " " + h)
    .append("g");

  var marker = svg.selectAll("marker")
    .append("svg:defs")
    .data(["end-arrow"])
    .enter()
    .append("svg:marker")
    .attr("id", String)
    .attr("viewBox", "0 -3 6 6")
    .attr("refX", 11.3)
    .attr("refY", -0.2)
    .attr("markerWidth", 6)
    .attr("markerHeight", 6)
    .attr("orient", "auto")
    .append("svg:path")
    .attr("d", "M0,-3L6,0L0,3");

  var link = svg.selectAll("line.link")
    .data(links)
    .enter()
    .append("svg:path")
    .attr("id", function(d, i) {
      return "link_" + i;
    })
    .style("stroke", "black")
    .style("stroke-width", 2)
    .style("fill", "none")
    .attr("marker-end", "url(#end-arrow)");

  var link_label_shadow = svg.selectAll(".link_label_shadow")
    .data(links)
    .enter()
    .append("circle")
    .attr("r", 10)
    .style("fill", "white");

  var link_label = svg.selectAll(".link_label")
    .data(links)
    .enter()
    .append("text")
    .style("text-anchor", "middle")
    .style("dominant-baseline", "central")
    .attr("class", "shadow")
    .text(function(d, i) {
      return d.n;
    });

  var node = svg.selectAll(".node")
    .data(force.nodes())
    .enter()
    .append("svg:g")
    .attr("class", "node");

  node.append("svg:circle")
    .attr("r", 10)
    .style("fill", "black");

  node.call(force.drag);

  force.on("tick", function() {
    link.attr("d", function(d) {
      var dx = d.target.x - d.source.x;
      var dy = d.target.y - d.source.y;
      var 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;
    });

    link_label.attr("x", function(d, i) {
        var pathLength = d3.select("#link_" + i).node().getTotalLength();
        d.point = d3.select("#link_" + i).node().getPointAtLength(pathLength / 2);
        return d.point.x
      })
      .attr("y", function(d) {
        return d.point.y
      })
    node.attr("transform", function(d) {
      return ("translate(" + d.x + "," + d.y + ")");
    });

    link_label_shadow.attr("cx", function(d, i) {
        var pathLength = d3.select("#link_" + i).node().getTotalLength();
        d.point = d3.select("#link_" + i).node().getPointAtLength(pathLength / 2);
        return d.point.x
      })
      .attr("cy", function(d) {
        return d.point.y
      })
    node.attr("transform", function(d) {
      return ("translate(" + d.x + "," + d.y + ")");
    });
  });
  
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/3.4.11/d3.min.js"></script>

Same way the label is set using getPointAtLength(), we can include a white circle in between the link and the text label at the same position.

As white circles are created after their associated link, they will be positioned above links, and thus hide their middle section. Then only the labels are inserted and thus are above the white circles.

like image 42
Xavier Guihot Avatar answered Oct 13 '22 10:10

Xavier Guihot