【问题标题】:Center text over curved links in a force-directed graph力导向图中弯曲链接上的文本居中
【发布时间】:2019-02-22 00:11:03
【问题描述】:

这是第二个问题,它建立在我的上一个问题的基础上 - D3 Force Graph With Arrows and Curved Edges - shorten links so arrow doesnt overlap nodes - 关于如何缩短 d3 力图的弯曲链接。

我最近的挣扎涉及将放置在链接顶部的文本居中,实际上是在链接上方。这是一个可重现的示例,显示了我的问题(对于长代码表示歉意。创建一个可重现的示例需要做很多工作,尽管我目前只处理其中的一小部分):

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>

我知道我的代码的问题在于此处链接文本的 x 和 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); })

...在我的代码中也是如此。我不确定如何更新这些函数以说明链接是曲线(不是从节点到节点的直线)这一事实。

我的项目较大的力图有更多的链接和节点,最好将文本定位在弯曲的链接线的中心。

对此的任何帮助表示赞赏!

【问题讨论】:

    标签: javascript d3.js svg force-layout


    【解决方案1】:

    有几种不同的方法可以解决此问题。最明显的两个是:

    1. 使用getPointAtLength() 获取&lt;path&gt; 的中间,并将文本定位在那里;
    2. 使用&lt;textPath&gt; 元素。

    在我的解决方案中,我将选择 #2 主要是因为使用文本路径,数字可以根据路径的方向翻转,其中一些最终会颠倒(我假设这就是您想要的)。

    所以,我们追加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}`);
    

    ...已经给了路径唯一的ID:

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

    这是包含这些更改的代码:

    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>

    另一方面,如果您不希望某些文本颠倒,请使用getPointAtLength() 获取路径的中间,这是方法#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
    })
    

    这里是演示:

    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>

    这里我假设&lt;path&gt;&lt;text&gt; 的前一个兄弟姐妹。如果实际代码中不是这种情况,请相应更改。

    【讨论】:

    • 一如既往,您的回答非常有帮助我更喜欢文本始终朝上的版本,但是这破坏了我的代码。我的力布局具有允许用户切换节点数量的功能,这会重绘图形,这样做会在 ticked() 函数的 linkText.attr("x") 上引发以下错误TypeError: Cannot read property 'getTotalLength' of null。我将实施另一种方法(将文本倒置)并查看在重新绘制图形时是否会引发相同的错误。
    • @Canovic 问题是我的回答,这只是一个原始演示,假设路径始终是文本的前一个兄弟。在您的代码中,如果没有路径,也应该没有文本,因为linkText 是在links 组上创建的选择,它应该包含一个路径。
    • 是有道理的,并且其他解决方案也可以工作并且更健壮。我仍然更喜欢正面朝上的文本,并且会尝试编写代码来处理,如果没有路径,也不应该有文本。
    • 您的问题似乎是重绘期间那些links 组的 update/enter/exit 模式:如果没有链接,则应该没有组重绘,并且,通过扩展,没有路径和文本。
    猜你喜欢
    • 2013-02-17
    • 1970-01-01
    • 2016-11-12
    • 2018-06-20
    • 2017-01-08
    • 2015-09-18
    • 1970-01-01
    • 2011-07-08
    • 2013-09-05
    相关资源
    最近更新 更多