background

S2 is AntV’s solution in the field of multidimensional cross analysis tables. S2 uses Canvas for table drawing (based on the easy-to-use, efficient and powerful 2D visual rendering engine G), and has a large number of built-in interactive capabilities to assist users in number reading. For example, row linkage highlighting single selection/multiple selection highlighting brush selection highlighting row height column width dynamic adjustment column head hiding and so on. Meanwhile, it also supports custom interactions. This paper mainly introduces how S2 implements these interactions.

The difference between DOM interaction and Canvas interaction

In the case of cell clicks, thanks to the powerful CSS3 selector, we can accurately listen for click events on any DOM element

<ul class="cell">
  <li id="cell1">I'm the first cell</li>
  <li id="cell2">I'm the second cell</li>
</ul>
Copy the code
const cell = document.querySelector('.cell > li:first-child');

cell.addEventListener('click'.() = > {
  console.log(Cell 1: Don't light me! ');
})
Copy the code

But canvas has only one
DOM element

<canvas />
Copy the code

How do you know exactly what cell you clicked on? The answer is event delegate + mouse coordinates

const canvas = document.querySelector('canvas');

canvas.addEventListener('click'.() = > {
  console.log('Which cell did I click on? ');
})
Copy the code

In the DOM, a classic example of event bubbling is event delegate. Again, we can just listen to the parent UL element and determine which cell is clicked based on the current event.target

const cell = document.querySelector('.cell');

cell.addEventListener('click'.(event) = > {
  const CELL_ID = 'cell1'
  if(event.target? .id === CELL_ID) {console.log('I'm the first cell.'); }});Copy the code

Therefore, in Canvas, we can also follow suit. The difference is that cells are no longer dom nodes, but data structures corresponding to canvas graphics, similar to virtual DOM

const cell = new Shape({ type: 'rect' })
Copy the code

public getCell<T extends S2CellType = S2CellType>(event): T {
  let parent = event.target;
  // Determine which instance the current target belongs to
  while(parent && ! (parentinstanceof Canvas)) {
    if (parent instanceof BaseCell) {
      // In a cell, return true
      return parent asT; } parent = parent.get? . ('parent');
  }
  return null;
}

// Canvas constructor provided by ANTv /g
const canvas = new Canvas()

canvas.on('click'.(event) = > {
  const cell = this.getCell(event)
})
Copy the code

Event classification

With event delegate, you can get the cell that specifically triggered the event (concrete implementation)

  • Corner head cell click:S2Event.CORNER_CELL_CLICK
  • Column header cell click:S2Event.COL_CELL_CLICK
  • Wardrobe cell click:S2Event.ROW_CELL_CLICK
  • Data cell click:S2Event.DATA_CELL_CLICK
  • Cell double click
  • Cell right click
  • .

After listening for the corresponding event, the internalevent emitterTo 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; }};Copy the code
this.spreadsheet.on(S2event.DATA_CELL_MOUSE_DOWN, (event) = > {
  console.log('Value cell click')})Copy the code

Interactive classification

With sorted cell events, we can arrange and combine them. For example, swiping highlights will correspond to mousedown+ Mousemove + Mouseup events of numeric cells, and then the meta information of cells obtained will be stored in the state machine, and finally canvas will be redrawn according to the interactive state

Interaction types The name of the Applicable scenario
select all ALL_SELECTED copy
The selected SELECTED Single selection/multiple selection/row batch selection
uncheck UNSELECTED Click the blank, ESC resets, and click the cell an even number of times
hover HOVER Row and column linkage highlighting
Long hover HOVER_FOCUS Show tooltip
primary PREPARE_SELECT Brush to choose

Radio highlight

Online experience

Left-click a cell to highlight the current cell and focus on the current data.

In practice, instead of highlighting the currently selected cell, all other unselected numeric cells are grayed out, like a spotlight effect.

Through cell. GetMeta closure save () to get the render current cell information, and then call interaction. The changeState change the current state of interaction, change the status to 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,
    });
  });
Copy the code

The final state is:

const cell = {
  id: 'cell-id'  // Cell unique identifier
  colIndex: 0./ / the column index
  rowIndex: 0    / / row index
  type: 'cell-type' // Cell type
}

const state = {
  name: InteractionStateName.SELECTED,
  cells: [cell]
}
Copy the code

The next step is to get all the numerical cells in the current viewable range and update them


  public updatePanelGroupAllDataCells() {
    this.updateCells(this.getPanelGroupAllDataCells());
  }

  public updateCells(cells: S2CellType[] = []) {
    cells.forEach((cell) = > {
      cell.update();
    });
  }
Copy the code

Each cell instance will have an Update method that will eventually change the cell background color transparency fillOpacity based on the current state

// Simplify the code
function update() {
  const stateName = this.spreadsheet.interaction.getCurrentStateName();
  const fillOpacity = stateName === InteractionStateName.SELECTED ? 1 : 0.2
  
  cell.attrs = {
    fillOpacity
  }
  
  canvas.draw()
}
Copy the code

Row and column linkage highlighting

Online experience

When the mouse hover on the numerical cell, it will highlight the corresponding row and column head at the same time, that is, the cross highlight effect, so that users can clearly know the corresponding relationship, the implementation of the first and radio, To change the status to InteractionStateName. HOVER and then paint the black border of the current cell

this.spreadsheet.on(S2Event.DATA_CELL_HOVER, (event: CanvasEvent) = > {
  const cell = this.spreadsheet.getCell(event.target) as S2CellType;
  const { interaction, options } = this.spreadsheet;
  constmeta = cell? .getMeta()as ViewMeta;
  
  interaction.changeState({
    cells: [getCellMeta(cell)],
    stateName: InteractionStateName.HOVER,
  });

   this.updateRowColCells(meta);
 }
Copy the code

First draw the cross highlight of the numeric cell area, compare the rowIndex/colIndex of the current cell and the state store to see if the rowIndex/colIndex is the same, if one is the same, it means that they are in the same column/row, and highlight it


  const currentColIndex = this.meta.colIndex;
  const currentRowIndex = this.meta.rowIndex;
  // Draw the hover cross when the index of the cell row in the view matches the hover cell
  if( currentColIndex === currentHoverCell? .colIndex || currentRowIndex === currentHoverCell? .rowIndex ) {this.updateByState(InteractionStateName.HOVER);
  } else {
    // Hide other styles when index does not match hover's cell
    this.hideInteractionShape();
  }
Copy the code
  cell.attrs = {
    backgroundOpacity: '#color'
  }
Copy the code

Next comes the row and column headers, which are handled slightly differently. Since PivotTable row and column headers are multi-dimensional nested and have parent-child relationships, it is not possible to compare row/column indexes alone, but requires additional cell ids

As shown in the figure, we need to highlight the outfit. Zhejiang/Zhoushan Column needs to highlight the furniture/sofa/quantity, and the internal corresponding storage ID is

  • Zhejiang Province/Zhoushan City= >Root [&] Zhoushan, Zhejiang Province
  • Furniture/sofa/quantity= >Root furniture [&] sofa [&]number

Therefore, the ID of 834 numeric cell corresponding to Zhejiang province/Zhoushan city and furniture/sofa/quantity is => root[&] Zhejiang Province [&] Zhoushan city -root[&] furniture [&] sofa [&]number. Finally, check whether the row/column header cell ID is contained, and highlight it

const allRowHeaderCells = getActiveHoverRowColCells(
  rowId,
  interaction.getAllRowHeaderCells(),
  this.spreadsheet.isHierarchyTreeType(),
);

forEach(allRowHeaderCells, (cell: RowCell) = > {
  cell.updateByState(InteractionStateName.HOVER);
});
Copy the code

Brush to choose highlight

Online experience

Brush is used to summarize cell data in batches, which is essentially a drag action. After the drag, all cells in the diagonal rectangle area of the starting coordinate point need to be selected.

In the process of scrolling, it is also necessary to consider that the mouse has exceeded the table area. At this time, it is considered by default that the user still wants to continue to swipe and select cells outside the visual range (if any), that is, scrolling and selecting. This has been introduced in AntV S2 to create big data table components. I will not repeat it here.

Different from other interactions, there will be a pre-selected state, as shown in the figure, there will be a blue pre-selected blue mask, and the cells in this area will show a black border, indicating that after releasing the mouse, these cells will be selected and used to give the user a hint

First, record a brush start point when clicking the cell, including x/ Y coordinates, rowIndex/colIndex row/column index, etc

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,
  };
}
Copy the code

Then at the end of swiping, the mouse is released to get a complete swiping information, and finally compare whether the current cell is in this range

  return {
    start: {
      rowIndex: 0.colIndex: 0.x: 0.y: 0,},end: {
      rowIndex: 2.colIndex: 2.x: 200.y: 200,},width: 200.height: 200};Copy the code
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
  );
}
Copy the code

The cell information is retrieved, stored in State, and redrawn

this.spreadsheet.on(S2Event.GLOBAL_MOUSE_UP, (event) = > {
  const range = this.getBrushRange();

  this.spreadsheet.interaction.changeState({
    cells: this.getSelectedCellMetas(range),
    stateName: InteractionStateName.SELECTED,
  });
}
Copy the code

Row height and column height dynamic adjustment

Online experience

S2 provides three layout modes (preview) by default: column width, column width, column width and compact layout. You can also drag and drop row/column headers for dynamic adjustment. To achieve this effect, you first need to draw the hot area for adjustment, that is, the blue bar as shown below, which is hidden by default. It only shows up when the mouse is over the edge of the cell (you can also customize the hot zone range)

In the CSS, we can add a cursor to any element: Col-resize. Since there is only one DOM tag in Canvas, we need to judge hover hot area by adding cursor: col-resize inline style to Canvas to achieve the same effect

If the hot spots were all displayed, they would look like this:

Tile mode:

Tree mode:

List:

The next step is to draw a guide line, similar to the brush line, which displays the pre-selected mask, and dynamic adjustment, which displays two guides to preview the adjusted cell width

Two lines correspond to two paths. Dashed lines are implemented using lineDash

const attrs: ShapeAttrs = {
  path: ' '.lineDash: guideLineDash,
  stroke: guideLineColor,
  strokeWidth: size,
};
// Start reference
this.resizeReferenceGroup.addShape('path', {
  id: RESIZE_START_GUIDE_LINE_ID,
  attrs,
});
// End the guide
this.resizeReferenceGroup.addShape('path', {
  id: RESIZE_END_GUIDE_LINE_ID,
  attrs,
});
Copy the code

During the dragging process, the position of the guide line needs to be updated in real time. Both horizontal and vertical cases need to be considered. The starting point is the bottom of the cell, and the ending point is the bottom of the table area


    if (type === ResizeDirectionType.Horizontal) {
      startResizeGuideLineShape.attr('path'The [['M', offsetX, offsetY],
        ['L', offsetX, guideLineMaxHeight],
      ]);
      endResizeGuideLineShape.attr('path'The [['M', offsetX + width, offsetY],
        ['L', offsetX + width, guideLineMaxHeight],
      ]);
      return;
    }

    startResizeGuideLineShape.attr('path'The [['M', offsetX, offsetY],
      ['L', guideLineMaxWidth, offsetY],
    ]);
    endResizeGuideLineShape.attr('path'The [['M', offsetX, offsetY + height],
      ['L', guideLineMaxWidth, offsetY + height],
    ]);
Copy the code

For those familiar with SVG, uppercase represents absolute positioning and lowercase represents relative positioning. The corresponding meanings are as follows:

M = moveto moveto L = lineto connect a lineto H = horizontal lineto V = vertical lineto C = curveto S = smooth curveto Q = Quadratic Belzier curve T = smooth Belzier curveto A = Octagonal Two-dimensional Arc Bezier curve Z = Closepath End the current pathCopy the code

After the drag is complete, save the latest cell height/width to s2options. style, redraw the update and render the cell to the latest size

  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; }}Copy the code

Links to jump

Online experience

You can underline the text of the specified cell to indicate that you can click to jump. If DOM is used, you only need to add a hyperlink label to the corresponding element. If Canvas is used, you need to draw the underline by yourself to monitor click events. To simulate the effect of a tag, the core implementation is as follows

// Get a bounding box for the current text
const { minX, maxX, maxY }: BBox = this.textShape.getBBox();

// Draw an underline under the current text
this.linkFieldShape = renderLine(
  this,
  {
    x1: minX,
    y1: maxY + 1.x2: maxX,
    y2: maxY + 1}, {stroke: linkFillColor, lineWidth: 1});Copy the code

Column head hidden

Online experience

Pivottables and itemlists both support hiding column headers by clicking on the column header to display ToolTip and then clicking the Hide button on ToolTip, as well as batch/group hiding

If a given hidden column is not contiguous, for example, the original column is [1,2,3,4,5,6,7] and the hidden column is [2,3,6], then the table actually needs to display two expand buttons [[2,3],[6]. The core code is as follows

export const getHiddenColumnsThunkGroup = (
  columns: string[].hiddenColumnFields: string[],
): string[] [] = > {if (isEmpty(hiddenColumnFields)) {
    return [];
  }
  // The sequence number of the last item to hide
  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;
    returnresult; } []); };Copy the code

Next, generate the grouping information

const detail = {
   displaySiblingNode: {
     next: Node, // Hide the next sibling of the column
     prev: Node, // Hide the previous sibling of the column
   }
   hideColumnNodes: [Node, ...]
}
Copy the code

With this data, you know on which cell the expand button is drawn. By default, the expand button is displayed on the next sibling node, except when the first and last cells are hidden

In addition to manually clicking to hide, S2 also supports default hiding by declaring the configuration to remove the interference of some unimportant data and improve the efficiency of viewing data

const s2DataConfig = {
  fields: {
    columns: ['type'.'province'.'city'.'price'.'cost',}}const s2Options = {
  interaction: {
    hiddenColumnFields: ['province'.'price'],}};Copy the code

For a list, a field corresponds to only one column header. For a PivotTable, a field corresponds to one or more column headers. If you specify a field, you do not know which column header to hide

const s2Options = {
  interaction: {
    // Pivottables hide the need to specify a unique column header ID by default
    's2.getColumnNodes()' can be used to obtain the id of the column head node
    hiddenColumnFields: ['root[&] furniture [&] sofa [&]number'],}};Copy the code

After the column header is hidden, the corresponding is expand, expand is relatively simple, the current hidden column configuration and the expanded column header do a diff, remove the corresponding configuration can be

  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); }}Copy the code

Finally, based on this configuration information, we can rebuild the layout and render the table behind the hidden/expanded column head

Custom interaction

Online experience

In addition to the above mentioned rich built-in interaction, developers can also according to S2 S2Event events, free combination, custom form interaction, through interaction. CustomInteractions registration, For example, a custom column header hover displays the tooltip interaction

import { PivotSheet, BaseEvent, S2Event } from '@antv/s2';

class RowColumnHoverTooltipInteraction extends BaseEvent {
  bindEvents() {
    / / wardrobe hover
    this.spreadsheet.on(S2Event.ROW_CELL_HOVER, (event) = > {
      this.showTooltip(event);
    });
    / / column head 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()

Copy the code

conclusion

The above is some introduction of some interaction implementations of S2. In addition, S2 also supports the merging of cells, custom scroll speed and other rich interactions, which will not be listed in space.

We also welcome students from the community to build AntV/S2 together with us to create the strongest open source big data table engine. If you gain something from reading this article, please give encouragement to our warehouse Star⭐️.

Related links to S2:

Refer to the link