使用背景

说到前端数据可视化,就不得不提到鼎鼎大名的 d3.js。之前因为公司一直都在用 echarts,所以对 d3 了解的不多。幸好前几个星期有个项目要实现一个人物关系的力导向图,并且人物图片要实现圆形。经过在网上查找资料后发现echarts 并没有这样的功能,所以就想到了用 d3 来实现,下面说说大体的实现思路:
image.png
(最终实现效果)

构建力导向图

  1. var width = 1170,
  2. height = 800;
  3. var imgSize = 60;
  4. var svg = d3
  5. .select("body")
  6. .append("svg")
  7. .attr("width", width)
  8. .attr("height", height);
  9. var simulation = d3
  10. .forceSimulation()
  11. .alpha(0.2)
  12. .force(
  13. "link",
  14. d3.forceLink().id(function(d) {
  15. return d.id;
  16. })
  17. )
  18. .force(
  19. "charge",
  20. d3
  21. .forceManyBody()
  22. .distanceMin(200)
  23. .strength(-250)
  24. )
  25. .force("center", d3.forceCenter(width / 2, height / 2))

首先我们去生成一个 svg 节点,利用 d3 基本的 api 就可以实现,然后根据官方文档的介绍,我们要生成一个 simulation。我们利用 d3.forceSimulation 这个方法生成了一个力的模型,之后我们可以在这个框架上面添加我们需要的节点和连线。

创建节点

  1. svg.append("defs")
  2. .selectAll("pattern")
  3. .data(graph.nodes)
  4. .enter()
  5. .append("pattern")
  6. .attr("patternUnits", "objectBoundingBox")
  7. .attr("patternContentUnits", "userSpaceOnUse")
  8. .attr("id", d => d.id)
  9. .attr("width", 1.0)
  10. .attr("height", 1.0)
  11. .append("image")
  12. .attr("width", d => {
  13. if (d.main) {
  14. console.log(1);
  15. return 178;
  16. }
  17. return imgSize;
  18. })
  19. .attr("height", d => {
  20. if (d.main) {
  21. return 178;
  22. }
  23. return imgSize;
  24. })
  25. .attr("xlink:href", d => d.image)
  26. .attr("x", 0)
  27. .attr("y", d => {
  28. if (d.main) {
  29. return 10;
  30. }
  31. return 0;
  32. });

d3 选择器的用法和 jQuery 选择器用法类似。如果没有接触过可以自行查看相关文档,下面说下 d3 中常用的两个方法:enter、exit

enter()

返回数组数据相比对应选中节点多余出的那部分数据,示例如下:

  1. d3.selectAll('line')
  2. .data(data)
  3. .append('line')
  4. .attr('stroke','red')

selectAll 选中当前文档中所有line节点,如果line节点的个数为n,data数组长度为m,则 enter 选中的数据为 n-m 长度的 data 集合,以上代码会将少的那部分 line 节点 append 上去。

exit()

返回选中节点数量比数据长度多出的那部分节点集合

  1. d3.select('text')
  2. .data(data) //data.length==n
  3. .exit()
  4. .remove()

以上代码执行后会将多余的 text 节点删除

创建连线和文字

创建连线和显示文字和创建节点的逻辑基本一样。
创建连线:

  1. var link = svg
  2. .append("g")
  3. .attr("class", "links")
  4. .selectAll("line")
  5. .data(graph.links)
  6. .enter()
  7. .append("line")
  8. .attr("stroke", "#00a0e9");

创建文字:

  1. var linkText = svg
  2. .append("g")
  3. .selectAll("circle")
  4. .enter()
  5. .append("text")
  6. .text(d => d.id);

连接节点和连线

  1. simulation
  2. .nodes(graph.nodes)
  3. .on("tick", ticked)
  4. .force("link")
  5. .links(graph.links);

在节点和连线都准备好的前提下我们可以将节点和连线关联起来,用 simulation.nodes 和simulation.force(“link”).links() 方法。

在这里要提下 tick 事件,节点和连线每次更新都会触发tick事件,因此我们要给 tick 事件增加回调函数,来更新节点和连线还有文字的位置。
下面是 ticked 代码:

  1. function ticked() {
  2. node.attr("cx", d => {
  3. if (d.main) {
  4. return width / 2;
  5. } else if (d.x <= imgSize) {
  6. return imgSize;
  7. } else if (d.x >= width - imgSize) {
  8. return width - imgSize;
  9. }
  10. return d.x;
  11. }).attr("cy", d => {
  12. if (d.main) {
  13. return height / 2;
  14. } else if (d.y <= imgSize) {
  15. return imgSize;
  16. } else if (d.y >= height) {
  17. return height - imgSize;
  18. }
  19. return d.y;
  20. });
  21. link.attr("x1", d => {
  22. if (d.source.main) {
  23. return width / 2;
  24. }
  25. return d.source.x;
  26. })
  27. .attr("y1", d => {
  28. if (d.source.main) {
  29. return height / 2;
  30. }
  31. return d.source.y;
  32. })
  33. .attr("x2", d => {
  34. if (d.target.x <= imgSize) {
  35. return imgSize;
  36. } else if (d.target.x >= width - imgSize) {
  37. return width - imgSize;
  38. }
  39. return d.target.x;
  40. })
  41. .attr("y2", d => {
  42. if (d.target.y <= imgSize) {
  43. return imgSize;
  44. } else if (d.target.y >= height - imgSize) {
  45. return height - imgSize;
  46. }
  47. return d.target.y;
  48. });
  49. linkText
  50. .attr("x", d => {
  51. if (d.main) {
  52. return width / 2 - 20;
  53. } else if (d.x <= imgSize) {
  54. return imgSize - 20;
  55. } else if (d.x >= width - imgSize) {
  56. return width - imgSize - 20;
  57. }
  58. return d.x-20;
  59. })
  60. .attr("y", d => {
  61. if (d.main) {
  62. return height / 2 + 110;
  63. } else if (d.y <= imgSize) {
  64. return imgSize + 50;
  65. } else if (d.y >= height) {
  66. return height - imgSize +50;
  67. }
  68. return d.y+50;
  69. });
  70. }

这里我检测了下边界值,为了不让节点和连线超出边界,但是现在用这样的方法重置节点和连线的位置在拖拽的时候会非常生硬,在广泛查了资料后还是没有找到比较好的解决方法,希望有办法的同学告知~:)

实现拖拽

实现拖拽需要调用 d3.drag,这里采用 call 方法调用 drag 方法

  1. nodes.call(
  2. d3.drag()
  3. .on("start",dragstarted)
  4. .on("drag",dragged)
  5. .on("end",dragended)
  6. )
  7. function dragstarted(d) {
  8. if (!d3.event.active) simulation.alphaTarget(0.3).restart();
  9. d.fx = d.x;
  10. d.fy = d.y;
  11. }
  12. function dragged(d) {
  13. d.fx = d3.event.x;
  14. d.fy = d3.event.y;
  15. }
  16. function dragended(d) {
  17. if (!d3.event.active) simulation.alphaTarget(0);
  18. d.fx = null;
  19. d.fy = null;
  20. }

这里绑定了拖拽的三个时间,分别是开始,拖拽中和拖拽结束。在三个事件回调中去更新节点位置。

圆形图片实现

由于初始状态下 nodes 采用的并不是圆形图片,要实现圆形图片需要借助于 svg 中 circle 和 pattern 标签。关于这方面我是参考的这篇文章来做的 svg圆角
首先对于每一个 node 节点对应生成 pattern 标签,用 g 标签做分组

  1. const timestamp = Date.now()
  2. svg.append("defs")
  3. .data(data.graph)
  4. .append("pattern")
  5. .attr("patternUnits","objectBoundingBox")
  6. .attr("patternContentUnits","userSpaceOnUse")
  7. .attr("id",d=>d.id+'_'+ timestamp)
  8. .append("image")
  9. .attr("xlink:href",d=>d.imageUrl)
  10. nodes.selectAll("circle")
  11. .data(data.graph)
  12. .append("circle")
  13. .attr("fill",d=>`url(#${d.id}_${timestamp})`)

这里先缓存一个一个当前时间戳变量,方便后面为 pattern 标签生成唯一的 id ,然后在 svg 中添加一个 defs 标签,defs 标签用法可以参考相关资料,在这里就不多说啦。然后在 defs 中添加多个 pattern ,为每一个 pattern 生成唯一的 id ,方便后面 circle 标签引用。由于需求中提到主节点和其他分支节点的大小不一样,所以我在这里设置为 pattern 设置了 patternUnits 属性,这个属性可以方便用户定义 pattern 大小。

patternUnits

  1. objectBoundingBox:设置 pattern 大小为相对值,设置范围是0~1
  2. userSpaceOnUser: 设置 pattern 大小为绝对值

    patternContentUnits

  3. objectBoundingBox:设置 pattern 中内容大小为相对值,设置范围是0~1

  4. userSpaceOnUser: 设置 pattern 中内容大小为绝对值

相关资料可以参考 w3cplus 上面相关文章:
https://www.w3cplus.com/svg/svg-pattern-element.html

该效果的主体 内容就这么多,至于图片后面蓝色小点纯粹是为了装饰用,在实际中没有意义。相关实现思路为在每个图片节点后面添加三个小节点数据,然后加上对应的标志,在更新位置与设置样式的时候与其他节点区别对待。

  1. function setEmptyNodes(data, num) {
  2. data.nodes.forEach(item => {
  3. if (!item.main) {
  4. for (let i = 0; i < num; i++) {
  5. data.nodes.push({
  6. id: `${item.id}_empty_${i}`,
  7. name: ``,
  8. empty: true,
  9. parentNode: item.id
  10. });
  11. data.links.push({
  12. source: item.id,
  13. target: `${item.id}_empty_${i}`
  14. });
  15. }
  16. }
  17. });
  18. console.log(data);
  19. }

如此,一个简单的力导向图就大功告成啦。

总结

d3 不愧是前端数据可视化最流行的框架,用了一次就感受到了它无比强大。想比 echarts 来说,d3 更灵活,可以实现更加丰富的效果。最后附上 d3 的官网地址 https://d3js.org/。希望还没有使用过 d3 的小伙伴们可以在今后的工作过程中运用。