文章使用的 D3 是 v5 版本,里面涉及了 D3 的一些方法,你可以通过官方文档来学习。
其实用 D3实战 - 箭头来命名这篇文章有标题党的嫌疑,其实箭头的绘制只需要 svg 元素就能实现,但是箭头常常被用于各种线条的开始或者结束点上,这些线条的路径构造器可能来自于 D3。
在业务开发过程中,要拥有一个完美的适用于任何地方,不需要手动改很多参数的箭头是非常难的一件事情,但是不用担心,明白了箭头的API,以及要注意的细节点,就可以完美 cover 业务了。
开始之前先看一下线条上常见的标记:
圆形和方形标记常用于图表折线图的转折点上,箭头标记用于有方向的连接线上。
元素
在svg 上绘制箭头使用的容器元素是 marker 元素,当然你如果画的是一次性箭头,也可以直接使用 circle rect path 去绘制。
marker是一种可以连结一个或多个path、line、polyline、或polygon的顶点的标志类型。最常见的用例是绘制箭头或在输出结果的线上的标记一个(polymarker)图形。
下面我们通过一个简单的小例子学习一下 marker 的使用:
<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">
<defs>
<marker
id="arrow"
viewbox="0 0 10 10"
refX="10"
refY="5"
markerWidth="10"
markerHeight="10"
orient="auto"
>
<path d="M 0 0 L 10 5 L 0 10 z"/>
</marker>
</defs>
<path d="M 10 10 L 100 50" marker-end="url(#arrow)" stroke="black" />
</svg>
效果如下:
这个SVG包含了一个marker,以及一条引用marker的基础图形line。我们先来看看marker标签中的内容。它是包含在
接下来我们来看一下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”
以线条的结束为原点,效果会是这样的:
调整一下参数 refX=”10” refY=”5” 就可以看到最初小例子中的效果,移动的目的是把箭头的结束位置对准线条的中心,但是放大一点看一下效果:
发现线条的端口已经超出了,那是不是有方法可以让这个结束点不要超出,能想到的快速解决方案是不是就是重新调整一下偏移位置:refX=”9”
<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">
<defs>
<marker
id="arrow"
viewbox="0 0 10 10"
refX="9"
refY="5"
markerWidth="10"
markerHeight="10"
orient="auto"
>
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
</defs>
<path d="M 10 10 L 100 50" marker-end="url(#arrow)" />
</svg>
很完美,我们改变一下引用箭头的线条宽度:
<path d="M 10 10 L 100 50" marker-end="url(#arrow)" stroke="black" stroke-width="3"/>
依旧很完美。
除了这种方式还有别的方法吗?我们给箭头设置一个border来尝试一下:
<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">
<defs>
<marker
id="arrow"
viewbox="0 0 12 10"
refX="10"
refY="5"
markerWidth="8"
markerHeight="10"
orient="auto"
>
<path d="M 0 0 L 10 5 L 0 10 z" stroke-width="1" stroke="black" />
</marker>
</defs>
<path d="M 10 10 L 100 50" marker-end="url(#arrow)" stroke="black"/>
</svg>
增加border之后还需要调整 viewbox, 但是会出现毛刺。
那是否可以通过改变引用线条的结束端点方式,线条有一个属性 stroke-linecap 可以定义结束端点的形式:
<svg width="120" height="120">
<path stroke-linecap="butt" d="M30,30 L30,90" stroke="black" stroke-width="20"/>
<path stroke-linecap="round" d="M60,30 L60,90" stroke="black" stroke-width="20"/>
<path stroke-linecap="square" d="M90,10 L90,90" stroke="black" stroke-width="20"/>
<path d="M30,30 L30,90 M60,30 L60,90 M90,30 L90,90"
stroke="white" />
</svg>
我们把引用线条的结束端点改为round看看效果:
虽然毛刺已经好了很多,但是还是有一点点圆润的毛刺,这种方式需要修改的参数较多,达到的效果也不够完美,对比之后还是调整平移位置的参数更加完美。
箭头应用
接下来我们把这个箭头用在我们一个树形图:
我们有一份数据:
const data = [
{id: 0, name: 'A', parent: null},
{id: 1, name: 'A-B', parent: 0},
{id: 2, name: 'A-C', parent: 0},
{id: 3, name: 'A-B-1', parent: 1},
{id: 4, name: 'A-B-2', parent: 1},
{id: 5, name: 'A-B-3', parent: 1},
{id: 6, name: 'A-B-4', parent: 1},
{id: 7, name: 'A-C-1', parent: 2},
{id: 8, name: 'A-C-2', parent: 2}
];
通过 D3 的方法把它转化为一份有层级结构的数据:
const stratifyMap = d3.stratify(data)
.id(function(d) {return d.id;})
.parentId(function(d) {return d.parent});
const stratifyData = stratifyMap(data);
const hierarchyData = d3.hierarchy(stratifyData);
再将层级结构的数据通过 D3 的树形布局转化为带有坐标的数据:
const treeLayout = d3.tree().size([200,200]);
const treeData = treeLayout(hierarchyData);
自此,数据已经准备好了,接下来我们就需要把这些数据画到页面上。
我们建一个画布,用于渲染我们需要绘制的内容:
<svg width="960" height="600">
<g transform="translate(5, 5)">
<g class="links"></g>
<g class="nodes"></g>
</g>
</svg>
接着在画布上画上构建好的数据:
const nodeRadius = 20;
d3.select('svg .nodes')
.selectAll('circle.node')
.data(treeData.descendants()) // 所有节点的一个数组
.enter()
.append('circle')
.classed('node', true)
.attr('fill', 'none')
.attr('stroke', 'purple')
.attr('id', function(d) {return 'node_' + d.data.id})
.attr('cx', function(d) {return d.y;})
.attr('cy', function(d) {return d.x;})
.attr('r', nodeRadius);
页面上就出现了一系列圆形,为了让这个层级关系更明显一点,我们需要加上连线:
d3.select('svg .links')
.selectAll('line.link')
.data(treeData.links()) // 所有links数组
.enter()
.append('path')
.attr("d", d3.linkHorizontal().x(d => d.y).y(d => d.x))
.attr("fill", "none")
.classed('link', true)
.style('stroke', 'red')
最后我们在线条上加上箭头
const strokeWidth = 1;
const markerData = {
id: 'arrow',
width: 10,
height: 10,
path: 'M 0,0 L 10,5 L 0,10 Z',
viewbox: '0 0 10 10'
};
const defs = d3.select('svg').append('svg:defs');
const marker = defs
.append('svg:marker')
.attr("id", markerData.id)
.attr('markerHeight', markerData.height)
.attr('markerWidth', markerData.width)
.attr('markerUnits', 'strokeWidth')
.attr('orient', 'auto')
.attr('refX', markerData.width - strokeWidth)
.attr('refY', markerData.height/2)
.attr('viewBox', markerData.viewbox )
.attr('fill', 'red')
.append('svg:path')
.attr('d', markerData.path );
看起来已经很完美了,但是我们放大一点看:
在连接处出现了重叠。
我们针对箭头的平移对节点和连线做一下调整:
默认箭头的宽度是根据连线的宽度来按缩放的,当然你可以通过 markerUnits 属性来改变,所以我们把连线的宽度通过变量 strokeWidth 来保存起来,然后用于设置连线的线宽,接着根据层级深度调整一下节点的位置:
// 给连线增加宽度属性
d3.select('svg .links')
.selectAll('line.link')
.data(treeData.links()) // 所有links数组
.enter()
.append('path')
...
.style('stroke-width', strokeWidth)
...
// 调整节点位置
d3.select('svg .nodes')
.selectAll('circle.node')
.data(treeData.descendants()) // 所有节点的一个数组
.enter()
.append('circle')
...
.attr('cx', function(d) {
if (d.depth > 0) {
return d.y + nodeRadius + strokeWidth;
} else {
return d.y + nodeRadius;
}
})
...
这样的图形就没有重叠了
小结
虽然箭头在业务的图形绘制中是非常小的点,但是要在业务中画一个完美的箭头也不是一个简单的事情,所谓小地方,大世界。文章虽然“吹毛求疵”地去实现一个箭头,但是在真正的业务开发中更重要还是把握大局,切忌盲目钻牛角尖。
最后附上丘比特之箭的代码
<svg width="300" height="400">
<defs>
<marker id="heart" viewBox="0 0 77 77" refX="20" refY="39" markerWidth="30" markerHeight="30" orient="auto" >
<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>
</marker>
<marker id="tail" viewBox="0 0 80 45" refX="50" refY="22" markerWidth="40" markerHeight="30" orient="auto" >
<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 "/>
</marker>
</defs>
<polyline points="50,20 150,20" fill="none" stroke="black" stroke-width="1" marker-end="url(#heart)" marker-start="url(#tail)"></polyline>
</svg>
拿去,用丘比特之箭插在你的爱人心上吧~