How AntV S2 Implements Rich Canvas Interactions for Multi‑Dimensional Tables
This article explains how AntV S2 uses a canvas‑based rendering engine and event delegation to provide advanced interactions such as cell highlighting, brush selection, dynamic resizing, column hiding, and custom tooltips for multi‑dimensional data tables, complete with code examples and implementation details.
Difference Between DOM and Canvas Interaction
Using a cell click as an example, DOM elements can listen to click events directly via CSS3 selectors, while a canvas provides only a single <canvas/> element, requiring event delegation and mouse coordinates to determine which cell was clicked.
<ul class="cell">
<li id="cell1">I am the first cell</li>
<li id="cell2">I am the second cell</li>
</ul> const cell = document.querySelector('.cell > li:first-child');
cell.addEventListener('click', () => {
console.log('First cell: do not click me!');
});Canvas interaction uses a single <canvas/> element and determines the clicked cell through event delegation and mouse coordinates.
const canvas = document.querySelector('canvas');
canvas.addEventListener('click', () => {
console.log('Which cell did I click?');
});Event delegation combined with mouse coordinates enables accurate cell identification.
canvas.addEventListener('click', () => {
console.log('I clicked which cell?');
});Event Classification
Through event delegation, specific cell events can be captured (implementation: event‑controller.ts ).
Corner cell click: S2Event.CORNER_CELL_CLICK Column header cell click: S2Event.COL_CELL_CLICK Row header cell click: S2Event.ROW_CELL_CLICK Data cell click: S2Event.DATA_CELL_CLICK Cell double‑click
Cell right‑click
After listening to an event, the internal event emitter distributes it to trigger the corresponding cell event.
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('Data cell clicked');
});Interaction Classification
Combining cell events enables interactions such as brush highlight, which uses mousedown + mousemove + mouseup to store cell meta information in a state machine and redraw the canvas accordingly.
Interaction Type
Name
Applicable Scenario
All selected
ALL_SELECTED
Copy
Selected
SELECTED
Single/multi/row‑column batch selection
Unselected
UNSELECTED
Click blank, ESC reset, even‑click cell
Hover
HOVER
Row‑column linked highlight
Hover focus
HOVER_FOCUS
Show tooltip
Prepare select
PREPARE_SELECT
Brush
Single‑Select Highlight
Clicking a cell highlights it while dimming other non‑selected cells, creating a “spotlight” effect.
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,
});
});The resulting state contains cell id, column index, row index, and type.
const cell = {
id: 'cell-id',
colIndex: 0,
rowIndex: 0,
type: 'cell-type',
};
const state = {
name: InteractionStateName.SELECTED,
cells: [cell],
};Updating all visible data cells simply calls each cell’s update method, which adjusts fillOpacity based on the current state.
public updatePanelGroupAllDataCells() {
this.updateCells(this.getPanelGroupAllDataCells());
}
public updateCells(cells: S2CellType[] = []) {
cells.forEach((cell) => {
cell.update();
});
} function update() {
const stateName = this.spreadsheet.interaction.getCurrentStateName();
const fillOpacity = stateName === InteractionStateName.SELECTED ? 1 : 0.2;
cell.attrs = { fillOpacity };
canvas.draw();
}Row‑Column Linked Highlight
Hovering a data cell simultaneously highlights its row and column, creating a cross‑highlight effect.
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);
});The logic compares the hovered cell’s rowIndex and colIndex with those of other cells to decide which ones to highlight.
if (currentColIndex === currentHoverCell?.colIndex ||
currentRowIndex === currentHoverCell?.rowIndex) {
this.updateByState(InteractionStateName.HOVER);
} else {
this.hideInteractionShape();
}Brush Highlight
Brush selection records the start point (x, y, rowIndex, colIndex) and, upon mouse release, determines the rectangular range to select all cells inside it.
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
);
} this.spreadsheet.on(S2Event.GLOBAL_MOUSE_UP, (event) => {
const range = this.getBrushRange();
this.spreadsheet.interaction.changeState({
cells: this.getSelectedCellMetas(range),
stateName: InteractionStateName.SELECTED,
});
});Dynamic Row/Column Height Adjustment
S2 provides three layout modes (equal‑width columns, equal‑width rows, compact) and allows dragging row/column headers to resize them. A hidden hot‑area appears on the cell edge; when hovered, the cursor changes to col-resize (or row-resize for rows).
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],
]); 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;
}
}Link Jump
To simulate an a tag on canvas, draw an underline under the text and listen for click events.
// Get the bounding box of the text shape
const { minX, maxX, maxY }: BBox = this.textShape.getBBox();
// Draw an underline line beneath the text
this.linkFieldShape = renderLine(
this,
{ x1: minX, y1: maxY + 1, x2: maxX, y2: maxY + 1 },
{ stroke: linkFillColor, lineWidth: 1 },
);Column Header Hiding
Users can hide column headers via a tooltip button; the system groups consecutive hidden columns to display expand buttons.
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;
}, []);
};When expanding, the hidden column configuration is diffed and the corresponding fields are removed.
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);
}Custom Interaction
Developers can register custom interactions using S2Event and interaction.customInteractions. The example below shows a tooltip that appears when hovering row or column headers.
import { PivotSheet, BaseEvent, S2Event } from '@antv/s2';
class RowColumnHoverTooltipInteraction extends BaseEvent {
bindEvents() {
this.spreadsheet.on(S2Event.ROW_CELL_HOVER, (event) => {
this.showTooltip(event);
});
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();Conclusion
The article covered several S2 interaction implementations, including cell highlighting, brush selection, dynamic resizing, column hiding, and custom interactions, and pointed readers to the GitHub repository, official site, and npm packages for further exploration.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
