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

以单元格点击为例, 得益于强大的 CSS3选择器, 我们可以准确的监听任意 dom 元素的点击事件
<ulclass="cell">
<liid="cell1">
我是第一个单元格
</li>
<liid="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'
);

  }

returnnull
;

}


// 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
// 简化代码
functionupdate() 
{

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 然后绘制当前单元格的黑色边框
image.png
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
刷选用于对批量单元格数据汇总, 本质是一种拖拽的动作, 拖拽结束后, 需要选中拖拽起始坐标点对角线矩形区域的所有单元格.
image.png
刷选过程中, 还需要考虑鼠标已经超过表格区域, 此时默认认为用户还想继续刷选可视范围外的单元格 (如有), 也就是滚动刷选, 这个在 使用 AntV S2 打造大数据表格组件(https://zhuanlan.zhihu.com/p/478129151) 已有相关介绍. 这里就不再赘述.
刷选和其他交互不同, 会有一个
预选中
状态, 如图, 会有一个蓝色的预选中蓝色蒙层, 并且该区域单元格显示黑色边框, 表示松开鼠标后, 这些单元格会被选中, 用于给用户一个提示


首先在点击单元格时记录一个刷选起始点, 包含
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
行内样式, 实现同样的效果


image.png
如果把热区全部显示出来, 展示的效果如下:
平铺模式:


树状模式:


明细表:

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

两条线, 对应两条 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
:

returnnull
;

    }

  }

链接跳转
在线体验: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]], 核心代码如下
exportconst
 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 还支持通过声明配置默认隐藏,  用于去掉一些不重要数据的干扰, 提升看数效率
image.png
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 还支持 合并单元格, 自定义滚动速度 等丰富的交互, 篇幅有限, 就不一一列举了。
也欢迎社区的同学和我们一起共建 AntV/S2,打造最强的开源大数据表格引擎。如果看完这篇文章你有所收获,欢迎给我们的仓库 Star⭐️ 鼓励。
S2 的相关链接:
  • GitHub:https://github.com/antvis/s2
  • 官网:https://s2.antv.vision/zh
  • 核心层: @antv/s2:https://www.npmjs.com/package/@antv/s2
  • 组件层: @antv/s2-react:https://www.npmjs.com/package/@antv/s2-react

参考链接

  • 用 SVG 画一个字:https://www.lijinke.cn/2019/03/04/%E7%94%A8SVG-%E7%94%BB%E4%B8%80%E4%B8%AA%E5%AD%97/
  • 使用 AntV S2 打造大数据表格组件:https://zhuanlan.zhihu.com/p/478129151
  • AntV/G:https://g-next.antv.vision/zh/docs/guide/introduce/
  • MDN SVG:https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths

关注「Alibaba F2E」微信公众号把握阿里巴巴前端新动向
继续阅读
阅读原文