「AntV」用 S2 写一个属于你的透视表

1,123 阅读12分钟

S2 是一个面向可视分析领域的数据驱动的表可视化引擎。"S" 取自于 "SpreadSheet" 的两个 "S","2" 代表了透视表中的行列两个维度。旨在提供美观、易用、高性能、易扩展的多维表格。

S2.gif

虽然 S2 的文档非常完善,各种属性配置基本也是应有尽有,但是产品和设计的需求总是无穷无尽的,作为一款面向社区大众的开源产品,S2 当然不会做定制开发,那又该如何满足产品和设计的需求呢?好在 S2 不仅拥有基础组件库、业务场景库,同时也具备自由扩展的能力,让开发者既能开箱即用,也能基于自身场景自由发挥。笔者将基于真实场景聊聊如何自定义 S2,写一个属于你的透视表。

使用 S2 自定义 Hook 完成自定义需求

笔者接到了一个需求,需要对透视表的对齐方式进行优化,需要支持文本在当前整个单元格内的居上居下,S2 默认所有数据都是在单元格内居中的,并没有提供在单元格内居上居下的配置,但是熟读文档的笔者立马就想到了自定义 Hook,继承对应单元格的类可以重写表格的元素,具体可以重写的方法可以查看 github.com/antvis/S2/b… 文件夹下的文件中类所包含的方法,不再赘述。

分析一下需求

  1. 找到绘制文本的方法

  2. 更改文本在 Y轴 上的位置

    1. 如果文本设置了居上,当前单元格上边Y + 边距 + 字体大小的一半
    2. 如果文本设置了居中,恭喜你,因为 S2 实现默认都是文本上下居中,不用改
    3. 如果文本设置了居下,当前单元格下边Y(上边Y + 单元格高度) - 边距 - 字体大小的一半

绘制文本的方法

透视表由五部分组成,分别是行头、列头、角头、数据单元格、框架 如下图所示: 222.png 其中 4 种单元格是需要重写的,从 github.com/antvis/S2/t… 下的文件可以发现绘制文件的关键方法 👇

  • 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. 文本开始显示不全时,文本贴右边
 * **************************************************************
 *                    viewportLeft               rectLeft
 *                    ▲                          ▲   centerTextLeft
 *                    |                          |   ▲
 *                    +------------------+       |   |
 *                    |                  |       +------------+
 *                    |                  |       |   +----+   |
 *                    |                  |       |   |Text|   |
 *                    |                  |       |   +----+   |
 *                    |                  |       +------------+
 *                    +------------------+       ◀- rectWidth -▶
 *                    ◀- viewportWidth  -▶
 * ****************************************************************
 */
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 函数的实现,应当将代码注释其中的左边理解为上边,代码看着挺长一串,其实很好理解,将“中间”理解为期望文本所处的位置。

  • 当文本处于从下方出现的情况时:

    • 一开始贴上边
    • 当能够显示全文字,贴下边
  • 文本处于期望位置

  • 当文本处于从上方消失的情况时:

    • 一开始贴上边
    • 当不能够显示全文字时,贴下边

如果理解不了,可以在官网找个例子,把行头高度拉高,滚动行头,至于2. 文本还未达到矩形中心位置,继续贴左边,笔者也无法理解。

现在理解了源码,清楚了其实只要修改期望位置就可以达到需求。

// 文本居于矩形中间时的坐标
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
}

完整的代码和效果请看:observablehq.com/@no-use-tec…

Untitled2.png

后记

笔者时隔几个月重新回顾当时这个需求,想起当时发现在 s2.antv.antgroup.com/manual/adva… 中有关于对齐方式的内容,不过可惜的是只有数据单元格的设置是符合笔者需求的,猜想是 S2 将数据单元格内的文字类似行高的属性设置成了单元格高度,不然 textBaseline 也不能达到 verticalAlign 的效果,不过我认为这样欠佳,参考 developer.mozilla.org/en-US/docs/… 定义,容易让人产生误解。

使用原生 Canvas 完成自定义需求

笔者又接到了一个需求,需要在透视表的树状模式下用虚线将各个 Tree icon 连接起来,以达到更好的分层效果。

Untitled (1).png

虽然 S2 提供了很多的自定义 Hook 允许开发者重写表格的所有元素,但是仔细观察一下不难发现,自定义 Hook 的自定义范围都是在当前单元格内,无论是列头单元格、行头单元格还是数据单元格,它们都无法在彼此之间进行跨越单元格的自定义。

关于如何创建树状模式的透视表,在本篇文章中不做赘述,请移步 S2 官网查看详情。

分析一下需求

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

如何找到每个层级的每个节点的 Tree icon

S2 的图表实例暴露了 SpreadSheet.getRowNodes(i) 来获取当前 i 层级的行头结点,需要注意的就是图表的行头究竟有多少层级。

第一种做法:在不清楚一共就多少层级的情况下,在递进循环中判断当前层级是否有行头结点 👇 js 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 的位置信息

const rowNodes = chart.getRowNodes(i) // 当前层级的行头结点

因为数据的层级深度不同,可能存在子节点部分有 Tree icon,部分没有,需要判断一下 👇

if (rowNode.children.some((child) => _.get(child, "belongsCell.treeIcon"))) {
  // do something...
}

接下来我们只需要根据 node.belongsCell.treeIcon 获取 Tree icon 的位置信息 👇

function getTreeIconCfg(node) {
  return _.get(node, "belongsCell.treeIcon.cfg")
}

笔者一开始也是这么写的,最多就是加了个非空判断,但是却踩了坑:

  1. 有些时候虽然存在 _.get(node, "belongsCell.treeIcon.cfg") ,但是 Tree icon 已经被销毁了,这时候再获取就不合适了
  2. 节点存在不在当前视口的情况,如果不在当前视口,因为对不在当前视口内的父节点仍然需要绘制到当前视口内的节点连线,需要手动计算 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 是每个层级 Tree icon 相隔的距离
    // y: node.y 是节点的上边距位置,加上当前节点的高度的一半,减去 Tree icon 高度的一半,就是当前 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 版本 g.antv.vision/,S2 通过图表的实例 SpreadSheet.container 暴露了底层渲染引擎的 Canvas 实例,可以根据这个实例绘制任何想要的图形

具体绘制折线的 API 文档:g.antv.vision/zh/docs/api…

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
    }
    
  • rowNodeTreeIconCfg.y 虽然是去除了列头高度的 y,但是因为 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,
    // 防止 y 点超出视口的下方
    Math.min(
      // 是防止 y 点超出数据视口的上方
      // offsetHeight 是滚动的距离,S2 规定 y 是可以超出容器的大小的,需要减去滚动的距离
      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)
})

完整代码和效果请看:observablehq.com/@no-use-tec…

Screen Shot 2023-05-29 at 00.07.53.png

对 S2 贡献自己的一份力量

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

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

想要参与 S2 开源贡献的同学可以参照 S2 官网的贡献指南

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

总结

本文通过两个案例,介绍了如何通过 S2 的自定义机制,实现定制化的需求。这里面有几个步骤:

  • 遇到问题先读文档,有现成的配置是最好不过的
  • 使用 S2 自定义 Hook
  • 虽然官网没有提到,但是却可以使用的 S2 暴露的 G Canvas 实例
  • 参与 S2 的开源贡献

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