背景

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) {// 在单元格中,返回truereturn 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
  • 单元格双击
  • 单元格右键
  • ...

在监听到对应事件后, 通过内部的 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

刷选

单选高亮

在线体验

鼠标左键单击单元格后, 会高亮当前单元格, 聚焦当前的数据。

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

通过 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.2cell.attrs = {fillOpacity}canvas.draw()
}

行列联动高亮

在线体验

当鼠标 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);
});

刷选高亮

在线体验

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

刷选过程中, 还需要考虑鼠标已经超过表格区域, 此时默认认为用户还想继续刷选可视范围外的单元格 (如有), 也就是滚动刷选, 这个在 使用 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,});
}

行高列高动态调整

在线体验

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

细心的同学可能发现了, 鼠标放在热区上面, 会变成这样一个图标, 这个比较有趣, 在 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],]);

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

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 中, 重绘更新后, 单元格按照最新的大小渲染即可

  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;}}

链接跳转

在线体验

可以给指定单元格的文字加上下划线, 表示可以点击跳转

如果使用 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 },
);

列头隐藏

在线体验

透视表和明细表都支持隐藏列头, 首先点击列头, 显示 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()` 获取列头节点查看idhiddenColumnFields: ['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);}
}

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

自定义交互

在线体验

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

import { PivotSheet, BaseEvent, S2Event } from '@antv/s2';class RowColumnHoverTooltipInteraction extends BaseEvent {bindEvents() {// 行头hoverthis.spreadsheet.on(S2Event.ROW_CELL_HOVER, (event) => {this.showTooltip(event);});// 列头hoverthis.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 的相关链接:

  • GitHub
  • 官网
  • 核心层: @antv/s2
  • 组件层: @antv/s2-react

参考链接

  • 用 SVG 画一个字
  • 使用 AntV S2 打造大数据表格组件
  • AntV/G
  • MDN SVG

你不知道的 Canvas 表格交互相关推荐

  1. (2/2)Canvas的交互存为图片-爬坑篇

    需求介绍 page2上的canvas可交互,并实时显示交互结果: 点击下一步,page2消失,page3显示: page3显示的是一张图片,图片有canvas交互区和另外的一些元素组成. 实现思路 c ...

  2. html语言填充没有只有描边,HTML5 Canvas笔记——交互绘制文本(描边、填充、阴影、渐变填充、图案填充、文本的属性设置)...

    (1)文本的描边.填充.阴影 (2)文本的渐变填充 (3)文本的图案填充 (4)文本的属性设置及效果呈现 交互绘制文本.html 交互绘制文本 body { background: #eeeeee; ...

  3. (1/2)Canvas的交互存为图片-基本篇

    前言 公司的产品同学看到朋友圈疯传的这张图后.一拍脑袋,决定做个H5版本的来推广一波. 需求如下: 文字变成可以点击的,而且还要能够变色(闪瞎有木有) 中间的姓名换成用户的微信头像 点击button后 ...

  4. python与excel表格-xlrd/xlwt - python与excel表格交互

    假设我的表格文件叫demo.xls,三个sheet,第一个sheet内容如下 则要访问3行第D列单元格则使用如下代码 ----------------------------------------- ...

  5. 【教程】一步一步教你如何自定义设置——博客园canvas/JS交互动画背景

    演示步骤的原文链接:(看完就可以掌握如何设置博客园canvas动画背景) 博客园如何自定义设置canvas/JS动画背景 --2018-10-27 本博客的弹弹弹小球-canvas动画原文链接:(看完 ...

  6. Canvas 生成交互动画

    2019独角兽企业重金招聘Python工程师标准>>> 今天介绍的是一个HTML5交互动画效果,难以置信.HTML5虽说还有很多东西在改进,但现在所能实现的 效果的程度我想是诸位很难 ...

  7. 葡萄城首席架构师:前端开发与Web表格控件技术解读

    讲师:Issam Elbaytam,葡萄城集团全球首席架构师(Chief Software Architect of GrapeCity Global).曾任 Data Dynamics.Inc 创始 ...

  8. SpreadJS企业表格技术实践二:设计医疗行业报表模板

    SpreadJS v14.0正式版下载 前情回顾 作为 SpreadJS 的增强扩展插件,在线表格编辑器可直接在 Angular. React. Vue 等前端框架中调用,借助其开放的 API 和类 ...

  9. 使用 AntV S2 打造大数据表格组件

    导读 在蚂蚁的大数据研发平台中,数据表格是一类重要的业务组件.我们需要流畅的展示 SQL 查询出来的上万条结果,并对结果做筛选.排序.搜索.复制.框选.聚合分析等操作.同时也存在数据手工录入的场景,需 ...

最新文章

  1. 无语!你竟然连CompletableFuture都不知道,还天天说在jdk8原地踏步~
  2. 序列比对软件/比对工具的比较
  3. Mxnet TensorRT
  4. 数学--数论-- HDU6298 Maximum Multiple 打表找规律
  5. C语言讲义——字符串
  6. JavaScript 转载
  7. Per-FedAvg:联邦个性化元学习
  8. Java ForkJoin 框架初探
  9. PAT L2-003. 月饼
  10. Java设计模式透析之 —— 组合(Composite)
  11. js ajax 表单异步提交
  12. asp.net2.0(C#)图像处理类[转]
  13. safari看html5卡顿,MacOS下Safari 10浏览器卡顿解决方案整理 - YangJunwei
  14. 读书,意味着你还不服输
  15. 创造与魔法241服务器系统什么时候修好,《创造与魔法》萌新小课堂——如何选择服务器...
  16. 武大地理信息科学本科生的专业相关网站总结分享(包括制图、专业课、自学复习等)
  17. IT新人到底该不该去外包公司?
  18. 微信小程序布局快速入门
  19. 【渝粤题库】广东开放大学 管理学基础 形成性考核
  20. blur和GaussianBlur

热门文章

  1. 全面演示springboot发送QQ邮件过程(复杂邮件+简单邮件)图片、附件
  2. 华为OD机试 - 不含101的数
  3. 字体反爬之猫眼票房爬虫python
  4. PPASR语音识别(进阶级)
  5. 云开发小课-OA 物品领用
  6. K8S kube-scheduler-master CreateContainerError 问题解决及思路
  7. 计算机c程序题孔融让梨,幼儿园大班语言游戏教案《孔融让梨》含PPT课件.doc
  8. 7张图带你轻松入门RocketMQ
  9. MySQL where in 用法详解
  10. 团队建设的一些思考和实践