文章使用的 D3 是 v5 版本,里面涉及了 D3 的一些方法,你可以通过官方文档来学习。

其实用 D3实战 - 箭头来命名这篇文章有标题党的嫌疑,其实箭头的绘制只需要 svg 元素就能实现,但是箭头常常被用于各种线条的开始或者结束点上,这些线条的路径构造器可能来自于 D3。

在业务开发过程中,要拥有一个完美的适用于任何地方,不需要手动改很多参数的箭头是非常难的一件事情,但是不用担心,明白了箭头的API,以及要注意的细节点,就可以完美 cover 业务了。

开始之前先看一下线条上常见的标记:

20191011164555.jpg

圆形和方形标记常用于图表折线图的转折点上,箭头标记用于有方向的连接线上。

元素

在svg 上绘制箭头使用的容器元素是 marker 元素,当然你如果画的是一次性箭头,也可以直接使用 circle rect path 去绘制。

marker是一种可以连结一个或多个path、line、polyline、或polygon的顶点的标志类型。最常见的用例是绘制箭头或在输出结果的线上的标记一个(polymarker)图形。

下面我们通过一个简单的小例子学习一下 marker 的使用:

  1. <svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">
  2. <defs>
  3. <marker
  4. id="arrow"
  5. viewbox="0 0 10 10"
  6. refX="10"
  7. refY="5"
  8. markerWidth="10"
  9. markerHeight="10"
  10. orient="auto"
  11. >
  12. <path d="M 0 0 L 10 5 L 0 10 z"/>
  13. </marker>
  14. </defs>
  15. <path d="M 10 10 L 100 50" marker-end="url(#arrow)" stroke="black" />
  16. </svg>

效果如下:

1.jpg
这个SVG包含了一个marker,以及一条引用marker的基础图形line。我们先来看看marker标签中的内容。它是包含在标签中的,所以它暂时不会被渲染出来。还赋了一个id为arrow。defs 同一级有一个 path 元素,其中 marker-end是表示线条的结束点,它的值是对arrow的引用,至此箭头被渲染到页面上。

接下来我们来看一下marker的属性都是什么作用。

  • id: 定义唯一标识;
  • viewbox: 箭头容器的可视区,如果可视区的尺寸大于箭头的尺寸,箭头会被成比例缩小,如果可视区的尺寸小于箭头的尺寸,箭头会被裁剪;
  • markerWidth: 宽度;
  • markerHeight:高度;
  • orient:“方向”属性指示标记放置在形状上的位置时如何旋转,可选值 auto | auto-start-reverse | |
  • refX:箭头相对原点在 X 轴偏移位置;
  • refY:箭头相对原点在 Y 轴偏移位置;

还有一个例子中没有使用的属性:markerUNits,用于确定marker是否进行缩放。它定义了markerWidth和markerHeight,以及marker的内容本身的坐标系统。

它接受两个值,strokeWidth和userSpaceOnUse。默认值是strokeWidth,这也是大家大多数情况下会设置的值,因为它允许你的marker随着它连接的线进行缩放。

画一个完美的箭头

下面的图展示 refX,refY 的值对箭头位置的影响,图中蓝色的箭头的偏移值为 refX=”5”

20191011164857.jpg

以线条的结束为原点,效果会是这样的:
2.jpg
调整一下参数 refX=”10” refY=”5” 就可以看到最初小例子中的效果,移动的目的是把箭头的结束位置对准线条的中心,但是放大一点看一下效果:

1570793984603-db6d9da2-294a-4f2c-9f6d-3af7a6627ea3.png

发现线条的端口已经超出了,那是不是有方法可以让这个结束点不要超出,能想到的快速解决方案是不是就是重新调整一下偏移位置:refX=”9”

  1. <svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">
  2. <defs>
  3. <marker
  4. id="arrow"
  5. viewbox="0 0 10 10"
  6. refX="9"
  7. refY="5"
  8. markerWidth="10"
  9. markerHeight="10"
  10. orient="auto"
  11. >
  12. <path d="M 0 0 L 10 5 L 0 10 z" />
  13. </marker>
  14. </defs>
  15. <path d="M 10 10 L 100 50" marker-end="url(#arrow)" />
  16. </svg>

1570794155895-fdac98fb-0353-42ff-80f7-e498208051e3.png
很完美,我们改变一下引用箭头的线条宽度:

  1. <path d="M 10 10 L 100 50" marker-end="url(#arrow)" stroke="black" stroke-width="3"/>

依旧很完美。

除了这种方式还有别的方法吗?我们给箭头设置一个border来尝试一下:

  1. <svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">
  2. <defs>
  3. <marker
  4. id="arrow"
  5. viewbox="0 0 12 10"
  6. refX="10"
  7. refY="5"
  8. markerWidth="8"
  9. markerHeight="10"
  10. orient="auto"
  11. >
  12. <path d="M 0 0 L 10 5 L 0 10 z" stroke-width="1" stroke="black" />
  13. </marker>
  14. </defs>
  15. <path d="M 10 10 L 100 50" marker-end="url(#arrow)" stroke="black"/>
  16. </svg>

1570794446734-6de824e4-479b-49f2-85c2-16c5ca1a4a8b.png
增加border之后还需要调整 viewbox, 但是会出现毛刺。

那是否可以通过改变引用线条的结束端点方式,线条有一个属性 stroke-linecap 可以定义结束端点的形式:

  1. <svg width="120" height="120">
  2. <path stroke-linecap="butt" d="M30,30 L30,90" stroke="black" stroke-width="20"/>
  3. <path stroke-linecap="round" d="M60,30 L60,90" stroke="black" stroke-width="20"/>
  4. <path stroke-linecap="square" d="M90,10 L90,90" stroke="black" stroke-width="20"/>
  5. <path d="M30,30 L30,90 M60,30 L60,90 M90,30 L90,90"
  6. stroke="white" />
  7. </svg>

3.jpg
我们把引用线条的结束端点改为round看看效果:

4.jpg

虽然毛刺已经好了很多,但是还是有一点点圆润的毛刺,这种方式需要修改的参数较多,达到的效果也不够完美,对比之后还是调整平移位置的参数更加完美。

箭头应用

接下来我们把这个箭头用在我们一个树形图:

我们有一份数据:

  1. const data = [
  2. {id: 0, name: 'A', parent: null},
  3. {id: 1, name: 'A-B', parent: 0},
  4. {id: 2, name: 'A-C', parent: 0},
  5. {id: 3, name: 'A-B-1', parent: 1},
  6. {id: 4, name: 'A-B-2', parent: 1},
  7. {id: 5, name: 'A-B-3', parent: 1},
  8. {id: 6, name: 'A-B-4', parent: 1},
  9. {id: 7, name: 'A-C-1', parent: 2},
  10. {id: 8, name: 'A-C-2', parent: 2}
  11. ];

通过 D3 的方法把它转化为一份有层级结构的数据:

  1. const stratifyMap = d3.stratify(data)
  2. .id(function(d) {return d.id;})
  3. .parentId(function(d) {return d.parent});
  4. const stratifyData = stratifyMap(data);
  5. const hierarchyData = d3.hierarchy(stratifyData);

再将层级结构的数据通过 D3 的树形布局转化为带有坐标的数据:

  1. const treeLayout = d3.tree().size([200,200]);
  2. const treeData = treeLayout(hierarchyData);

自此,数据已经准备好了,接下来我们就需要把这些数据画到页面上。

我们建一个画布,用于渲染我们需要绘制的内容:

  1. <svg width="960" height="600">
  2. <g transform="translate(5, 5)">
  3. <g class="links"></g>
  4. <g class="nodes"></g>
  5. </g>
  6. </svg>

接着在画布上画上构建好的数据:

  1. const nodeRadius = 20;
  2. d3.select('svg .nodes')
  3. .selectAll('circle.node')
  4. .data(treeData.descendants()) // 所有节点的一个数组
  5. .enter()
  6. .append('circle')
  7. .classed('node', true)
  8. .attr('fill', 'none')
  9. .attr('stroke', 'purple')
  10. .attr('id', function(d) {return 'node_' + d.data.id})
  11. .attr('cx', function(d) {return d.y;})
  12. .attr('cy', function(d) {return d.x;})
  13. .attr('r', nodeRadius);

5.jpg
页面上就出现了一系列圆形,为了让这个层级关系更明显一点,我们需要加上连线:

  1. d3.select('svg .links')
  2. .selectAll('line.link')
  3. .data(treeData.links()) // 所有links数组
  4. .enter()
  5. .append('path')
  6. .attr("d", d3.linkHorizontal().x(d => d.y).y(d => d.x))
  7. .attr("fill", "none")
  8. .classed('link', true)
  9. .style('stroke', 'red')

6.jpg
最后我们在线条上加上箭头

  1. const strokeWidth = 1;
  2. const markerData = {
  3. id: 'arrow',
  4. width: 10,
  5. height: 10,
  6. path: 'M 0,0 L 10,5 L 0,10 Z',
  7. viewbox: '0 0 10 10'
  8. };
  9. const defs = d3.select('svg').append('svg:defs');
  10. const marker = defs
  11. .append('svg:marker')
  12. .attr("id", markerData.id)
  13. .attr('markerHeight', markerData.height)
  14. .attr('markerWidth', markerData.width)
  15. .attr('markerUnits', 'strokeWidth')
  16. .attr('orient', 'auto')
  17. .attr('refX', markerData.width - strokeWidth)
  18. .attr('refY', markerData.height/2)
  19. .attr('viewBox', markerData.viewbox )
  20. .attr('fill', 'red')
  21. .append('svg:path')
  22. .attr('d', markerData.path );

8.jpg

看起来已经很完美了,但是我们放大一点看:

7.jpg

在连接处出现了重叠。

我们针对箭头的平移对节点和连线做一下调整:

默认箭头的宽度是根据连线的宽度来按缩放的,当然你可以通过 markerUnits 属性来改变,所以我们把连线的宽度通过变量 strokeWidth 来保存起来,然后用于设置连线的线宽,接着根据层级深度调整一下节点的位置:

  1. // 给连线增加宽度属性
  2. d3.select('svg .links')
  3. .selectAll('line.link')
  4. .data(treeData.links()) // 所有links数组
  5. .enter()
  6. .append('path')
  7. ...
  8. .style('stroke-width', strokeWidth)
  9. ...
  10. // 调整节点位置
  11. d3.select('svg .nodes')
  12. .selectAll('circle.node')
  13. .data(treeData.descendants()) // 所有节点的一个数组
  14. .enter()
  15. .append('circle')
  16. ...
  17. .attr('cx', function(d) {
  18. if (d.depth > 0) {
  19. return d.y + nodeRadius + strokeWidth;
  20. } else {
  21. return d.y + nodeRadius;
  22. }
  23. })
  24. ...

这样的图形就没有重叠了

9.jpg

小结

虽然箭头在业务的图形绘制中是非常小的点,但是要在业务中画一个完美的箭头也不是一个简单的事情,所谓小地方,大世界。文章虽然“吹毛求疵”地去实现一个箭头,但是在真正的业务开发中更重要还是把握大局,切忌盲目钻牛角尖。

最后附上丘比特之箭的代码

  1. <svg width="300" height="400">
  2. <defs>
  3. <marker id="heart" viewBox="0 0 77 77" refX="20" refY="39" markerWidth="30" markerHeight="30" orient="auto" >
  4. <path fill="#f00" d="M12.707,38.6C-8.594,27.4-1.594,0,22.406,0c30.3,0,51.7,38.5,51.7,38.5s-22.7,38.6-51.7,38.6 C-1.594,77.1-8.594,49.8,12.707,38.6z"></path>
  5. </marker>
  6. <marker id="tail" viewBox="0 0 80 45" refX="50" refY="22" markerWidth="40" markerHeight="30" orient="auto" >
  7. <polygon fill="#600" points="65.397,0 0,0 14.173,22.417 0.001,44.833 65.398,44.833 79.569,22.417 "/>
  8. </marker>
  9. </defs>
  10. <polyline points="50,20 150,20" fill="none" stroke="black" stroke-width="1" marker-end="url(#heart)" marker-start="url(#tail)"></polyline>
  11. </svg>

20191012102311.jpg

拿去,用丘比特之箭插在你的爱人心上吧~