「AntV」用 S2 写一个属于你的透视表
2389 字 · 7 分钟#可视化#S2#Canvas
S2 是一个面向可视分析领域的数据驱动的表可视化引擎。"S" 取自于 "SpreadSheet" 的两个 "S","2" 代表了透视表中的行列两个维度,旨在提供美观、易用、高性能、易扩展的多维表格。
虽然 S2 的文档非常完善,各种属性配置基本也是应有尽有,但是产品和设计的需求总是无穷无尽的。作为一款面向社区大众的开源产品,S2 当然不会做定制开发,那又该如何满足产品和设计的需求呢?好在 S2 不仅拥有基础组件库、业务场景库,同时也具备自由扩展的能力,让开发者既能开箱即用,也能基于自身场景自由发挥。本文将基于真实场景聊聊如何自定义 S2,写一个属于你的透视表。
使用 S2 自定义 Hook 完成自定义需求
我接到了一个需求:对透视表的对齐方式进行优化,支持文本在整个单元格内居上、居下。S2 默认所有数据都在单元格内居中,并没有提供居上居下的配置。但熟读文档之后立马能想到自定义 Hook——继承对应单元格的类,就可以重写表格的元素。具体可以重写的方法可以查看 S2 源码 cell 目录下各个类所包含的方法,不再赘述。
分析一下需求
要做的事情只有两件:找到绘制文本的方法,然后更改文本在 Y 轴上的位置——
- 文本居上:单元格上边 Y + 边距 + 字体大小的一半;
- 文本居中:S2 默认就是居中,不用改;
- 文本居下:单元格下边 Y(上边 Y + 单元格高度)− 边距 − 字体大小的一半。
绘制文本的方法
透视表由五部分组成:行头、列头、角头、数据单元格、框架,如下图所示。
其中四种单元格需要重写。翻看源码可以发现绘制文本的关键方法有这几个:drawTextShape、getTextPosition、getTextAndIconPosition。不同单元格的职责不同,实现方式多少有些差异,不过本着少做少错的原则,尽量只修改需要改变的 Y,改起来就容易多了。
角头
function drawTextShape() {
// ...
const textY = y + (isEmpty(secondLine) ? height / 2 : height / 4)
// ...
}
从源码可以看出,角头需要额外对多行文本的情况做判断——文本居下时要给第二行文本留足空间,需要再减去一个字体大小的高度:
const getTextY = (verticalAlign) => {
switch (verticalAlign) {
case "top":
return y + fontSize / 2 + 4
case "bottom":
return _.isEmpty(secondLine)
? y + height - fontSize / 2 - 4
: y + height - fontSize / 2 - fontSize - 4
case "middle":
return _.isEmpty(secondLine) ? y + height / 2 : y + height / 4
}
}
列头
function getTextPosition() {
// ...
const textY = contentBox.y + contentBox.height / 2
// ...
}
列头没有特殊情况需要判断:
const getY = (align) => {
let y = contentBox.y
switch (align) {
case "middle":
y = contentBox.y + contentBox.height / 2
break
case "top":
y = contentBox.y + textStyle.fontSize / 2 + 4
break
case "bottom":
y = contentBox.y + contentBox.height - textStyle.fontSize / 2 - 4
break
}
return y
}
数据单元格
数据单元格也没有特殊情况,只不过方法名变成了 getTextAndIconPosition。需要注意的是,S2 在不同单元格内使用的 baseline 似乎没有统一,会导致一些处理上的差异。
行头
function getTextPosition() {
// ...
const textY = getAdjustPosition(
textArea.y,
textArea.height,
scrollY,
height,
fontSize
)
// ...
}
/**
* 文本吸附定位计算
* @param rectLeft 矩形左边
* @param rectWidth 矩形宽度
* @param viewportLeft 视窗左边
* @param viewportWidth 视窗宽度
* @param textWidth
* @returns 文本定位坐标 x 或者 y
*
* 画布元素:视窗矩形、矩形、文本,其中文本放置在矩形中
* 矩形从右进入视窗时,交互流程为:
* 1. 文本开始显示,文本贴左边
* 2. 文本还未达到矩形中心位置,继续贴左边
* 3. 文本显示全时,文本贴右边
* 4. 文本居于矩形中心时,文本贴中间
* 5. 文本贴近视窗左边还能显示全,文本贴左边
* 6. 文本开始显示不全时,文本贴右边
*/
export const getAdjustPosition = (
rectLeft: number,
rectWidth: number,
viewportLeft: number,
viewportWidth: number,
textWidth: number,
): number => {
let textX = 0
// 文本居于矩形中间时的坐标
const centerTextLeft = rectLeft + (rectWidth - textWidth) / 2
const centerTextRight = rectLeft + (rectWidth + textWidth) / 2
const viewportRight = viewportLeft + viewportWidth
if (rectLeft + textWidth >= viewportRight) {
// 1. 文本开始显示,文本贴左边
textX = rectLeft
} else if (viewportRight < centerTextRight) {
// 2. 文本还未达到矩形中心位置,继续贴左边
textX = viewportLeft + viewportWidth - textWidth
} else if (viewportLeft > centerTextLeft) {
// 3. 文本显示全时,文本贴右边
if (rectLeft + rectWidth - viewportLeft > textWidth) {
// 5. 文本贴近视窗左边还能显示全,文本贴左边
textX = viewportLeft
} else {
// 6. 文本开始显示不全时,文本贴右边
textX = rectLeft + rectWidth - textWidth
}
} else {
// 4. 文本居于矩形中心时,文本贴中间
textX = rectLeft + (rectWidth - textWidth) / 2
}
return textX
}行头的代码非常有趣:S2 给行头增加了滑动居中的特性,保证行头文本的最大可见,关键就在 getAdjustPosition 函数的实现。应当把代码注释中的「左边」理解为「上边」。代码看着挺长一串,其实很好理解——把「中间」理解为期望文本所处的位置:
当文本从下方出现时,一开始贴上边,能显示全文字后贴下边;随后文本处于期望位置;当文本从上方消失时,一开始贴上边,不能显示全文字时贴下边。
如果理解不了,可以在官网找个例子,把行头高度拉高,滚动行头观察。理解了源码就清楚了:只要修改「期望位置」就可以达到需求。
// 文本居于矩形中间时的坐标
let centerTextLeft
let centerTextRight
switch (align) {
case "middle":
centerTextLeft = rectLeft + (rectWidth - textWidth) / 2
centerTextRight = rectLeft + (rectWidth + textWidth) / 2
break
case "top":
centerTextLeft = rectLeft + paddingY
centerTextRight = rectLeft + textWidth + paddingY
break
case "bottom":
centerTextLeft = rectLeft + rectWidth - textWidth - paddingY
centerTextRight = rectLeft + rectWidth - paddingY
break
}
// ...
// 4. 文本居于矩形中心时,文本贴中间
switch (align) {
case "middle":
textX = rectLeft + (rectWidth - textWidth) / 2
break
case "top":
textX = rectLeft + paddingY
break
case "bottom":
textX = rectLeft + rectWidth - textWidth - paddingY
break
}完整的代码和效果请看 Observable。
后记
时隔几个月重新回顾这个需求,想起当时在官方文档中发现过关于对齐方式的内容,可惜只有数据单元格的设置符合需求。猜想是 S2 将数据单元格内文字的类似行高的属性设置成了单元格高度,不然 textBaseline 也不能达到 verticalAlign 的效果。不过我认为这样欠佳——参考 MDN 对 textBaseline 的定义,容易让人产生误解。
使用原生 Canvas 完成自定义需求
我又接到了一个需求:在透视表的树状模式下,用虚线将各个 Tree icon 连接起来,以达到更好的分层效果。
虽然 S2 提供了很多自定义 Hook 允许开发者重写表格的所有元素,但仔细观察不难发现:自定义 Hook 的自定义范围都在当前单元格内。无论是列头、行头还是数据单元格,它们都无法做跨越单元格的自定义。
分析一下需求
- 找到每个层级的每个节点的 Tree icon;
- 判断每个节点的下一层级的每个节点是否有 Tree icon;
- 获取符合条件的每对父子 Tree icon 的位置信息;
- 绘制平滑弯折的虚线。
如何找到每个层级的每个节点的 Tree icon
S2 的图表实例暴露了 SpreadSheet.getRowNodes(i) 来获取第 i 层级的行头节点,需要搞清楚的是行头究竟有多少层级。
第一种做法:在不清楚一共有多少层级的情况下,在递进循环中判断当前层级是否有行头节点:
for (let i = 0; chart.getRowNodes(i); i++) {
// do something...
}
第二种做法:获取层级信息,找到最深层级。既然都决定要深度自定义了,读源码已经是必不可少的步骤——从源码得知 Node 类上有一个属性叫 hierarchy 代表层级结构,hierarchy.maxLevel 就是树级行头的最大嵌套深度:
function getRowHeaderTreeDepth(chart) {
return chart.getRowNodes(0)[0].hierarchy.maxLevel
}
const rowHeaderTreeDepth = getRowHeaderTreeDepth(chart)
for (let i = 0; i < rowHeaderTreeDepth; i++) {
// do something...
}
判断是否需要绘制虚线并获取 Tree icon 的位置信息
因为数据的层级深度不同,可能存在子节点部分有 Tree icon、部分没有的情况,需要判断:
if (rowNode.children.some((child) => _.get(child, "belongsCell.treeIcon"))) {
// do something...
}
接下来根据 node.belongsCell.treeIcon 获取 Tree icon 的位置信息。我一开始也是直接取,最多加个非空判断,但是踩了两个坑:其一,有时虽然 treeIcon.cfg 存在,但 Tree icon 已经被销毁了,这时再取就不合适了;其二,节点可能不在当前视口内,而对不在视口内的父节点,仍然需要绘制到视口内子节点的连线,这时就要手动计算 Tree icon 的必要信息。
function getTreeIconCfg(node) {
if (
_.get(node, "belongsCell.treeIcon.cfg") &&
!_.get(node, "belongsCell.treeIcon.cfg.destroyed")
) {
return _.get(node, "belongsCell.treeIcon.cfg")
}
// x: 8 是第一个层级 Tree icon 的左边距,node.level * 14 是层级间的距离
// y: 节点上边距 + 节点高度的一半 - Tree icon 高度的一半
// 注意:返回的是 Tree icon 的左上角位置!
return {
x: 8 + node.level * 14,
y: node.y + node.height / 2 - 10 / 2,
width: 10,
height: 10
}
}
// 父节点的 x 应当是 Tree icon 的中间
const x1 = rowNode.x + rowNodeTreeIconCfg.x + rowNodeTreeIconCfg.width / 2
// 父节点的 y 应当是 Tree icon 的下边
const y1 = rowNodeTreeIconCfg.y + rowNodeTreeIconCfg.height
// 子节点的 x 应当是 Tree icon 的左边
const x3 = child.x + childTreeIconCfg.x
// 子节点的 y 应当是 Tree icon 的中间
const y3 = childTreeIconCfg.y + childTreeIconCfg.height / 2如何画平滑弯折的虚线
虽然官网并没有大书特书,但就像其它可视化图表库一样,AntV 也有它底层的渲染引擎 G(S2 使用的是 G@4 版本)。S2 通过图表实例的 SpreadSheet.container 暴露了底层渲染引擎的 Canvas 实例,可以根据这个实例绘制任何想要的图形:
canvas.addShape("polyline", {
attrs: {
points: [[x1, y1], [x2, y2], [x3, y3]],
lineWidth: 1,
lineDash: [2, 3],
lineJoin: "round",
stroke: "#000"
}
})
复习一下:points 是形如 [[x1, y1], [x2, y2], ...] 的点集合,[x1, y1] 是当前层级 Tree icon 的位置,[x3, y3] 是下一层级 Tree icon 的位置,[x2, y2] 是拐角的位置——其实就是 [x1, y3];lineDash 是虚线样式,[2, 3] 表示一段长 2px、间隔 3px;lineJoin: "round" 让拐角是圆角。
最后在合适的事件中注册绘制方法:
chart.on(S2.S2Event.LAYOUT_AFTER_RENDER, () => {
drawDottedLines(dottedLines, chart)
})
上述代码乍一看没什么问题,也是我的第一版代码,其实存在不小的问题🤣。逐个说:
rowNodeTreeIconCfg.y 并不是整个画布中的 y,而是去除了列头高度的 y,需要把列头高度加回来:
function getColHeaderHeight(chart) {
return chart.getColumnNodes(0)[0].hierarchy.height
}
S2 用虚拟滚动实现了 Canvas 内的滚动,y 其实可以大于容器高度,需要减去滚动距离:
function getOffsetHeight(chart) {
return chart.facet.getScrollOffset().scrollY
}
在 G 中绘制超出 Canvas 高度的点会造成奇怪的现象,需要计算视口的高度:
/**
* 获取视口高度(列头 + 数值区域)
*/
function getViewportHeight(chart) {
return chart.facet.panelBBox.viewportHeight + getColHeaderHeight(chart)
}
另外,不是所有折线都需要绘制最后一段——如果最后一段在数据视口的极上方或极下方,就不需要画;还应当保存已绘制的连接线,在每一次新的绘制开始之前销毁旧的:
const x1 = rowNode.x + rowNodeTreeIconCfg.x + rowNodeTreeIconCfg.width / 2
const y1 = colHeaderHeight + rowNodeTreeIconCfg.y + rowNodeTreeIconCfg.height
const x2 = child.x + childTreeIconCfg.x
const y2 = colHeaderHeight + childTreeIconCfg.y + childTreeIconCfg.height / 2
// 第一段(两个点)
const points = [
[
x1,
// Math.max 防止 y 点超出数据视口的上方(offsetHeight 是滚动距离),
// Math.min 防止 y 点超出视口的下方
Math.min(Math.max(y1 - offsetHeight, colHeaderHeight), viewportHeight),
],
[x1, Math.min(Math.max(y2 - offsetHeight, colHeaderHeight), viewportHeight)],
]
// 如果折线最后一段在数据视口的极上方或者极下方,就不需要画最后一段
if (
y2 - offsetHeight >= colHeaderHeight &&
y2 - offsetHeight <= viewportHeight
) {
points.push([x2, y2 - offsetHeight])
}
// ---
// 存储已绘制的连接线,用于销毁
let dottedLines = []
// 在 drawDottedLines 的首行调用
function destroyDottedLines(dottedLines) {
while (dottedLines.length) {
dottedLines.pop().destroy()
}
}
// 因为关联到了滚动距离,需要在滚动事件中也注册绘制方法
chart.on(S2.S2Event.GLOBAL_SCROLL, () => {
drawDottedLines(dottedLines, chart)
})完整代码和效果请看 Observable。
对 S2 贡献自己的一份力量
什么?你说产品和设计的需求用上面两种方法都做不了!那就只能约产品和设计周末爬个山了。
开个玩笑。如果上述两种方法都做不到,一定是 S2 没有暴露足够的属性,可能是出于多方面的考量,也可能是一时疏忽。这种深层次的需求可以在开源社区和 S2 的成员沟通,提一个友善的 issue,或者直接提交 pull request——毕竟在自定义的过程中必不可少地接触了不少 S2 源码,牛刀小试一下也未尝不可。
我自己就是通过开源贡献的方式在 S2 中加入了想要的功能,顺便帮忙修复了一些缺陷。过程中更加熟悉了 S2 的源码,从源码中学到不少东西,顺带补了补 Jest 集成测试,同时也在 S2 交流群内帮其他使用者解决了一些问题。希望有一天能作为社区贡献者加入 AntV 开源团队。
总结
本文通过两个真实需求,介绍了自定义 S2 的完整路径,按成本从低到高依次是:
遇到问题先读文档,有现成的配置是最好不过的;配置覆盖不了,用 S2 的自定义 Hook 重写单元格;需要跨单元格的自由绘制,就拿 S2 暴露的 G Canvas 实例直接画;连引擎能力都不够时,参与开源贡献,把能力做进上游。
S2 的本质还是 Canvas,就是个画板。只要拿到了画笔,就没什么不能实现的。