「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 + 单元格高度)− 边距 − 字体大小的一半。

绘制文本的方法

透视表由五部分组成:行头、列头、角头、数据单元格、框架,如下图所示。

其中四种单元格需要重写。翻看源码可以发现绘制文本的关键方法有这几个:drawTextShapegetTextPositiongetTextAndIconPosition。不同单元格的职责不同,实现方式多少有些差异,不过本着少做少错的原则,尽量只修改需要改变的 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 的自定义范围都在当前单元格内。无论是列头、行头还是数据单元格,它们都无法做跨越单元格的自定义。

分析一下需求

  1. 找到每个层级的每个节点的 Tree icon;
  2. 判断每个节点的下一层级的每个节点是否有 Tree icon;
  3. 获取符合条件的每对父子 Tree icon 的位置信息;
  4. 绘制平滑弯折的虚线。

如何找到每个层级的每个节点的 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

树状模式下用虚线连接各层级的 Tree icon

对 S2 贡献自己的一份力量

什么?你说产品和设计的需求用上面两种方法都做不了!那就只能约产品和设计周末爬个山了。

开个玩笑。如果上述两种方法都做不到,一定是 S2 没有暴露足够的属性,可能是出于多方面的考量,也可能是一时疏忽。这种深层次的需求可以在开源社区和 S2 的成员沟通,提一个友善的 issue,或者直接提交 pull request——毕竟在自定义的过程中必不可少地接触了不少 S2 源码,牛刀小试一下也未尝不可。

我自己就是通过开源贡献的方式在 S2 中加入了想要的功能,顺便帮忙修复了一些缺陷。过程中更加熟悉了 S2 的源码,从源码中学到不少东西,顺带补了补 Jest 集成测试,同时也在 S2 交流群内帮其他使用者解决了一些问题。希望有一天能作为社区贡献者加入 AntV 开源团队。

总结

本文通过两个真实需求,介绍了自定义 S2 的完整路径,按成本从低到高依次是:

遇到问题先读文档,有现成的配置是最好不过的;配置覆盖不了,用 S2 的自定义 Hook 重写单元格;需要跨单元格的自由绘制,就拿 S2 暴露的 G Canvas 实例直接画;连引擎能力都不够时,参与开源贡献,把能力做进上游。

S2 的本质还是 Canvas,就是个画板。只要拿到了画笔,就没什么不能实现的。