背景

image.png
S2 是 AntV 在多维交叉分析表格领域的解决方案,主要用于看数分析,S2 采用 Canvas 来进行表格绘制 (基于 易用、高效、强大的 2D 可视化渲染引擎 G ) , 同时内置大量的 交互能力 来辅助用户看数,如 行列联动高亮 单选/多选高亮 刷选高亮 行高列宽动态调整 列头隐藏 等,同时还支持 自定义交互, 本文主要介绍 S2 是如何实现这些交互的。

DOM 交互和 Canvas 交互的区别

以单元格点击为例,得益于强大的 CSS3选择器,我们可以准确的监听任意 dom 元素的点击事件

1
2
3
4
<ul class="cell">
<li id="cell1">我是第一个单元格</li>
<li id="cell2">我是第二个单元格</li>
</ul>
1
2
3
4
5
const cell = document.querySelector('.cell > li:first-child');

cell.addEventListener('click', () => {
console.log('第一个单元格:别点我!');
})

但是 canvas 就只有一个 <canvas/> dom 元素

1
<canvas />

如何准确的知道点击的是哪个单元格呢?答案是 事件委托+ 鼠标坐标

1
2
3
4
5
const canvas = document.querySelector('canvas');

canvas.addEventListener('click', () => {
console.log('我点的是哪个单元格?');
})

在 dom 中,有一个很经典的事件冒泡应用场景,那就是 事件委托, 还是以上面的例子,我们可以只监听父级的 ul元素,根据当前的 event.target 来判断当前点击的是哪一个单元格

1
2
3
4
5
6
7
8
const cell = document.querySelector('.cell');

cell.addEventListener('click', (event) => {
const CELL_ID = 'cell1'
if (event.target?.id === CELL_ID) {
console.log('我是第一个单元格');
}
});

所以在 canvas中,我们也可以依葫芦画瓢,不同点是,单元格不再是一个个的 dom 节点,而是一个个 canvas 图形 对应的数据结构,类似于虚拟 dom
image.png

1
const cell = new Shape({ type: 'rect' })
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

public getCell<T extends S2CellType = S2CellType>(event): T {
let parent = event.target;
// 判断当前 target 属于哪一个实例
while (parent && !(parent instanceof Canvas)) {
if (parent instanceof BaseCell) {
// 在单元格中,返回 true
return parent as T;
}
parent = parent.get?.('parent');
}
return null;
}

// antv/g 提供的 Canvas 构造器
const canvas = new Canvas()

canvas.on('click', (event) => {
const cell = this.getCell(event)
})

事件分类

通过事件委托,能够获取到具体触发事件的单元格 ( 具体实现 )

  • 角头单元格点击:S2Event.CORNER_CELL_CLICK
  • 列头单元格点击:S2Event.COL_CELL_CLICK
  • 行头单元格点击:S2Event.ROW_CELL_CLICK
  • 数据单元格点击:S2Event.DATA_CELL_CLICK
  • 单元格双击
  • 单元格右键

image.png
在监听到对应事件后,通过内部的 event emitter 分发出去,从而触发对应的单元格事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
private onCanvasMousedown = (event: CanvasEvent) => {
const cellType = this.spreadsheet.getCellType(event.target);
switch (cellType) {
case CellTypes.DATA_CELL:
this.spreadsheet.emit(S2Event.DATA_CELL_MOUSE_DOWN, event);
break;
case CellTypes.ROW_CELL:
this.spreadsheet.emit(S2Event.ROW_CELL_MOUSE_DOWN, event);
break;
case CellTypes.COL_CELL:
this.spreadsheet.emit(S2Event.COL_CELL_MOUSE_DOWN, event);
break;
case CellTypes.CORNER_CELL:
this.spreadsheet.emit(S2Event.CORNER_CELL_MOUSE_DOWN, event);
break;
case CellTypes.MERGED_CELL:
this.spreadsheet.emit(S2Event.MERGED_CELLS_MOUSE_DOWN, event);
break;
default:
break;
}
};
1
2
3
this.spreadsheet.on(S2event.DATA_CELL_MOUSE_DOWN, (event) => {
console.log('数值单元格点击')
})

交互分类

有了分好类的单元格事件,我们就可以将其排列组合。 比如刷选高亮,就对应 数值单元格的 mousedown+ mousemove+ mouseup 事件,再将获取到的单元格 meta 信息存储在状态机,最后根据交互状态进行 canvas 重绘

交互类型 名称 适用场景
全选 ALL_SELECTED 复制
选中 SELECTED 单选/多选/行列批量选中
未选中 UNSELECTED 点击空白处,ESC 键重置,偶数次点击单元格
悬停 HOVER 行列联动高亮
长时间悬停 HOVER_FOCUS 显示 tooltip
预选中 PREPARE_SELECT 刷选

单选高亮

在线体验
Kapture 2022-03-23 at 11.35.49.gif
鼠标左键单击单元格后,会高亮当前单元格,聚焦当前的数据。

在实现上,其实并没有对当前选中单元格做高亮操作,而是置灰其他所有非选中状态的数值单元格,就像一种 聚光灯效果。

通过 cell.getMeta() 拿到渲染时闭包保存的当前单元格信息,然后调用 interaction.changeState 改变当前交互状态,将状态改为 InteractionStateName.SELECTED

1
2
3
4
5
6
7
8
9
this.spreadsheet.on(S2Event.DATA_CELL_CLICK, (event: CanvasEvent) => {
const cell: DataCell = this.spreadsheet.getCell(event.target);
const meta = cell.getMeta();

interaction.changeState({
cells: [getCellMeta(cell)],
stateName: InteractionStateName.SELECTED,
});
});

最后的 state 为:

1
2
3
4
5
6
7
8
9
10
11
const cell = {
id: 'cell-id' // 单元格唯一标识
colIndex: 0, // 列索引
rowIndex: 0 // 行索引
type: 'cell-type' // 单元格类型
}

const state = {
name: InteractionStateName.SELECTED,
cells: [cell]
}

接下来就是获取到当前可视范围内所有的数值单元格,对它们进行更新

1
2
3
4
5
6
7
8
9
10

public updatePanelGroupAllDataCells() {
this.updateCells(this.getPanelGroupAllDataCells());
}

public updateCells(cells: S2CellType[] = []) {
cells.forEach((cell) => {
cell.update();
});
}

每一个单元格实例会有一个 update方法,最终会根据当前的状态 改变单元格背景色透明度 fillOpacity

1
2
3
4
5
6
7
8
9
10
11
// 简化代码
function update() {
const stateName = this.spreadsheet.interaction.getCurrentStateName();
const fillOpacity = stateName === InteractionStateName.SELECTED ? 1 : 0.2

cell.attrs = {
fillOpacity
}

canvas.draw()
}

行列联动高亮

在线体验
Kapture 2022-03-23 at 11.37.06.gif

当鼠标 hover 在数值单元格上时,会同时高亮对应的行头和列头,也就是 十字高亮效果, 便于用户清晰的知道对应关系,实现上首先和单选一样,先改变状态为 InteractionStateName.HOVER 然后绘制当前单元格的黑色边框

image.png

1
2
3
4
5
6
7
8
9
10
11
12
this.spreadsheet.on(S2Event.DATA_CELL_HOVER, (event: CanvasEvent) => {
const cell = this.spreadsheet.getCell(event.target) as S2CellType;
const { interaction, options } = this.spreadsheet;
const meta = cell?.getMeta() as ViewMeta;

interaction.changeState({
cells: [getCellMeta(cell)],
stateName: InteractionStateName.HOVER,
});

this.updateRowColCells(meta);
}

先绘制数值单元格区域的十字高亮,比较当前单元格和 state 存储的 rowIndex / colIndex 是否一致,如果有一个相同就表示处于同一列/行,对其进行高亮

1
2
3
4
5
6
7
8
9
10
11
12
13

const currentColIndex = this.meta.colIndex;
const currentRowIndex = this.meta.rowIndex;
// 当视图内的 cell 行列 index 与 hover 的 cell 一致,绘制 hover 的十字样式
if (
currentColIndex === currentHoverCell?.colIndex ||
currentRowIndex === currentHoverCell?.rowIndex
) {
this.updateByState(InteractionStateName.HOVER);
} else {
// 当视图内的 cell 行列 index 与 hover 的 cell 不一致,隐藏其他样式
this.hideInteractionShape();
}
1
2
3
cell.attrs = {
backgroundOpacity: '#color'
}

接下来是行头和列头,处理有些许不同,由于透视表行头和列头是多维嵌套的,有父子级关系,不能单纯的比较行/列索引,需要额外比较 单元格 id
image.png
如图,行头我们需要高亮 浙江省/舟山市 列头需要高亮 家具/沙发/数量, 内部对应存储的 id 为

  • 浙江省/舟山市 => root[&] 浙江省 [&] 舟山市
  • 家具/沙发/数量 => root[&] 家具 [&] 沙发 [&]number


所以 浙江省/舟山市家具/沙发/数量 对应的834 数值单元格的 id 为 => root[&] 浙江省 [&] 舟山市-root[&] 家具 [&] 沙发 [&]number, 最后去看行/列头单元格 id 是否为包含关系,高亮即可

1
2
3
4
5
6
7
8
9
const allRowHeaderCells = getActiveHoverRowColCells(
rowId,
interaction.getAllRowHeaderCells(),
this.spreadsheet.isHierarchyTreeType(),
);

forEach(allRowHeaderCells, (cell: RowCell) => {
cell.updateByState(InteractionStateName.HOVER);
});

刷选高亮

在线体验
Kapture 2022-03-23 at 11.40.02.gif

刷选用于对批量单元格数据汇总,本质是一种拖拽的动作,拖拽结束后,需要选中拖拽起始坐标点对角线矩形区域的所有单元格。

image.png

刷选过程中,还需要考虑鼠标已经超过表格区域,此时默认认为用户还想继续刷选可视范围外的单元格 (如有), 也就是滚动刷选,这个在 使用 AntV S2 打造大数据表格组件 已有相关介绍。这里就不再赘述。

刷选和其他交互不同,会有一个 预选中状态,如图,会有一个蓝色的预选中蓝色蒙层,并且该区域单元格显示黑色边框,表示松开鼠标后,这些单元格会被选中,用于给用户一个提示
image.png
首先在点击单元格时记录一个刷选起始点,包含 x/y坐标,rowIndex/colIndex 行/列索引等信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private getBrushPoint(event: CanvasEvent): BrushPoint {
const { scrollY, scrollX } = this.spreadsheet.facet.getScrollOffset();
const originalEvent = event.originalEvent as unknown as OriginalEvent;
const point: Point = {
x: originalEvent?.layerX,
y: originalEvent?.layerY,
};
const cell = this.spreadsheet.getCell(event.target);
const { colIndex, rowIndex } = cell.getMeta();

return {
...point,
rowIndex,
colIndex,
scrollY,
scrollX,
};
}

然后在刷选结束,鼠标松开后,得到一个完整的刷选信息,最后比较当前单元格是否在这个范围即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
return {
start: {
rowIndex: 0,
colIndex: 0,
x: 0,
y: 0,
},
end: {
rowIndex: 2,
colIndex: 2,
x: 200,
y: 200,
},
width: 200,
height: 200,
};
1
2
3
4
5
6
7
8
9
10
private isInBrushRange(meta: ViewMeta) {
const { start, end } = this.getBrushRange();
const { rowIndex, colIndex } = meta;
return (
rowIndex >= start.rowIndex &&
rowIndex <= end.rowIndex &&
colIndex >= start.colIndex &&
colIndex <= end.colIndex
);
}

将获取到单元格信息,存储在 state, 然后重绘

1
2
3
4
5
6
7
8
this.spreadsheet.on(S2Event.GLOBAL_MOUSE_UP, (event) => {
const range = this.getBrushRange();

this.spreadsheet.interaction.changeState({
cells: this.getSelectedCellMetas(range),
stateName: InteractionStateName.SELECTED,
});
}

行高列高动态调整

在线体验
Kapture 2022-03-23 at 11.41.05.gif

S2 默认提供 列等宽布局 行列等宽布局紧凑布局 三种布局方式 (预览), 也可以拖拽行/列头进行动态调整,要实现这种效果,首先需要绘制调整的热区,也就是如下图这个蓝色的小条,默认情况下是隐藏的,只有在鼠标放在单元格边缘才会显示出来 (还可以 自定义热区范围 )
image.png

细心的同学可能发现了,鼠标放在热区上面,会变成这样一个图标,这个比较有趣,在 CSS中 我们可以给任意元素添加 cursor: col-resize 来实现,在 Canvas中 由于只有 canvas一个 dom 标签,我们则需要判断 hover热区时,给 canvas加上 cursor: col-resize 行内样式,实现同样的效果

image.png

image.png

如果把热区全部显示出来,展示的效果如下:

平铺模式:
image.png
树状模式:
image.png
明细表:
image.png

接下来需要绘制辅助线,和刷选类似,刷选需要显示预选中的遮罩,动态调整需要显示两条辅助线来让用户预览调整之后的单元格宽度

image.png
两条线,对应两条 path, 虚线使用 lineDash实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const attrs: ShapeAttrs = {
path: '',
lineDash: guideLineDash,
stroke: guideLineColor,
strokeWidth: size,
};
// 起始参考线
this.resizeReferenceGroup.addShape('path', {
id: RESIZE_START_GUIDE_LINE_ID,
attrs,
});
// 结束参考线
this.resizeReferenceGroup.addShape('path', {
id: RESIZE_END_GUIDE_LINE_ID,
attrs,
});

在拖动过程中,需要实时更新参考线的位置,需要考虑水平和垂直两种情况,起始点为单元格的底部,结束点为表格区域的底部

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

if (type === ResizeDirectionType.Horizontal) {
startResizeGuideLineShape.attr('path', [
['M', offsetX, offsetY],
['L', offsetX, guideLineMaxHeight],
]);
endResizeGuideLineShape.attr('path', [
['M', offsetX + width, offsetY],
['L', offsetX + width, guideLineMaxHeight],
]);
return;
}

startResizeGuideLineShape.attr('path', [
['M', offsetX, offsetY],
['L', guideLineMaxWidth, offsetY],
]);
endResizeGuideLineShape.attr('path', [
['M', offsetX, offsetY + height],
['L', guideLineMaxWidth, offsetY + height],
]);

这里大写的 ML 熟悉 SVG的同学应该清楚,大写表示绝对定位,小写表示相对定位,对应的含义如下:

1
2
3
4
5
6
7
8
9
10
M = moveto 移动到
L = lineto 连接一根线到
H = horizontal lineto 水平连线
V = vertical lineto 垂直连线
C = curveto
S = smooth curveto
Q = quadratic Belzier curve
T = smooth quadratic Belzier curveto
A = elliptical Arc 椭圆的线 贝塞尔曲线
Z = closepath 结束当前路径

在拖拽完成后,将最新的单元格高度/宽度保存到 s2Options.style 中,重绘更新后,单元格按照最新的大小渲染即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private getResizeWidthDetail(): ResizeDetail {
const { start, end } = this.getResizeGuideLinePosition();
const width = Math.floor(end.x - start.x);
const resizeInfo = this.getResizeInfo();

switch (resizeInfo.effect) {
case ResizeAreaEffect.Cell:
return {
eventType: S2Event.LAYOUT_RESIZE_COL_WIDTH,
style: {
colCfg: {
widthByFieldValue: {
[resizeInfo.id]: width,
},
},
},
};
default:
return null;
}
}

链接跳转

在线体验
image.png
可以给指定单元格的文字加上下划线,表示可以点击跳转
如果使用 DOM 实现,只需要给对应元素加上 a 超链接标签即可,使用 Canvas实现,则需要自己绘制 下划线, 监听点击事件。来模拟 a 标签的效果,核心实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 获取当前文字的包围盒
const { minX, maxX, maxY }: BBox = this.textShape.getBBox();

// 在当前文字下面绘制一根下划线
this.linkFieldShape = renderLine(
this,
{
x1: minX,
y1: maxY + 1,
x2: maxX,
y2: maxY + 1,
},
{ stroke: linkFillColor, lineWidth: 1 },
);

列头隐藏

在线体验

Kapture 2022-03-23 at 11.42.38.gif

透视表和明细表都支持隐藏列头,首先点击列头,显示 tooltip, 然后点击 tooltip 的 隐藏 按钮,同时支持批量/分组隐藏

首先需要知道当前隐藏的列是否需要分组,如果给定的隐藏列不是连续的,比如原始列是 [1,2,3,4,5,6,7], 隐藏列是 [2,3,6], 那么其实在表格上需要显示两个展开按钮 [[2,3],[6]], 核心代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
export const getHiddenColumnsThunkGroup = (
columns: string[],
hiddenColumnFields: string[],
): string[][] => {
if (isEmpty(hiddenColumnFields)) {
return [];
}
// 上一个需要隐藏项的序号
let prevHiddenIndex = Number.NEGATIVE_INFINITY;
return columns.reduce((result, field, index) => {
if (!hiddenColumnFields.includes(field)) {
return result;
}
if (index === prevHiddenIndex + 1) {
const lastGroup = last(result);
lastGroup.push(field);
} else {
const group = [field];
result.push(group);
}
prevHiddenIndex = index;
return result;
}, []);
};

接下来是生成分组信息

1
2
3
4
5
6
7
const detail = {
displaySiblingNode: {
next: Node, // 隐藏列的后一个兄弟节点
prev: Node, // 隐藏列的前一个兄弟节点
}
hideColumnNodes: [Node, ...]
}

image.png
有了这些数据,就能知道展开按钮绘制在哪一个单元格上,展开按钮默认显示在后一个兄弟节点,首尾单元格被隐藏的情况例外,需要反过来
Kapture 2022-03-23 at 16.17.17.gif

除了手动点击进行隐藏,S2 还支持通过声明配置默认隐藏,用于去掉一些不重要数据的干扰,提升看数效率

image.png

1
2
3
4
5
6
7
8
9
10
11
const s2DataConfig = {
fields: {
columns: ['type', 'province', 'city', 'price', 'cost'],
},
}

const s2Options = {
interaction: {
hiddenColumnFields: ['province', 'price'],
},
};

对于明细表,一个 field 就只对应一个列头,对于透视表,一个 field 对应一个或多个列头,只指定 field 的话并不知道需要隐藏哪个列头,需要指定对应列头的 id
image.png

1
2
3
4
5
6
7
const s2Options = {
interaction: {
// 透视表默认隐藏需要指定唯一列头 id
// 可通过 `s2.getColumnNodes()` 获取列头节点查看 id
hiddenColumnFields: ['root[&] 家具 [&] 沙发 [&]number'],
},
};

列头隐藏后,对应的就是展开,展开相对来说就比较简单了,将当前隐藏列配置和展开的列头做一次 diff, 移除相应配置即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
  private handleExpandIconClick(node: Node) {
const lastHiddenColumnsDetail = this.spreadsheet.store.get(
'hiddenColumnsDetail',
[],
);
const { hideColumnNodes = [] } =
lastHiddenColumnsDetail.find(({ displaySiblingNode }) =>
isEqualDisplaySiblingNodeId(displaySiblingNode, node.id),
) || {};

const { hiddenColumnFields: lastHideColumnFields } =
this.spreadsheet.options.interaction;

const willDisplayColumnFields = hideColumnNodes.map(
this.getHideColumnField,
);
const hiddenColumnFields = difference(
lastHideColumnFields,
willDisplayColumnFields,
);

const hiddenColumnsDetail = lastHiddenColumnsDetail.filter(
({ displaySiblingNode }) =>
!isEqualDisplaySiblingNodeId(displaySiblingNode, node.id),
);

this.spreadsheet.setOptions({
interaction: {
hiddenColumnFields,
},
});
this.spreadsheet.store.set('hiddenColumnsDetail', hiddenColumnsDetail);
}
}

最后我们根据这些配置信息,重新构建布局,渲染隐藏/展开列头后的表格即可

自定义交互

在线体验
Kapture 2022-03-23 at 16.33.45.gif
除了上面提到的丰富的内置交互以外,开发者还可以根据 S2 提供的 事件S2Event, 自由排列组合,自定义表格交互,可通过 interaction.customInteractions 注册,比如自定义一个 行列头 hover 显示 tooltip 的交互

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import { PivotSheet, BaseEvent, S2Event } from '@antv/s2';

class RowColumnHoverTooltipInteraction extends BaseEvent {
bindEvents() {
// 行头 hover
this.spreadsheet.on(S2Event.ROW_CELL_HOVER, (event) => {
this.showTooltip(event);
});
// 列头 hover
this.spreadsheet.on(S2Event.COL_CELL_HOVER, (event) => {
this.showTooltip(event);
});
}

showTooltip(event) {
const cell = this.spreadsheet.getCell(event.target);
const meta = cell.getMeta();
const content = meta.value;

this.spreadsheet.tooltip.show({
position: {
x: event.clientX,
y: event.clientY,
},
content,
});
}
}

const s2Options = {
interaction: {
customInteractions: [
{
key: 'RowColumnHoverTooltipInteraction',
interaction: RowColumnHoverTooltipInteraction,
},
],
},
};

const s2 = new PivotSheet(container, dataCfg, s2Options);

s2.render()

结语

以上就是对于 S2 部分交互实现的一些介绍,除此之外,S2 还支持 合并单元格, 自定义滚动速度 等丰富的交互,篇幅有限,就不一一列举了。

也欢迎社区的同学和我们一起共建 AntV/S2,打造最强的开源大数据表格引擎。如果看完这篇文章你有所收获,欢迎给我们的 仓库 Star⭐️ 鼓励。

S2 的相关链接:

参考链接