背景


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

事件分类

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

  • 角头单元格点击: 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.2    cell.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 = 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;    }  }

链接跳转

在线体验

能够给指定单元格的文字加上下划线, 示意能够点击跳转
如果应用 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()` 获取列头节点查看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);  }}

最初咱们依据这些配置信息, 从新构建布局, 渲染暗藏/开展列头后的表格即可

自定义交互

在线体验

除了下面提到的丰盛的内置交互以外, 开发者还能够依据 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 还反对 合并单元格, 自定义滚动速度 等丰盛的交互, 篇幅无限, 就不一一列举了。

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

S2 的相干链接:

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

参考链接

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