2

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!

Gerardo Furtado
  • 100,839
  • 9
  • 121
  • 171
Canovice
  • 9,012
  • 22
  • 93
  • 211

1 Answers1

2

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.

Gerardo Furtado
  • 100,839
  • 9
  • 121
  • 171
  • as always, your answers are very helpful I much prefer the text-always-facing-up version, however this is breaking my code. my force layout has a functionality that allows the user to toggle the number of nodes, which redraws the graph, and doing so throws the following error `TypeError: Cannot read property 'getTotalLength' of null` - on the linkText.attr("x") in the ticked() function. I will implement the other approach (with text upside down) and see if that throws same error when graph is re-drawn. – Canovice Sep 18 '18 at 05:11
  • 1
    @Canovice The thing is that my answer, which is simply a raw demo, assumes that the path is always the previous sibling of the text. In your code, if there is no path there should be no text as well, because `linkText` is a selection created on the `links` group, which should contain a path. – Gerardo Furtado Sep 18 '18 at 05:15
  • makes sense, and the other solution works as well and is more robust. I still prefer the face-up text and will try to code to handle that if there is no path, there should be no text either. – Canovice Sep 18 '18 at 05:19
  • 1
    Your problem here seems to be the *update/enter/exit* pattern for those `links` groups during the redraw: if there is no link, there should be no group in the redraw and, by extension, no path and no text. – Gerardo Furtado Sep 18 '18 at 05:20