Center text over curved links in a force-directed graph

Viewed 846

This is a 2nd question that builds off of this a previous question of mine here - D3 Force Graph With Arrows and Curved Edges - shorten links so arrow doesnt overlap nodes - on how to shorten curved links for a d3 force graph.

My latest struggle involves centering the text placed on top of the links, actually over the links. Here is a reproducible example showing my issue (apologies for the long code. a lot was needed to create a reproducible example, although I am only working on a small bit of it currently):

const svg = d3.select('#mySVG')
const nodesG = svg.select("g.nodes")
const linksG = svg.select("g.links")

var graphs = {
  "nodes": [{
      "name": "Peter",
      "label": "Person",
      "id": 1
    },
    {
      "name": "Michael",
      "label": "Person",
      "id": 2
    },
    {
      "name": "Neo4j",
      "label": "Database",
      "id": 3
    },
    {
      "name": "Graph Database",
      "label": "Database",
      "id": 4
    }
  ],
  "links": [{
      "source": 1,
      "target": 2,
      "type": "KNOWS",
      "since": 2010
    },
    {
      "source": 1,
      "target": 3,
      "type": "FOUNDED"
    },
    {
      "source": 2,
      "target": 3,
      "type": "WORKS_ON"
    },
    {
      "source": 3,
      "target": 4,
      "type": "IS_A"
    }
  ]
}

svg.append('defs').append('marker')
  .attr('id', 'arrowhead')
  .attr('viewBox', '-0 -5 10 10')
  .attr('refX', 0)
  .attr('refY', 0)
  .attr('orient', 'auto')
  .attr('markerWidth', 13)
  .attr('markerHeight', 13)
  .attr('xoverflow', 'visible')
  .append('svg:path')
  .attr('d', 'M 0,-5 L 10 ,0 L 0,5')
  .attr('fill', '#999')
  .style('stroke', 'none');

const simulation = d3.forceSimulation()
  .force("link", d3.forceLink().id(d => d.id))
  .force("charge", d3.forceManyBody())
  .force("center", d3.forceCenter(100, 100));

let linksData = graphs.links.map(link => {
  var obj = link;
  obj.source = link.source;
  obj.target = link.target;
  return obj;
})

const links = linksG
  .selectAll("g")
  .data(graphs.links)
  .enter().append("g")
  .attr("cursor", "pointer")

const linkLines = links
  .append("path")
  .attr('stroke', '#000000')
  .attr('opacity', 0.75)
  .attr("stroke-width", 1)
  .attr("fill", "transparent")
  .attr('marker-end', 'url(#arrowhead)');

const linkText = links
  .append("text")
  .attr("x", d => (d.source.x + (d.target.x - d.source.x) * 0.5))
  .attr("y", d => (d.source.y + (d.target.y - d.source.y) * 0.5))
  .attr('stroke', '#000000')
  .attr("text-anchor", "middle")
  .attr('opacity', 1)
  .text((d,i) => `${i}`);

const nodes = nodesG
  .selectAll("g")
  .data(graphs.nodes)
  .enter().append("g")
  .attr("cursor", "pointer")
  .call(d3.drag()
    .on("start", dragstarted)
    .on("drag", dragged)
    .on("end", dragended));

const circles = nodes.append("circle")
  .attr("r", 12)
  .attr("fill", "000000")

nodes.append("title")
  .text(function(d) {
    return d.id;
  });

simulation
  .nodes(graphs.nodes)
  .on("tick", ticked);

simulation.force("link", d3.forceLink().links(linksData)
  .id((d, i) => d.id)
  .distance(150));

function ticked() {
  linkLines.attr("d", function(d) {
    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;
  });

  // recalculate and back off the distance
  linkLines.attr("d", function(d) {

    // length of current path
    var pl = this.getTotalLength(),
      // radius of circle plus backoff
      r = (12) + 30,
      // position close to where path intercepts circle
      m = this.getPointAtLength(pl - r);

    var dx = m.x - d.source.x,
      dy = m.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 " + m.x + "," + m.y;
  });

  linkText
    .attr("x", function(d) { return (d.source.x + (d.target.x - d.source.x) * 0.5); })
    .attr("y", function(d) { return (d.source.y + (d.target.y - d.source.y) * 0.5); })

  nodes
    .attr("transform", d => `translate(${d.x}, ${d.y})`);
    

}

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

function dragged(d) {
  d.fx = d3.event.x;
  d.fy = d3.event.y;
}

function dragended(d) {
  if (!d3.event.active) simulation.alphaTarget(0);
  d.fx = null;
  d.fy = null;
}
<html lang="en">

<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <script src="//d3js.org/d3.v4.min.js" type="text/javascript"></script>

</head>

<body>
  <svg id="mySVG" width="500" height="500">
  <g class="links" />
 <g class="nodes" />
</svg>

I know that the issue with my code is with the setting of the x and y values for the linkText here:

linkText
  .attr("x", function(d) { return (d.source.x + (d.target.x - d.source.x) * 0.5); })
  .attr("y", function(d) { return (d.source.y + (d.target.y - d.source.y) * 0.5); })

...and also earlier in my code. I am not sure how to update these functions to account for the fact that the links are curved lines (not straight lines from node to node).

The larger force graph for my project has many more links and nodes, and positioning of the text over the center of the curved linkLines would be preferable.

Any help with this is appreciated!

1 Answers

There are several different ways to fix this. The two most obvious ones are:

  1. Using getPointAtLength() to get the middle of the <path>, and positioning the texts there;
  2. Using a <textPath> element.

In my solution I'll choose #2 mainly because, using a text path, the numbers can flip according the paths' orientation, some of them ending up upside down (I'm assuming that this is what you want).

So, we append the textPaths...

const linkText = links
    .append("text")
    .attr("dy", -4)
    .append("textPath")
    .attr("xlink:href", function(_, i) {
        return "#path" + i
    })
    .attr("startOffset", "50%")
    .text((d, i) => `${i}`);

... having given the paths unique ids:

.attr("id", function(_, i) {
    return "path" + i
})

This is the code with those changes:

const svg = d3.select('#mySVG')
const nodesG = svg.select("g.nodes")
const linksG = svg.select("g.links")

var graphs = {
  "nodes": [{
      "name": "Peter",
      "label": "Person",
      "id": 1
    },
    {
      "name": "Michael",
      "label": "Person",
      "id": 2
    },
    {
      "name": "Neo4j",
      "label": "Database",
      "id": 3
    },
    {
      "name": "Graph Database",
      "label": "Database",
      "id": 4
    }
  ],
  "links": [{
      "source": 1,
      "target": 2,
      "type": "KNOWS",
      "since": 2010
    },
    {
      "source": 1,
      "target": 3,
      "type": "FOUNDED"
    },
    {
      "source": 2,
      "target": 3,
      "type": "WORKS_ON"
    },
    {
      "source": 3,
      "target": 4,
      "type": "IS_A"
    }
  ]
}

svg.append('defs').append('marker')
  .attr('id', 'arrowhead')
  .attr('viewBox', '-0 -5 10 10')
  .attr('refX', 0)
  .attr('refY', 0)
  .attr('orient', 'auto')
  .attr('markerWidth', 13)
  .attr('markerHeight', 13)
  .attr('xoverflow', 'visible')
  .append('svg:path')
  .attr('d', 'M 0,-5 L 10 ,0 L 0,5')
  .attr('fill', '#999')
  .style('stroke', 'none');

const simulation = d3.forceSimulation()
  .force("link", d3.forceLink().id(d => d.id))
  .force("charge", d3.forceManyBody())
  .force("center", d3.forceCenter(100, 100));

let linksData = graphs.links.map(link => {
  var obj = link;
  obj.source = link.source;
  obj.target = link.target;
  return obj;
})

const links = linksG
  .selectAll("g")
  .data(graphs.links)
  .enter().append("g")
  .attr("cursor", "pointer")

const linkLines = links
  .append("path")
  .attr("id", function(_, i) {
    return "path" + i
  })
  .attr('stroke', '#000000')
  .attr('opacity', 0.75)
  .attr("stroke-width", 1)
  .attr("fill", "transparent")
  .attr('marker-end', 'url(#arrowhead)');

const linkText = links
  .append("text")
  .attr("dy", -4)
  .append("textPath")
  .attr("xlink:href", function(_, i) {
    return "#path" + i
  })
  .attr("startOffset", "50%")
  .attr('stroke', '#000000')
  .attr('opacity', 1)
  .text((d, i) => `${i}`);

const nodes = nodesG
  .selectAll("g")
  .data(graphs.nodes)
  .enter().append("g")
  .attr("cursor", "pointer")
  .call(d3.drag()
    .on("start", dragstarted)
    .on("drag", dragged)
    .on("end", dragended));

const circles = nodes.append("circle")
  .attr("r", 12)
  .attr("fill", "000000")

nodes.append("title")
  .text(function(d) {
    return d.id;
  });

simulation
  .nodes(graphs.nodes)
  .on("tick", ticked);

simulation.force("link", d3.forceLink().links(linksData)
  .id((d, i) => d.id)
  .distance(150));

function ticked() {
  linkLines.attr("d", function(d) {
    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;
  });

  // recalculate and back off the distance
  linkLines.attr("d", function(d) {

    // length of current path
    var pl = this.getTotalLength(),
      // radius of circle plus backoff
      r = (12) + 30,
      // position close to where path intercepts circle
      m = this.getPointAtLength(pl - r);

    var dx = m.x - d.source.x,
      dy = m.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 " + m.x + "," + m.y;
  });

  linkText
    .attr("x", function(d) {
      return (d.source.x + (d.target.x - d.source.x) * 0.5);
    })
    .attr("y", function(d) {
      return (d.source.y + (d.target.y - d.source.y) * 0.5);
    })

  nodes
    .attr("transform", d => `translate(${d.x}, ${d.y})`);


}

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

function dragged(d) {
  d.fx = d3.event.x;
  d.fy = d3.event.y;
}

function dragended(d) {
  if (!d3.event.active) simulation.alphaTarget(0);
  d.fx = null;
  d.fy = null;
}
<html lang="en">

<head>
  <meta charset="utf-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1">

  <script src="//d3js.org/d3.v4.min.js" type="text/javascript"></script>

</head>

<body>
  <svg id="mySVG" width="500" height="500">
  <g class="links" />
 <g class="nodes" />
</svg>

On the other hand, if you don't want some of the texts upside down, use getPointAtLength() to get the middle of the path, which is the approach #1:

.attr("x", function(d) {
    const length = this.previousSibling.getTotalLength();
    return this.previousSibling.getPointAtLength(length/2).x
})
.attr("y", function(d) {
    const length = this.previousSibling.getTotalLength();
    return this.previousSibling.getPointAtLength(length/2).y
})

Here is the demo:

const svg = d3.select('#mySVG')
const nodesG = svg.select("g.nodes")
const linksG = svg.select("g.links")

var graphs = {
  "nodes": [{
      "name": "Peter",
      "label": "Person",
      "id": 1
    },
    {
      "name": "Michael",
      "label": "Person",
      "id": 2
    },
    {
      "name": "Neo4j",
      "label": "Database",
      "id": 3
    },
    {
      "name": "Graph Database",
      "label": "Database",
      "id": 4
    }
  ],
  "links": [{
      "source": 1,
      "target": 2,
      "type": "KNOWS",
      "since": 2010
    },
    {
      "source": 1,
      "target": 3,
      "type": "FOUNDED"
    },
    {
      "source": 2,
      "target": 3,
      "type": "WORKS_ON"
    },
    {
      "source": 3,
      "target": 4,
      "type": "IS_A"
    }
  ]
}

svg.append('defs').append('marker')
  .attr('id', 'arrowhead')
  .attr('viewBox', '-0 -5 10 10')
  .attr('refX', 0)
  .attr('refY', 0)
  .attr('orient', 'auto')
  .attr('markerWidth', 13)
  .attr('markerHeight', 13)
  .attr('xoverflow', 'visible')
  .append('svg:path')
  .attr('d', 'M 0,-5 L 10 ,0 L 0,5')
  .attr('fill', '#999')
  .style('stroke', 'none');

const simulation = d3.forceSimulation()
  .force("link", d3.forceLink().id(d => d.id))
  .force("charge", d3.forceManyBody())
  .force("center", d3.forceCenter(100, 100));

let linksData = graphs.links.map(link => {
  var obj = link;
  obj.source = link.source;
  obj.target = link.target;
  return obj;
})

const links = linksG
  .selectAll("g")
  .data(graphs.links)
  .enter().append("g")
  .attr("cursor", "pointer")

const linkLines = links
  .append("path")
  .attr('stroke', '#000000')
  .attr('opacity', 0.75)
  .attr("stroke-width", 1)
  .attr("fill", "transparent")
  .attr('marker-end', 'url(#arrowhead)');

const linkText = links
  .append("text")
  .attr("x", function(d) {
    const length = this.previousSibling.getTotalLength();
    return this.previousSibling.getPointAtLength(length / 2).x
  })
  .attr("y", function(d) {
    const length = this.previousSibling.getTotalLength();
    return this.previousSibling.getPointAtLength(length / 2).y
  })
  .attr('stroke', '#000000')
  .attr("text-anchor", "middle")
  .attr("dominant-baseline", "central")
  .attr('opacity', 1)
  .text((d, i) => `${i}`);

const nodes = nodesG
  .selectAll("g")
  .data(graphs.nodes)
  .enter().append("g")
  .attr("cursor", "pointer")
  .call(d3.drag()
    .on("start", dragstarted)
    .on("drag", dragged)
    .on("end", dragended));

const circles = nodes.append("circle")
  .attr("r", 12)
  .attr("fill", "000000")

nodes.append("title")
  .text(function(d) {
    return d.id;
  });

simulation
  .nodes(graphs.nodes)
  .on("tick", ticked);

simulation.force("link", d3.forceLink().links(linksData)
  .id((d, i) => d.id)
  .distance(150));

function ticked() {
  linkLines.attr("d", function(d) {
    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;
  });

  // recalculate and back off the distance
  linkLines.attr("d", function(d) {

    // length of current path
    var pl = this.getTotalLength(),
      // radius of circle plus backoff
      r = (12) + 30,
      // position close to where path intercepts circle
      m = this.getPointAtLength(pl - r);

    var dx = m.x - d.source.x,
      dy = m.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 " + m.x + "," + m.y;
  });

  linkText
    .attr("x", function(d) {
      const length = this.previousSibling.getTotalLength();
      return this.previousSibling.getPointAtLength(length / 2).x
    })
    .attr("y", function(d) {
      const length = this.previousSibling.getTotalLength();
      return this.previousSibling.getPointAtLength(length / 2).y
    })

  nodes
    .attr("transform", d => `translate(${d.x}, ${d.y})`);


}

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

function dragged(d) {
  d.fx = d3.event.x;
  d.fy = d3.event.y;
}

function dragended(d) {
  if (!d3.event.active) simulation.alphaTarget(0);
  d.fx = null;
  d.fy = null;
}
<html lang="en">

  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1">

    <script src="//d3js.org/d3.v4.min.js" type="text/javascript"></script>

  </head>

  <body>
    <svg id="mySVG" width="500" height="500">
  <g class="links" />
 <g class="nodes" />
</svg>

Here I'm assuming that the <path> is the previous sibling of the <text>. If that's not the case in the real code, change this accordingly.

Related