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.

Alibaba Terminal Technology
Alibaba Terminal Technology
Alibaba Terminal Technology
How AntV S2 Implements Rich Canvas Interactions for Multi‑Dimensional Tables

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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Frontend DevelopmentEvent DelegationAntVCanvas InteractionCustom InteractionData TableS2
Alibaba Terminal Technology
Written by

Alibaba Terminal Technology

Official public account of Alibaba Terminal

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.