背景
S2 是 AntV 在多维交叉分析表格领域的解决方案,主要用于看数分析, S2 采用 canvas
来进行表格绘制 (基于 易用、高效、强大的 2D 可视化渲染引擎 G ) , 同时内置大量的交互能力来辅助用户看数, 如 行列联动高亮
单选/多选高亮
刷选高亮
行高列宽动态调整
列头隐藏
等, 同时还支持 自定义交互
, 本文主要介绍 S2 是如何实现这些交互的。
DOM 交互和 Canvas 交互的区别
以单元格点击为例, 得益于强大的 CSS3
选择器, 我们可以准确的监听任意 dom 元素的点击事件
<ul class="cell"> <li id="cell1">我是第一个单元格</li> <li id="cell2">我是第二个单元格</li></ul>
const cell = document.querySelector('.cell > li:first-child');cell.addEventListener('click', () => { console.log('第一个单元格: 别点我!');})
但是 canvas 就只有一个 <canvas/>
dom 元素
<canvas />
如何准确的知道点击的是哪个单元格呢? 答案是 事件委托
+ 鼠标坐标
const canvas = document.querySelector('canvas'); canvas.addEventListener('click', () => { console.log('我点的是哪个单元格?');})
在 dom 中, 有一个很经典的事件冒泡应用场景, 那就是 事件委托
, 还是以上面的例子, 我们可以只监听父级的 ul
元素, 根据当前的 event.target
来判断当前点击的是哪一个单元格
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
const cell = new Shape({ type: 'rect' })
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)})
事件分类
通过事件委托, 能够获取到具体触发事件的单元格 ( 具体实现:https://github.com/antvis/S2/blob/master/packages/s2-core/src/interaction/event-controller.ts )
- 角头单元格点击:
S2Event.CORNER_CELL_CLICK
- 列头单元格点击:
S2Event.COL_CELL_CLICK
- 行头单元格点击:
S2Event.ROW_CELL_CLICK
- 数据单元格点击:
S2Event.DATA_CELL_CLICK
- 单元格双击
- 单元格右键
- ...
在监听到对应事件后, 通过内部的 event emitter
分发出去, 从而触发对应的单元格事件
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; } };
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 | 刷选 |
单选高亮
在线体验:https://s2.antv.vision/zh/examples/interaction/basic#click-highlight鼠标左键单击单元格后, 会高亮当前单元格, 聚焦当前的数据。在实现上, 其实并没有对当前选中单元格做高亮操作, 而是置灰其他所有非选中状态的数值单元格, 就像一种 聚光灯
效果.通过 cell.getMeta()
拿到渲染时闭包保存的当前单元格信息, 然后调用 interaction.changeState
改变当前交互状态, 将状态改为 InteractionStateName.SELECTED
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 为:
const cell = { id: 'cell-id' // 单元格唯一标识 colIndex: 0, // 列索引 rowIndex: 0 // 行索引 type: 'cell-type' // 单元格类型} const state = { name: InteractionStateName.SELECTED, cells: [cell]}
接下来就是获取到当前可视范围内所有的数值单元格, 对它们进行更新
public updatePanelGroupAllDataCells() { this.updateCells(this.getPanelGroupAllDataCells()); } public updateCells(cells: S2CellType[] = []) { cells.forEach((cell) => { cell.update(); }); }
每一个单元格实例会有一个 update
方法, 最终会根据当前的状态 改变单元格背景色透明度 fillOpacity
// 简化代码function update() { const stateName = this.spreadsheet.interaction.getCurrentStateName(); const fillOpacity = stateName === InteractionStateName.SELECTED ? 1 : 0.2 cell.attrs = { fillOpacity } canvas.draw()}
行列联动高亮
在线体验:https://s2.antv.vision/zh/examples/interaction/basic#hover
当鼠标 hover 在数值单元格上时, 会同时高亮对应的行头和列头, 也就是 十字高亮效果
, 便于用户清晰的知道对应关系, 实现上首先和单选一样, 先改变状态为 InteractionStateName.HOVER
然后绘制当前单元格的黑色边框
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
是否一致, 如果有一个相同就表示处于同一列/行, 对其进行高亮
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(); }
cell.attrs = { backgroundOpacity: '#color' }
接下来是行头和列头, 处理有些许不同, 由于透视表行头和列头是多维嵌套的, 有父子级关系, 不能单纯的比较行/列索引, 需要额外比较 单元格 id
如图, 行头我们需要高亮 浙江省/舟山市
列头需要高亮 家具/沙发/数量
, 内部对应存储的 id 为
浙江省/舟山市
=>root[&]浙江省[&]舟山市
家具/沙发/数量
=>root[&]家具[&]沙发[&]number
所以 浙江省/舟山市
和 家具/沙发/数量
对应的834
数值单元格的 id 为 => root[&]浙江省[&]舟山市-root[&]家具[&]沙发[&]number
, 最后去看行/列头单元格 id 是否为包含关系, 高亮即可
const allRowHeaderCells = getActiveHoverRowColCells( rowId, interaction.getAllRowHeaderCells(), this.spreadsheet.isHierarchyTreeType(),); forEach(allRowHeaderCells, (cell: RowCell) => { cell.updateByState(InteractionStateName.HOVER);});
刷选高亮
在线体验:https://s2.antv.vision/zh/examples/interaction/basic#select-highlight
刷选用于对批量单元格数据汇总, 本质是一种拖拽的动作, 拖拽结束后, 需要选中拖拽起始坐标点对角线矩形区域的所有单元格.
刷选过程中, 还需要考虑鼠标已经超过表格区域, 此时默认认为用户还想继续刷选可视范围外的单元格 (如有), 也就是滚动刷选, 这个在 使用 AntV S2 打造大数据表格组件 已有相关介绍. 这里就不再赘述.刷选和其他交互不同, 会有一个 预选中
状态, 如图, 会有一个蓝色的预选中蓝色蒙层, 并且该区域单元格显示黑色边框, 表示松开鼠标后, 这些单元格会被选中, 用于给用户一个提示
首先在点击单元格时记录一个刷选起始点, 包含 x/y
坐标, rowIndex/colIndex
行/列索引等信息
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, };}
然后在刷选结束, 鼠标松开后, 得到一个完整的刷选信息, 最后比较当前单元格是否在这个范围即可
return { start: { rowIndex: 0, colIndex: 0, x: 0, y: 0, }, end: { rowIndex: 2, colIndex: 2, x: 200, y: 200, }, width: 200, height: 200, };
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, 然后重绘
this.spreadsheet.on(S2Event.GLOBAL_MOUSE_UP, (event) => { const range = this.getBrushRange(); this.spreadsheet.interaction.changeState({ cells: this.getSelectedCellMetas(range), stateName: InteractionStateName.SELECTED, });}
行高列高动态调整
在线体验:https://s2.antv.vision/zh/examples/interaction/basic#resize
S2 默认提供 列等宽布局
行列等宽布局
和 紧凑布局
三种布局方式 (预览:https://s2.antv.vision/zh/examples/layout/basic#compact), 也可以拖拽行/列头进行动态调整, 要实现这种效果, 首先需要绘制调整的热区, 也就是如下图这个蓝色的小条, 默认情况下是隐藏的, 只有在鼠标放在单元格边缘才会显示出来 (还可以自定义热区范围:https://s2.antv.vision/zh/examples/interaction/advanced#resize-active)
细心的同学可能发现了, 鼠标放在热区上面, 会变成这样一个图标, 这个比较有趣, 在 CSS
中 我们可以给任意元素添加 cursor: col-resize
来实现, 在 Canvas
中 由于只有 canvas
一个 dom 标签, 我们则需要判断 hover
热区时, 给 canvas
加上 cursor: col-resize
行内样式, 实现同样的效果
如果把热区全部显示出来, 展示的效果如下:平铺模式:
树状模式:
明细表:
接下来需要绘制辅助线, 和刷选类似, 刷选需要显示预选中的遮罩, 动态调整需要显示两条辅助线来让用户预览调整之后的单元格宽度
两条线, 对应两条 path
, 虚线使用 lineDash
实现
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,});
在拖动过程中, 需要实时更新参考线的位置, 需要考虑水平和垂直两种情况, 起始点为单元格的底部, 结束点为表格区域的底部
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], ]);
这里大写的 M
和 L
熟悉 SVG
的同学应该清楚, 大写表示绝对定位, 小写表示相对定位, 对应的含义如下:
M = moveto 移动到L = lineto 连接一根线到H = horizontal lineto 水平连线V = vertical lineto 垂直连线C = curvetoS = smooth curvetoQ = quadratic Belzier curveT = smooth quadratic Belzier curvetoA = elliptical Arc 椭圆的线 贝塞尔曲线 Z = closepath 结束当前路径
在拖拽完成后, 将最新的单元格高度/宽度保存到 s2Options.style 中, 重绘更新后, 单元格按照最新的大小渲染即可
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; } }
链接跳转
在线体验:https://s2.antv.vision/zh/examples/interaction/advanced#pivot-link-jump可以给指定单元格的文字加上下划线, 表示可以点击跳转如果使用 DOM
实现, 只需要给对应元素加上 a
超链接标签即可, 使用 Canvas
实现, 则需要自己绘制 下划线
, 监听点击事件. 来模拟 a
标签的效果, 核心实现如下
// 获取当前文字的包围盒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 },);
列头隐藏
在线体验:https://s2.antv.vision/zh/examples/interaction/advanced#pivot-hide-columns
透视表和明细表都支持隐藏列头, 首先点击列头, 显示 tooltip, 然后点击 tooltip 的 隐藏
按钮, 同时支持批量/分组隐藏首先需要知道当前隐藏的列是否需要分组, 如果给定的隐藏列不是连续的, 比如原始列是 [1,2,3,4,5,6,7]
, 隐藏列是 [2,3,6]
, 那么其实在表格上需要显示两个展开按钮 [[2,3],[6]]
, 核心代码如下
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; }, []);};
接下来是生成分组信息
const detail = { displaySiblingNode: { next: Node, // 隐藏列的后一个兄弟节点 prev: Node, // 隐藏列的前一个兄弟节点 } hideColumnNodes: [Node, ...]}
有了这些数据, 就能知道展开按钮绘制在哪一个单元格上, 展开按钮默认显示在后一个兄弟节点, 首尾单元格被隐藏的情况例外, 需要反过来除了手动点击进行隐藏, S2 还支持通过声明配置默认隐藏, 用于去掉一些不重要数据的干扰, 提升看数效率
const s2DataConfig = { fields: { columns: ['type', 'province', 'city', 'price', 'cost'], },} const s2Options = { interaction: { hiddenColumnFields: ['province', 'price'], },};
对于明细表, 一个 field
就只对应一个列头, 对于透视表, 一个 field
对应一个或多个列头, 只指定 field
的话并不知道需要隐藏哪个列头, 需要指定对应列头的 id
const s2Options = { interaction: { // 透视表默认隐藏需要指定唯一列头id // 可通过 `s2.getColumnNodes()` 获取列头节点查看id hiddenColumnFields: ['root[&]家具[&]沙发[&]number'], },};
列头隐藏后, 对应的就是展开, 展开相对来说就比较简单了, 将当前隐藏列配置和展开的列头做一次 diff
, 移除相应配置即可
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); }}
最后我们根据这些配置信息, 重新构建布局, 渲染隐藏/展开列头后的表格即可
自定义交互
在线体验:https://s2.antv.vision/zh/examples/interaction/custom#row-col-hover-tooltip
除了上面提到的丰富的内置交互以外, 开发者还可以根据 S2 提供的 事件S2Event
, 自由排列组合, 自定义表格交互, 可通过 interaction.customInteractions
注册, 比如自定义一个 行列头hover显示 tooltip
的交互
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 还支持 合并单元格, 自定义滚动速度(链接:https://s2.antv.vision/zh/examples/interaction/advanced#scroll-speed-ratio)等丰富的交互, 篇幅有限, 就不一一列举了。也欢迎社区的同学和我们一起共建 AntV/S2,打造最强的开源大数据表格引擎。如果看完这篇文章你有所收获,欢迎给我们的仓库 Star⭐️ 鼓励。S2 的相关链接:
- GitHub:https://github.com/antvis/s2
- 官网:https://s2.antv.vision/
- 核心层: @antv/s2:https://www.npmjs.com/package/@antv/s2
- 组件层: @antv/s2-react:https://www.npmjs.com/package/@antv/s2-react