From be5e32e0d956cc828e858a985c0d67cf3e28ec84 Mon Sep 17 00:00:00 2001 From: Jinke Li Date: Fri, 17 Nov 2023 15:44:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BA=A4=E5=8F=89=E8=A1=A8=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=86=BB=E7=BB=93=E9=A6=96=E8=A1=8C=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=20(#2419)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: 修复交叉模式下 行序号位置不正确&总计行未添加行序号 (#2412) fix: fix row series number position and height bug Co-authored-by: wuding.why * refactor: extract freeze public logic * feat: the pivot table supports freezing the first row as a whole * fix: lint error fixed * fix: test case optimize --------- Co-authored-by: wuhaiyang Co-authored-by: wuding.why --- .../unit/facet/header/frozen-row-spec.ts | 104 +++ .../__tests__/unit/facet/pivot-facet-spec.ts | 120 ++- .../unit/sheet-type/pivot-sheet-spec.ts | 3 +- packages/s2-core/src/cell/frozen-row-cell.ts | 101 +++ packages/s2-core/src/cell/header-cell.ts | 2 +- packages/s2-core/src/cell/index.ts | 4 + packages/s2-core/src/cell/row-cell.ts | 6 +- .../s2-core/src/cell/series-number-cell.ts | 27 + packages/s2-core/src/common/constant/basic.ts | 6 +- .../s2-core/src/common/constant/frozen.ts | 8 +- .../s2-core/src/common/interface/s2Options.ts | 2 + packages/s2-core/src/facet/base-facet.ts | 44 +- packages/s2-core/src/facet/frozen-facet.ts | 746 +++++++++++++++++ .../src/facet/header/base-frozen-row.ts | 138 +++ packages/s2-core/src/facet/header/col.ts | 4 +- .../s2-core/src/facet/header/frozen-row.ts | 37 + .../src/facet/header/frozen-series-number.ts | 79 ++ packages/s2-core/src/facet/header/index.ts | 1 + packages/s2-core/src/facet/header/row.ts | 57 +- .../s2-core/src/facet/header/series-number.ts | 24 +- .../s2-core/src/facet/header/table-col.ts | 6 +- packages/s2-core/src/facet/layout/node.ts | 13 + packages/s2-core/src/facet/pivot-facet.ts | 165 +++- packages/s2-core/src/facet/table-facet.ts | 786 ++---------------- packages/s2-core/src/facet/utils.ts | 51 ++ .../s2-core/src/sheet-type/pivot-sheet.ts | 22 +- .../s2-core/src/sheet-type/spread-sheet.ts | 6 +- packages/s2-core/src/utils/indexes.ts | 2 +- s2-site/docs/api/general/S2Options.zh.md | 1 + .../manual/advanced/frozen-row-pivot.en.md | 31 + .../manual/advanced/frozen-row-pivot.zh.md | 32 + .../advanced/demo/frozen-pivot-grid.ts | 24 + .../advanced/demo/frozen-pivot-tree.ts | 19 + .../interaction/advanced/demo/meta.json | 16 + 34 files changed, 1892 insertions(+), 795 deletions(-) create mode 100644 packages/s2-core/__tests__/unit/facet/header/frozen-row-spec.ts create mode 100644 packages/s2-core/src/cell/frozen-row-cell.ts create mode 100644 packages/s2-core/src/cell/series-number-cell.ts create mode 100644 packages/s2-core/src/facet/frozen-facet.ts create mode 100644 packages/s2-core/src/facet/header/base-frozen-row.ts create mode 100644 packages/s2-core/src/facet/header/frozen-row.ts create mode 100644 packages/s2-core/src/facet/header/frozen-series-number.ts create mode 100644 s2-site/docs/manual/advanced/frozen-row-pivot.en.md create mode 100644 s2-site/docs/manual/advanced/frozen-row-pivot.zh.md create mode 100644 s2-site/examples/interaction/advanced/demo/frozen-pivot-grid.ts create mode 100644 s2-site/examples/interaction/advanced/demo/frozen-pivot-tree.ts diff --git a/packages/s2-core/__tests__/unit/facet/header/frozen-row-spec.ts b/packages/s2-core/__tests__/unit/facet/header/frozen-row-spec.ts new file mode 100644 index 0000000000..1acb772798 --- /dev/null +++ b/packages/s2-core/__tests__/unit/facet/header/frozen-row-spec.ts @@ -0,0 +1,104 @@ +import { createPivotSheet } from 'tests/util/helpers'; +import { assembleDataCfg, assembleOptions } from 'tests/util'; +import { DEFAULT_STYLE } from '@/common'; + +import { FrozenRowCell, RowCell, SeriesNumberCell } from '@/cell'; +import type { PivotFacet } from '@/facet'; +import { FrozenRowHeader } from '@/facet/header'; +import { FrozenSeriesNumber } from '@/facet/header/frozen-series-number'; + +const s2 = createPivotSheet({ + ...assembleDataCfg().fields, + valueInCols: true, + ...assembleOptions({ + frozenEntireHeadRowPivot: true, + totals: { + row: { + showGrandTotals: true, + reverseLayout: true, + }, + }, + frozenColCount: 2, + frozenRowCount: 2, + frozenTrailingColCount: 2, + frozenTrailingRowCount: 2, + showSeriesNumber: true, + }), + ...DEFAULT_STYLE, + height: 480, + width: 400, + cellCfg: { + width: 200, + height: 50, + }, +}); +describe('Frozen Row Header Test', () => { + let facet; + + test.each(['grid', 'tree'])( + 'frozen row header group api', + (hierarchyType: 'grid' | 'tree') => { + s2.setOptions({ hierarchyType }); + s2.render(); + facet = s2.facet as PivotFacet; + + expect(facet.rowHeader instanceof FrozenRowHeader).toBeTrue(); + expect(facet.rowHeader.frozenHeadGroup).toBeTruthy(); + expect(facet.rowHeader.scrollGroup).toBeTruthy(); + + expect(facet.rowHeader.frozenHeadGroup.getChildren()).toHaveLength(1); + const frozenRowCell = facet.rowHeader.frozenHeadGroup.getChildren()[0]; + + expect(frozenRowCell instanceof RowCell).toBeTrue(); + expect(frozenRowCell.meta.height).toEqual(30); + + expect(facet.rowHeader.scrollGroup.getChildren()).toHaveLength(3); + const scrollCell = facet.rowHeader.scrollGroup.getChildren()[0]; + + expect(scrollCell instanceof FrozenRowCell).toBeTrue(); + expect(frozenRowCell.meta.height).toEqual(30); + + const rowHeader = facet.rowHeader; + expect(rowHeader.getFrozenRowHeight()).toBe(30); + + expect( + rowHeader.isFrozenRow({ + rowIndex: 0, + }), + ).toBe(true); + expect( + rowHeader.isFrozenRow({ + rowIndex: -1, + }), + ).toBe(false); + + expect(rowHeader.getFrozenRowCount()).toBe(1); + }, + ); +}); + +describe('Frozen Series Number Test', () => { + let facet; + test.each(['grid', 'tree'])( + 'series number test', + (hierarchyType: 'grid' | 'tree') => { + s2.setOptions({ hierarchyType }); + s2.render(); + facet = s2.facet as PivotFacet; + expect(facet.rowIndexHeader instanceof FrozenSeriesNumber).toBe(true); + + const seriesNumberCell = + facet.rowIndexHeader.frozenHeadGroup.getChildren(); + expect(seriesNumberCell).toHaveLength(1); + + expect( + facet.rowIndexHeader.scrollGroup.getChildren()[0] instanceof + SeriesNumberCell, + ).toBe(true); + + expect(seriesNumberCell[0] instanceof SeriesNumberCell).toBe(true); + + expect(seriesNumberCell[0].meta.height).toBe(30); + }, + ); +}); diff --git a/packages/s2-core/__tests__/unit/facet/pivot-facet-spec.ts b/packages/s2-core/__tests__/unit/facet/pivot-facet-spec.ts index a17d7819ad..f5608a15e0 100644 --- a/packages/s2-core/__tests__/unit/facet/pivot-facet-spec.ts +++ b/packages/s2-core/__tests__/unit/facet/pivot-facet-spec.ts @@ -15,10 +15,17 @@ import { CornerCell, DataCell } from '@/cell'; import { Store } from '@/common/store'; import { getTheme } from '@/theme'; import { DEFAULT_OPTIONS, DEFAULT_STYLE } from '@/common/constant/options'; -import { ColHeader, CornerHeader, Frame, RowHeader } from '@/facet/header'; +import { + ColHeader, + CornerHeader, + Frame, + FrozenRowHeader, + RowHeader, +} from '@/facet/header'; import type { Fields, ViewMeta } from '@/common/interface/basic'; import { RootInteraction } from '@/interaction/root'; -import { areAllFieldsEmpty } from '@/facet/utils'; +import { areAllFieldsEmpty, getFrozenOptionsPivot } from '@/facet/utils'; +import { FrozenGroup } from '@/common/constant'; jest.mock('@/interaction/root'); @@ -249,7 +256,9 @@ describe('Pivot Mode Facet Test', () => { test('get header after render', () => { const { rowHeader, cornerHeader, columnHeader, centerFrame } = facet; - expect(rowHeader instanceof RowHeader).toBeTrue(); + expect( + rowHeader instanceof FrozenRowHeader || rowHeader instanceof RowHeader, + ).toBeTrue(); expect(rowHeader.cfg.children).toHaveLength(10); expect(rowHeader.cfg.visible).toBeTrue(); @@ -475,3 +484,108 @@ describe('Pivot Mode Facet Test', () => { expect(facet.getHiddenColumnsInfo(node)).toEqual(hiddenColumnsInfo); }); }); + +describe.each(['grid', 'tree'])( + 'Pivot Mode Facet frozen entire row', + (hierarchyType: 'grid' | 'tree') => { + const s2: SpreadSheet = new MockSpreadSheet(); + const dataSet: PivotDataSet = new MockPivotDataSet(s2); + s2.dataSet = dataSet; + s2.interaction = new RootInteraction(s2); + const facet: PivotFacet = new PivotFacet({ + spreadsheet: s2, + dataSet, + dataCell: (fct) => new DataCell(fct, s2), + ...assembleDataCfg().fields, + valueInCols: true, + ...assembleOptions({ + frozenEntireHeadRowPivot: true, + totals: { + row: { + showGrandTotals: true, + reverseLayout: true, + }, + }, + frozenColCount: 2, + frozenRowCount: 2, + frozenTrailingColCount: 2, + frozenTrailingRowCount: 2, + }), + ...DEFAULT_STYLE, + hierarchyType, + }); + + test('should get correct frozenOptions', () => { + expect(getFrozenOptionsPivot(facet.cfg)).toStrictEqual({ + frozenRowCount: 1, + frozenColCount: 0, + frozenTrailingColCount: 0, + frozenTrailingRowCount: 0, + }); + + expect( + getFrozenOptionsPivot({ + ...{ + ...facet.cfg, + showSeriesNumber: true, + }, + }), + ).toStrictEqual({ + frozenRowCount: 1, + frozenColCount: 0, + frozenTrailingColCount: 0, + frozenTrailingRowCount: 0, + }); + }); + + test('should get correct frozenInfo', () => { + facet.calculateFrozenGroupInfo(); + expect(facet.frozenGroupInfo).toStrictEqual({ + [FrozenGroup.FROZEN_COL]: { + width: 0, + }, + [FrozenGroup.FROZEN_ROW]: { + height: 30, + range: [0, 0], + }, + [FrozenGroup.FROZEN_TRAILING_COL]: { + width: 0, + }, + [FrozenGroup.FROZEN_TRAILING_ROW]: { + height: 0, + }, + }); + }); + + test('should get correct xy indexes with frozen', () => { + expect(facet.calculateXYIndexes(0, 0)).toStrictEqual({ + center: [0, 3, 1, 7], + frozenCol: [0, -1, 1, 7], + frozenRow: [0, 3, 0, 0], + frozenTrailingCol: [4, 3, 1, 7], + frozenTrailingRow: [0, 3, 8, 7], + }); + expect(facet.calculateXYIndexes(110, 30)).toStrictEqual({ + center: [1, 3, 2, 7], + frozenCol: [0, -1, 2, 7], + frozenRow: [1, 3, 0, 0], + frozenTrailingCol: [4, 3, 2, 7], + frozenTrailingRow: [1, 3, 8, 7], + }); + }); + + test('should get correct indexes with row height gt canvas height', () => { + const originHeight = facet.panelBBox.viewportHeight; + facet.panelBBox.viewportHeight = 10; + expect(facet.calculateXYIndexes(0, 0)).toStrictEqual({ + center: [0, 3, 1, 0], + frozenCol: [0, -1, 1, 0], + frozenRow: [0, 3, 0, 0], + frozenTrailingCol: [4, 3, 1, 0], + frozenTrailingRow: [0, 3, 8, 7], + }); + // reset + facet.panelBBox.viewportHeight = originHeight; + }); + }, +); diff --git a/packages/s2-core/__tests__/unit/sheet-type/pivot-sheet-spec.ts b/packages/s2-core/__tests__/unit/sheet-type/pivot-sheet-spec.ts index 7f6fd15f46..c0de4f2fb4 100644 --- a/packages/s2-core/__tests__/unit/sheet-type/pivot-sheet-spec.ts +++ b/packages/s2-core/__tests__/unit/sheet-type/pivot-sheet-spec.ts @@ -604,7 +604,8 @@ describe('PivotSheet Tests', () => { expect(s2.foregroundGroup.getChildren()).toHaveLength(9); // panel scroll group - expect(s2.panelGroup.getChildren()).toHaveLength(1); + // contain panelScrollGroup and frozenRowGroup + expect(s2.panelGroup.getChildren()).toHaveLength(2); expect(s2.panelGroup.findAllByName(KEY_GROUP_PANEL_SCROLL)).toHaveLength(1); }); diff --git a/packages/s2-core/src/cell/frozen-row-cell.ts b/packages/s2-core/src/cell/frozen-row-cell.ts new file mode 100644 index 0000000000..a0f78e5c79 --- /dev/null +++ b/packages/s2-core/src/cell/frozen-row-cell.ts @@ -0,0 +1,101 @@ +import { + KEY_GROUP_ROW_RESIZE_AREA, + ResizeAreaEffect, + ResizeDirectionType, +} from '../common/constant'; +import { + getOrCreateResizeAreaGroupById, + getResizeAreaAttrs, + shouldAddResizeArea, +} from '../utils/interaction/resize'; +import type { BaseHeaderConfig } from '../facet/header/base'; +import { RowCell } from './row-cell'; + +export class FrozenRowCell extends RowCell { + private frozenRowHeight: number; + + protected handleRestOptions( + ...[headerConfig, ...options]: [BaseHeaderConfig, number] + ) { + super.handleRestOptions(headerConfig, options); + this.frozenRowHeight = options[0]; + } + + protected drawResizeAreaInLeaf(): void { + if ( + !this.meta.isLeaf || + !this.shouldDrawResizeAreaByType('rowCellVertical', this) + ) { + return; + } + + const { x, y, width, height } = this.getCellArea(); + const resizeStyle = this.getResizeAreaStyle(); + const resizeArea = getOrCreateResizeAreaGroupById( + this.spreadsheet, + KEY_GROUP_ROW_RESIZE_AREA, + ); + + const { + position, + seriesNumberWidth, + width: headerWidth, + viewportHeight: headerHeight, + scrollX, + scrollY, + } = this.headerConfig; + + // const frozenRowHeight = this.getFrozenRowHeight(); + const resizeAreaBBox = { + // fix: When horizontally scrolling and closing the entire frozen header, the resize area is being removed prematurely. + x: x + seriesNumberWidth, + // packages/s2-core/src/facet/header/frozen-row.ts The y-coordinate has been decreased by the height of the frozen rows. need plus frozenRowHeight + y: y + this.frozenRowHeight + height - resizeStyle.size / 2, + width, + height: resizeStyle.size, + }; + + const resizeClipAreaBBox = { + x: 0, + // There are frozen rows, so the clip should start from the position of the frozen rows. + y: this.frozenRowHeight, + width: headerWidth, + height: headerHeight, + }; + + if ( + !shouldAddResizeArea(resizeAreaBBox, resizeClipAreaBBox, { + scrollX, + scrollY, + }) + ) { + return; + } + + const offsetX = position?.x + x - scrollX + seriesNumberWidth; + const offsetY = position?.y + y + this.frozenRowHeight - scrollY; + + const resizeAreaWidth = this.spreadsheet.isFrozenRowHeader() + ? headerWidth - seriesNumberWidth - (x - scrollX) + : width; + + resizeArea.addShape('rect', { + attrs: { + ...getResizeAreaAttrs({ + id: this.meta.id, + theme: resizeStyle, + type: ResizeDirectionType.Vertical, + effect: ResizeAreaEffect.Cell, + offsetX, + offsetY, + width, + height, + meta: this.meta, + }), + x: offsetX, + y: offsetY + height - resizeStyle.size / 2, + width: resizeAreaWidth, + }, + }); + } +} diff --git a/packages/s2-core/src/cell/header-cell.ts b/packages/s2-core/src/cell/header-cell.ts index a2dfd1eb3f..76d7685c8a 100644 --- a/packages/s2-core/src/cell/header-cell.ts +++ b/packages/s2-core/src/cell/header-cell.ts @@ -48,7 +48,7 @@ export abstract class HeaderCell extends BaseCell { protected abstract isBolderText(): boolean; - protected handleRestOptions(...[headerConfig]: [BaseHeaderConfig]) { + protected handleRestOptions(...[headerConfig]: [BaseHeaderConfig, unknown]) { this.headerConfig = { ...headerConfig }; const { value, query } = this.meta; const sortParams = this.spreadsheet.dataCfg.sortParams; diff --git a/packages/s2-core/src/cell/index.ts b/packages/s2-core/src/cell/index.ts index fc45940492..54eb1eafba 100644 --- a/packages/s2-core/src/cell/index.ts +++ b/packages/s2-core/src/cell/index.ts @@ -9,6 +9,8 @@ import { TableColCell } from './table-col-cell'; import { TableCornerCell } from './table-corner-cell'; import { TableDataCell } from './table-data-cell'; import { TableSeriesCell } from './table-series-cell'; +import { FrozenRowCell } from './frozen-row-cell'; +import { SeriesNumberCell } from './series-number-cell'; export { TableCornerCell, @@ -22,4 +24,6 @@ export { CornerCell, BaseCell, HeaderCell, + FrozenRowCell, + SeriesNumberCell, }; diff --git a/packages/s2-core/src/cell/row-cell.ts b/packages/s2-core/src/cell/row-cell.ts index 413a77f5e1..34d23713d7 100644 --- a/packages/s2-core/src/cell/row-cell.ts +++ b/packages/s2-core/src/cell/row-cell.ts @@ -167,8 +167,7 @@ export class RowCell extends HeaderCell { if (!isCollapsed) { const oldScrollY = this.spreadsheet.store.get('scrollY'); // 可视窗口高度 - const viewportHeight = - this.spreadsheet.facet.panelBBox.viewportHeight || 0; + const viewportHeight = this.headerConfig.viewportHeight || 0; // 被折叠项的高度 const deleteHeight = getAllChildrenNodeHeight(this.meta); // 折叠后真实高度 @@ -281,7 +280,8 @@ export class RowCell extends HeaderCell { } = this.headerConfig; const resizeAreaBBox = { - x, + // fix: When horizontally scrolling and closing the entire frozen header, the resize area is being removed prematurely. + x: x + seriesNumberWidth, y: y + height - resizeStyle.size / 2, width, height: resizeStyle.size, diff --git a/packages/s2-core/src/cell/series-number-cell.ts b/packages/s2-core/src/cell/series-number-cell.ts new file mode 100644 index 0000000000..0b6e9fb8c1 --- /dev/null +++ b/packages/s2-core/src/cell/series-number-cell.ts @@ -0,0 +1,27 @@ +import type { Condition, MappingResult, TextTheme } from '../common'; +import { RowCell } from './row-cell'; + +export class SeriesNumberCell extends RowCell { + protected initCell(): void { + this.drawBackgroundShape(); + this.drawRectBorder(); + this.drawTextShape(); + } + + protected getTextStyle(): TextTheme { + const style = super.getTextStyle(); + return { ...style, textAlign: 'center' }; + } + + protected drawResizeAreaInLeaf(): void {} + + public update() {} + + public findFieldCondition(): Condition | undefined { + return undefined; + } + + public mappingValue(): MappingResult | undefined { + return undefined; + } +} diff --git a/packages/s2-core/src/common/constant/basic.ts b/packages/s2-core/src/common/constant/basic.ts index 913615b674..9af8a77a63 100644 --- a/packages/s2-core/src/common/constant/basic.ts +++ b/packages/s2-core/src/common/constant/basic.ts @@ -9,8 +9,8 @@ export const BACK_GROUND_GROUP_CONTAINER_Z_INDEX = 0; // foregroundGroup 上的 children 层叠顺序 export const FRONT_GROUND_GROUP_CONTAINER_Z_INDEX = 3; // 约定这个 z-index 为 0 的 container 作为基准 -export const FRONT_GROUND_GROUP_COL_SCROLL_Z_INDEX = 3; -export const FRONT_GROUND_GROUP_COL_FROZEN_Z_INDEX = 4; +export const FRONT_GROUND_GROUP_SCROLL_Z_INDEX = 3; +export const FRONT_GROUND_GROUP_FROZEN_Z_INDEX = 4; export const FRONT_GROUND_GROUP_RESIZE_AREA_Z_INDEX = 5; export const FRONT_GROUND_GROUP_BRUSH_SELECTION_Z_INDEX = 5; @@ -45,6 +45,8 @@ export const KEY_GROUP_COL_SCROLL = 'colScrollGroup'; export const KEY_GROUP_COL_FROZEN = 'colFrozenGroup'; export const KEY_GROUP_COL_FROZEN_TRAILING = 'colFrozenTrailingGroup'; export const KEY_GROUP_GRID_GROUP = 'gridGroup'; +export const KEY_GROUP_ROW_SCROLL = 'rowScrollGroup'; +export const KEY_GROUP_ROW_HEADER_FROZEN = 'RowHeaderFrozenGroup'; // key of series number node in corner header export const KEY_SERIES_NUMBER_NODE = 'series-number-node'; diff --git a/packages/s2-core/src/common/constant/frozen.ts b/packages/s2-core/src/common/constant/frozen.ts index 4de2801ec5..058586bd3d 100644 --- a/packages/s2-core/src/common/constant/frozen.ts +++ b/packages/s2-core/src/common/constant/frozen.ts @@ -26,10 +26,10 @@ export const FrozenCellGroupMap = { }; export interface FrozenOpts { - frozenRowCount: number; - frozenColCount: number; - frozenTrailingRowCount: number; - frozenTrailingColCount: number; + frozenRowCount?: number; + frozenColCount?: number; + frozenTrailingRowCount?: number; + frozenTrailingColCount?: number; } export interface FrozenCellIndex { diff --git a/packages/s2-core/src/common/interface/s2Options.ts b/packages/s2-core/src/common/interface/s2Options.ts index 7ee7c9d042..6b156caaa4 100644 --- a/packages/s2-core/src/common/interface/s2Options.ts +++ b/packages/s2-core/src/common/interface/s2Options.ts @@ -78,6 +78,8 @@ export interface S2BasicOptions< supportCSSTransform?: boolean; // custom device pixel ratio, default "window.devicePixelRatio" devicePixelRatio?: number; + // pivot sheet type: frozen head row, default false + frozenEntireHeadRowPivot?: boolean; /** ***********CUSTOM CELL/HEADER HOOKS**************** */ // custom data cell diff --git a/packages/s2-core/src/facet/base-facet.ts b/packages/s2-core/src/facet/base-facet.ts index ff6cc9ac36..1ed736367f 100644 --- a/packages/s2-core/src/facet/base-facet.ts +++ b/packages/s2-core/src/facet/base-facet.ts @@ -64,6 +64,7 @@ import { Frame, RowHeader, SeriesNumberHeader, + type RowHeaderConfig, } from './header'; import type { ViewCellHeights } from './layout/interface'; import type { Node } from './layout/node'; @@ -74,6 +75,7 @@ import { optimizeScrollXY, translateGroup, } from './utils'; +import type { BaseHeader, BaseHeaderConfig } from './header/base'; export abstract class BaseFacet { // spreadsheet instance @@ -118,7 +120,7 @@ export abstract class BaseFacet { public cornerHeader: CornerHeader; - public rowIndexHeader: SeriesNumberHeader; + public rowIndexHeader: BaseHeader; public centerFrame: Frame; @@ -1018,7 +1020,7 @@ export abstract class BaseFacet { }); }; - protected clip(scrollX: number, scrollY: number) { + protected panelScrollGroupClip(scrollX: number, scrollY: number) { const isFrozenRowHeader = this.spreadsheet.isFrozenRowHeader(); this.spreadsheet.panelScrollGroup?.setClip({ type: 'rect', @@ -1031,6 +1033,10 @@ export abstract class BaseFacet { }); } + protected clip(scrollX: number, scrollY: number) { + this.panelScrollGroupClip(scrollX, scrollY); + } + /** * Translate panelGroup, rowHeader, cornerHeader, columnHeader ect * according to new scroll offset @@ -1210,22 +1216,26 @@ export abstract class BaseFacet { this.foregroundGroup.add(this.centerFrame); } + protected getRowHeaderCfg(): RowHeaderConfig { + const { y, viewportHeight, viewportWidth, height } = this.panelBBox; + const seriesNumberWidth = this.getSeriesNumberWidth(); + return { + width: this.cornerBBox.width, + height, + viewportWidth, + viewportHeight, + position: { x: 0, y }, + data: this.layoutResult.rowNodes, + hierarchyType: this.cfg.hierarchyType, + linkFields: this.cfg.spreadsheet.options?.interaction?.linkFields ?? [], + seriesNumberWidth, + spreadsheet: this.spreadsheet, + }; + } + protected getRowHeader(): RowHeader { if (!this.rowHeader) { - const { y, viewportHeight, viewportWidth, height } = this.panelBBox; - const seriesNumberWidth = this.getSeriesNumberWidth(); - return new RowHeader({ - width: this.cornerBBox.width, - height, - viewportWidth, - viewportHeight, - position: { x: 0, y }, - data: this.layoutResult.rowNodes, - hierarchyType: this.cfg.hierarchyType, - linkFields: this.cfg.spreadsheet.options?.interaction?.linkFields ?? [], - seriesNumberWidth, - spreadsheet: this.spreadsheet, - }); + return new RowHeader(this.getRowHeaderCfg()); } return this.rowHeader; } @@ -1264,7 +1274,7 @@ export abstract class BaseFacet { return this.cornerHeader; } - protected getSeriesNumberHeader(): SeriesNumberHeader { + protected getSeriesNumberHeader(): BaseHeader { return SeriesNumberHeader.getSeriesNumberHeader( this.panelBBox, this.getSeriesNumberWidth(), diff --git a/packages/s2-core/src/facet/frozen-facet.ts b/packages/s2-core/src/facet/frozen-facet.ts new file mode 100644 index 0000000000..e38450d00f --- /dev/null +++ b/packages/s2-core/src/facet/frozen-facet.ts @@ -0,0 +1,746 @@ +import type { Group } from '@antv/g-canvas'; +import { get, last } from 'lodash'; +import { + FRONT_GROUND_GROUP_FROZEN_Z_INDEX, + FrozenCellGroupMap, + FrozenGroup, + KEY_GROUP_FROZEN_SPLIT_LINE, +} from '../common/constant'; +import type { + S2CellType, + S2TableSheetOptions, + SplitLine, + SpreadSheetFacetCfg, + ViewMeta, +} from '../common/interface'; +import type { Indexes, PanelIndexes } from '../utils/indexes'; +import { getValidFrozenOptions, renderLine } from '../utils'; +import { + getColsForGrid, + getFrozenRowsForGrid, + getRowsForGrid, +} from '../utils/grid'; +import { + calculateInViewIndexes, + getFrozenDataCellType, + getFrozenLeafNodesCount, + isTopLevelNode, + splitInViewIndexesWithFrozen, + translateGroup, + translateGroupX, + translateGroupY, +} from './utils'; +import { BaseFacet } from './base-facet'; + +/** + * Defines the row freeze abstract standard interface + */ +export abstract class FrozenFacet extends BaseFacet { + public rowOffsets: number[]; + + public frozenGroupInfo: Record< + FrozenGroup, + { + width?: number; + height?: number; + range?: number[]; + } + > = { + [FrozenGroup.FROZEN_COL]: { + width: 0, + }, + [FrozenGroup.FROZEN_ROW]: { + height: 0, + }, + [FrozenGroup.FROZEN_TRAILING_ROW]: { + height: 0, + }, + [FrozenGroup.FROZEN_TRAILING_COL]: { + width: 0, + }, + }; + + public panelScrollGroupIndexes: Indexes = []; + + public constructor(cfg: SpreadSheetFacetCfg) { + super(cfg); + } + + // The core process of freezing is as follows: + // 1. prepareGroup + + // 1.1. spread-sheet defined group and added panelGroup + + // 1.2. prepare froznGroup info and init frozenGroup position + public calculateFrozenGroupInfo() { + const { + frozenColCount, + frozenRowCount, + frozenTrailingColCount, + frozenTrailingRowCount, + } = this.getFrozenOptionsDirectly(); + + const colLeafNodes = this.layoutResult.colNodes.filter((node) => { + return isTopLevelNode(node); + }); + const viewCellHeights = this.viewCellHeights; + const cellRange = this.getCellRange(); + const { frozenCol, frozenTrailingCol, frozenRow, frozenTrailingRow } = + this.frozenGroupInfo; + + if (frozenColCount > 0) { + frozenCol.width = + colLeafNodes[frozenColCount - 1].x + + colLeafNodes[frozenColCount - 1].width - + 0; + frozenCol.range = [0, frozenColCount - 1]; + } + + if (frozenRowCount > 0) { + frozenRow.height = + viewCellHeights.getCellOffsetY(cellRange.start + frozenRowCount) - + viewCellHeights.getCellOffsetY(cellRange.start); + frozenRow.range = [cellRange.start, cellRange.start + frozenRowCount - 1]; + } + + if (frozenTrailingColCount > 0) { + frozenTrailingCol.width = + colLeafNodes[colLeafNodes.length - 1].x - + colLeafNodes[colLeafNodes.length - frozenTrailingColCount].x + + colLeafNodes[colLeafNodes.length - 1].width; + frozenTrailingCol.range = [ + colLeafNodes.length - frozenTrailingColCount, + colLeafNodes.length - 1, + ]; + } + + if (frozenTrailingRowCount > 0) { + frozenTrailingRow.height = + viewCellHeights.getCellOffsetY(cellRange.end + 1) - + viewCellHeights.getCellOffsetY( + cellRange.end + 1 - frozenTrailingRowCount, + ); + frozenTrailingRow.range = [ + cellRange.end - frozenTrailingRowCount + 1, + cellRange.end, + ]; + } + } + + protected initFrozenGroupPosition = () => { + const { scrollY, scrollX } = this.getScrollOffset(); + const paginationScrollY = this.getPaginationScrollY(); + + translateGroup( + this.spreadsheet.frozenRowGroup, + this.cornerBBox.width - scrollX, + this.cornerBBox.height - paginationScrollY, + ); + translateGroup( + this.spreadsheet.frozenColGroup, + this.cornerBBox.width, + this.cornerBBox.height - scrollY - paginationScrollY, + ); + translateGroup( + this.spreadsheet.frozenTrailingColGroup, + this.cornerBBox.width, + this.cornerBBox.height - scrollY - paginationScrollY, + ); + translateGroup( + this.spreadsheet.frozenTopGroup, + this.cornerBBox.width, + this.cornerBBox.height - paginationScrollY, + ); + }; + + // 2. renderGroupingNodes + + // 2.1. doLayout: "No public logic extraction, please define implementation in subclasses" + + // 2.2. calculate the offsets scrollX and scrollY, the index of the frozen nodes within the visible viewport is determined. + public calculateXYIndexes(scrollX: number, scrollY: number): PanelIndexes { + const colLength = this.layoutResult.colLeafNodes.length; + const cellRange = this.getCellRange(); + + const { viewportHeight: height, viewportWidth: width } = this.panelBBox; + + const { + frozenColCount = 0, + frozenRowCount = 0, + frozenTrailingColCount = 0, + frozenTrailingRowCount = 0, + } = this.getFrozenOptionsDirectly(); + + const finalViewport = { + width, + height, + x: 0, + y: 0, + }; + + if (frozenTrailingColCount > 0 || frozenColCount > 0) { + const { frozenTrailingCol, frozenCol } = this.frozenGroupInfo; + finalViewport.width -= frozenTrailingCol.width! + frozenCol.width!; + finalViewport.x += frozenCol.width ?? 0; + } + + if (frozenTrailingRowCount > 0 || frozenRowCount > 0) { + const { frozenRow, frozenTrailingRow } = this.frozenGroupInfo; + // canvas 高度小于row height和trailingRow height的时候 height 为 0 + if ( + finalViewport.height < + frozenRow.height! + frozenTrailingRow.height! + ) { + finalViewport.height = 0; + finalViewport.y = 0; + } else { + finalViewport.height -= frozenRow.height! + frozenTrailingRow.height!; + finalViewport.y += frozenRow.height!; + } + } + + // https://github.com/antvis/S2/issues/2255 + const indexes = this.spreadsheet.dataSet?.isEmpty?.() + ? ([] as unknown as Indexes) + : calculateInViewIndexes( + scrollX, + scrollY, + this.viewCellWidths, + this.viewCellHeights, + finalViewport, + this.getRealScrollX(this.cornerBBox.width), + ); + + this.panelScrollGroupIndexes = indexes; + + const { colCount, trailingColCount } = this.getRealFrozenColumns( + frozenColCount, + frozenTrailingColCount, + ); + + return splitInViewIndexesWithFrozen( + indexes, + { + frozenColCount: colCount, + frozenRowCount, + frozenTrailingColCount: trailingColCount, + frozenTrailingRowCount, + }, + colLength, + cellRange, + ); + } + + // 2.3. Add the node to the frozen group. + addCell = (cell: S2CellType) => { + const { + frozenRowCount, + frozenColCount, + frozenTrailingRowCount, + frozenTrailingColCount, + } = this.getBizRevisedFrozenOptions(); + const colLength = this.layoutResult.colsHierarchy.getLeaves().length; + const cellRange = this.getCellRange(); + + const { colCount, trailingColCount } = this.getRealFrozenColumns( + frozenColCount, + frozenTrailingColCount, + ); + + const frozenCellType = getFrozenDataCellType( + cell.getMeta(), + { + frozenRowCount, + frozenColCount: colCount, + frozenTrailingRowCount, + frozenTrailingColCount: trailingColCount, + }, + colLength, + cellRange, + ); + + const group = FrozenCellGroupMap[frozenCellType]; + if (group && this.spreadsheet[group]) { + (this.spreadsheet[group] as Group).add(cell); + } + }; + + // 2.4. update the coordinates of the frozen rows and columns' dividers based on scrollX and scrollY. + protected updateFrozenGroupGrid(): void { + [ + FrozenGroup.FROZEN_COL, + FrozenGroup.FROZEN_ROW, + FrozenGroup.FROZEN_TRAILING_COL, + FrozenGroup.FROZEN_TRAILING_ROW, + ].forEach((key) => { + if (!this.frozenGroupInfo[key].range) { + return; + } + + let cols = []; + let rows = []; + + if (key.toLowerCase().includes('row')) { + const [rowMin, rowMax] = this.frozenGroupInfo[key].range; + cols = this.gridInfo.cols; + rows = getRowsForGrid(rowMin, rowMax, this.viewCellHeights); + if (key === FrozenGroup.FROZEN_TRAILING_ROW) { + const { minY } = this.spreadsheet.frozenTrailingRowGroup.getBBox(); + rows = getFrozenRowsForGrid( + rowMin, + rowMax, + Math.ceil(minY), + this.viewCellHeights, + ); + } + } else { + const [colMin, colMax] = this.frozenGroupInfo[key].range; + const nodes = this.layoutResult.colNodes.filter((node) => + isTopLevelNode(node), + ); + cols = getColsForGrid(colMin, colMax, nodes); + rows = this.gridInfo.rows; + } + + this.spreadsheet[`${key}Group`].updateGrid( + { + cols, + rows, + }, + `${key}Group`, + ); + }); + } + + public updatePanelScrollGroup(): void { + super.updatePanelScrollGroup(); + this.updateFrozenGroupGrid(); + } + + // 3. scrollInteraction + // 3.1. translate row and columns frozen group by scrollX and scrollY + protected translateRelatedGroups( + scrollX: number, + scrollY: number, + hRowScroll: number, + ): void { + const { + frozenColGroup, + frozenTrailingColGroup, + frozenRowGroup, + frozenTrailingRowGroup, + } = this.spreadsheet; + [frozenRowGroup, frozenTrailingRowGroup].forEach((g) => { + translateGroupX(g, this.cornerBBox.width - scrollX); + }); + + [frozenColGroup, frozenTrailingColGroup].forEach((g) => { + translateGroupY(g, this.cornerBBox.height - scrollY); + }); + super.translateRelatedGroups(scrollX, scrollY, hRowScroll); + + this.updateRowResizeArea(); + this.renderFrozenGroupSplitLine(scrollX, scrollY); + } + + // 3.2. frozen cell drawResizeArea + protected updateRowResizeArea() {} + + // 3.3. render frozen group split line + protected renderFrozenGroupSplitLine = (scrollX: number, scrollY: number) => { + const { + width: panelWidth, + height: panelHeight, + viewportWidth, + viewportHeight, + } = this.panelBBox; + const { height: cornerHeight } = this.cornerBBox; + const colLeafNodes = this.layoutResult.colNodes.filter((node) => { + return isTopLevelNode(node); + }); + const cellRange = this.getCellRange(); + const dataLength = cellRange.end - cellRange.start; + const { + frozenRowCount = 0, + frozenColCount = 0, + frozenTrailingColCount = 0, + frozenTrailingRowCount = 0, + } = getValidFrozenOptions( + this.getBizRevisedFrozenOptions(), + colLeafNodes.length, + dataLength, + ); + + // 在分页条件下需要额外处理 Y 轴滚动值 + const relativeScrollY = Math.floor(scrollY - this.getPaginationScrollY()); + + // scroll boundary + const maxScrollX = Math.max( + 0, + (last(this.viewCellWidths) ?? 0) - viewportWidth, + ); + const maxScrollY = Math.max( + 0, + this.viewCellHeights.getCellOffsetY(cellRange.end + 1) - + this.viewCellHeights.getCellOffsetY(cellRange.start) - + viewportHeight, + ); + + // remove previous splitline group + this.foregroundGroup.findById(KEY_GROUP_FROZEN_SPLIT_LINE)?.remove(); + + const style: SplitLine = get(this.cfg, 'spreadsheet.theme.splitLine'); + const splitLineGroup = this.foregroundGroup.addGroup({ + id: KEY_GROUP_FROZEN_SPLIT_LINE, + zIndex: FRONT_GROUND_GROUP_FROZEN_Z_INDEX, + }); + + const verticalBorderStyle = { + lineWidth: style?.verticalBorderWidth, + stroke: style?.verticalBorderColor, + opacity: style?.verticalBorderColorOpacity, + lineDash: style?.borderDash, + }; + + const horizontalBorderStyle = { + lineWidth: style?.horizontalBorderWidth, + stroke: style?.horizontalBorderColor, + opacity: style?.horizontalBorderColorOpacity, + lineDash: style?.borderDash, + }; + + if (frozenColCount > 0) { + const x = colLeafNodes.reduce((prev, item, idx) => { + if (idx < frozenColCount) { + return prev + item.width; + } + return prev; + }, 0); + + const height = frozenTrailingRowCount > 0 ? panelHeight : viewportHeight; + + renderLine( + splitLineGroup as Group, + { + x1: x, + x2: x, + y1: cornerHeight, + y2: cornerHeight + height, + }, + { + ...verticalBorderStyle, + }, + ); + + if (style.showShadow && scrollX > 0) { + splitLineGroup.addShape('rect', { + attrs: { + x, + y: cornerHeight, + width: style.shadowWidth, + height, + fill: this.getShadowFill(0), + }, + }); + } + } + + if (frozenRowCount > 0) { + const y = + cornerHeight + + this.getTotalHeightForRange( + cellRange.start, + cellRange.start + frozenRowCount - 1, + ); + const width = frozenTrailingColCount > 0 ? panelWidth : viewportWidth; + renderLine( + splitLineGroup as Group, + { + x1: 0, + x2: width, + y1: y, + y2: y, + }, + { + ...horizontalBorderStyle, + }, + ); + + if (style.showShadow && relativeScrollY > 0) { + splitLineGroup.addShape('rect', { + attrs: { + x: 0, + y, + width, + height: style.shadowWidth, + fill: this.getShadowFill(90), + }, + }); + } + } + + if (frozenTrailingColCount > 0) { + // const width = colLeafNodes.reduceRight((prev, item, idx) => { + // if (idx >= colLeafNodes.length - frozenTrailingColCount) { + // return prev + item.width; + // } + // return prev; + // }, 0); + const { x } = colLeafNodes[colLeafNodes.length - frozenTrailingColCount]; + const height = frozenTrailingRowCount ? panelHeight : viewportHeight; + renderLine( + splitLineGroup as Group, + { + x1: x, + x2: x, + y1: cornerHeight, + y2: cornerHeight + height, + }, + { + ...verticalBorderStyle, + }, + ); + + if (style.showShadow && Math.floor(scrollX) < Math.floor(maxScrollX)) { + splitLineGroup.addShape('rect', { + attrs: { + x: x - (style.shadowWidth ?? 0), + y: cornerHeight, + width: style.shadowWidth, + height, + fill: this.getShadowFill(180), + }, + }); + } + } + + if (frozenTrailingRowCount > 0) { + const y = + this.panelBBox.maxY - + this.getTotalHeightForRange( + cellRange.end - frozenTrailingRowCount + 1, + cellRange.end, + ); + const width = frozenTrailingColCount > 0 ? panelWidth : viewportWidth; + renderLine( + splitLineGroup as Group, + { + x1: 0, + x2: width, + y1: y, + y2: y, + }, + { + ...horizontalBorderStyle, + }, + ); + + if (style.showShadow && relativeScrollY < Math.floor(maxScrollY)) { + splitLineGroup.addShape('rect', { + attrs: { + x: 0, + y: y - style.shadowWidth, + width, + height: style.shadowWidth, + fill: this.getShadowFill(270), + }, + }); + } + } + this.foregroundGroup.sort(); + }; + + // 3.4. clip frozen group by scrolX and scrollY + // 对 panelScrollGroup 以及四个方向的 frozenGroup 做 Clip,避免有透明度时冻结分组和滚动分组展示重叠 + protected clip(scrollX: number, scrollY: number) { + const paginationScrollY = this.getPaginationScrollY(); + const { + frozenRowGroup, + frozenColGroup, + frozenTrailingColGroup, + frozenTrailingRowGroup, + panelScrollGroup, + } = this.spreadsheet; + let frozenColGroupWidth = 0; + let frozenRowGroupHeight = 0; + let frozenTrailingRowGroupHeight = 0; + if (frozenColGroup) { + frozenColGroupWidth = frozenColGroup.getBBox().width; + } + if (frozenRowGroup) { + frozenRowGroupHeight = frozenRowGroup.getBBox().height; + } + let frozenTrailingColBBox; + if (frozenTrailingColGroup) { + frozenTrailingColBBox = frozenTrailingColGroup.getBBox(); + } + if (frozenTrailingRowGroup) { + frozenTrailingRowGroupHeight = frozenTrailingRowGroup.getBBox().height; + } + const panelScrollGroupWidth = + this.panelBBox.width - + frozenColGroupWidth - + (frozenTrailingColBBox?.width ?? 0); + const panelScrollGroupHeight = + this.panelBBox.height - + frozenRowGroupHeight - + frozenTrailingRowGroupHeight; + + panelScrollGroup.setClip({ + type: 'rect', + attrs: { + x: scrollX + frozenColGroupWidth, + y: scrollY + frozenRowGroupHeight, + width: panelScrollGroupWidth, + height: panelScrollGroupHeight, + }, + }); + + frozenRowGroup.setClip({ + type: 'rect', + attrs: { + x: scrollX + frozenColGroupWidth, + y: paginationScrollY, + width: panelScrollGroupWidth, + height: frozenRowGroupHeight, + }, + }); + + frozenTrailingRowGroup?.setClip({ + type: 'rect', + attrs: { + x: scrollX + frozenColGroupWidth, + y: frozenTrailingRowGroup.getBBox().minY, + width: panelScrollGroupWidth, + height: frozenTrailingRowGroupHeight, + }, + }); + + const colClipArea = { + y: scrollY + frozenRowGroupHeight, + height: panelScrollGroupHeight, + }; + + frozenColGroup?.setClip({ + type: 'rect', + attrs: { + ...colClipArea, + x: 0, + width: frozenColGroupWidth, + }, + }); + + frozenTrailingColGroup?.setClip({ + type: 'rect', + attrs: { + ...colClipArea, + x: frozenTrailingColBBox?.minX ?? 0, + width: frozenTrailingColBBox?.width ?? 0, + }, + }); + } + + protected init(): void { + super.init(); + this.initRowOffsets(); + } + + public render(): void { + this.calculateFrozenGroupInfo(); + super.render(); + this.initFrozenGroupPosition(); + } + + private getFrozenOptionsDirectly(): S2TableSheetOptions { + const colLength = this.layoutResult.colLeafNodes.length; + const cellRange = this.getCellRange(); + + return getValidFrozenOptions( + this.getBizRevisedFrozenOptions(), + colLength, + cellRange.end - cellRange.start + 1, + ); + } + + protected getBizRevisedFrozenOptions(): S2TableSheetOptions { + const { + frozenColCount, + frozenRowCount, + frozenTrailingColCount, + frozenTrailingRowCount, + } = this.cfg; + return { + frozenColCount, + frozenRowCount, + frozenTrailingColCount, + frozenTrailingRowCount, + }; + } + + getRealFrozenColumns = ( + frozenColCount: number, + frozenTrailingColCount: number, + ) => { + let colCount = frozenColCount; + let trailingColCount = frozenTrailingColCount; + if (frozenColCount || frozenTrailingColCount) { + let nodes = this.layoutResult.colsHierarchy.getNodes(); + nodes = nodes.filter((node) => isTopLevelNode(node)); + ({ colCount, trailingColCount } = getFrozenLeafNodesCount( + nodes, + frozenColCount, + frozenTrailingColCount, + )); + } + return { colCount, trailingColCount }; + }; + + private initRowOffsets() { + const { dataSet } = this.cfg; + const heightByField = get( + this.spreadsheet, + 'options.style.rowCfg.heightByField', + {}, + ); + if (Object.keys(heightByField).length) { + const data = dataSet.getDisplayDataSet(); + this.rowOffsets = [0]; + let lastOffset = 0; + data.forEach((_, idx) => { + const currentHeight = + heightByField?.[String(idx)] ?? this.getDefaultCellHeight(); + const currentOffset = lastOffset + currentHeight; + this.rowOffsets.push(currentOffset); + lastOffset = currentOffset; + }); + } + } + + public getTotalHeightForRange = (start: number, end: number) => { + if (start < 0 || end < 0) { + return 0; + } + + if (this.rowOffsets) { + return this.rowOffsets[end + 1] - this.rowOffsets[start]; + } + + let totalHeight = 0; + for (let index = start; index < end + 1; index++) { + const height = this.getDefaultCellHeight(); + totalHeight += height; + } + return totalHeight; + }; + + protected getDefaultCellHeight() { + const { cellCfg } = this.cfg; + + return cellCfg?.height ?? 0; + } + + protected getShadowFill = (angle: number) => { + const style: SplitLine = get(this.cfg, 'spreadsheet.theme.splitLine'); + return `l (${angle}) 0:${style.shadowColors?.left} 1:${style.shadowColors?.right}`; + }; +} diff --git a/packages/s2-core/src/facet/header/base-frozen-row.ts b/packages/s2-core/src/facet/header/base-frozen-row.ts new file mode 100644 index 0000000000..154f7e021e --- /dev/null +++ b/packages/s2-core/src/facet/header/base-frozen-row.ts @@ -0,0 +1,138 @@ +import type { IGroup } from '@antv/g-canvas'; +import { get } from 'lodash'; +import type { Node } from '../layout/node'; +import { + getFrozenOptionsPivot, + translateGroup, + translateGroupX, +} from '../utils'; +import { + FRONT_GROUND_GROUP_FROZEN_Z_INDEX, + FRONT_GROUND_GROUP_SCROLL_Z_INDEX, + KEY_GROUP_ROW_HEADER_FROZEN, + KEY_GROUP_ROW_SCROLL, +} from '../../common/constant'; +import { RowHeader, type RowHeaderConfig } from './row'; + +export class BaseFrozenRowHeader extends RowHeader { + protected scrollGroup: IGroup; + + protected frozenHeadGroup: IGroup; + + constructor(cfg: RowHeaderConfig) { + super(cfg); + this.scrollGroup = this.addGroup({ + name: KEY_GROUP_ROW_SCROLL, + zIndex: FRONT_GROUND_GROUP_SCROLL_Z_INDEX, + }); + + this.frozenHeadGroup = this.addGroup({ + name: KEY_GROUP_ROW_HEADER_FROZEN, + zIndex: FRONT_GROUND_GROUP_FROZEN_Z_INDEX, + }); + + const { position, seriesNumberWidth } = this.headerConfig; + translateGroup( + this.frozenHeadGroup, + position.x + seriesNumberWidth, + position.y, + ); + } + + protected rowCellInRectXDir(item: Node): boolean { + const { width, scrollX, seriesNumberWidth } = this.headerConfig; + const itemX = item.x + seriesNumberWidth; + return width + scrollX > itemX && scrollX < itemX + item.width; + } + + protected rowCellInRectYDir(item: Node): boolean { + const { viewportHeight, scrollY } = this.headerConfig; + return viewportHeight + scrollY > item.y && scrollY < item.y + item.height; + } + + protected rowCellInRect(item: Node): boolean { + const visibleInXDir = this.rowCellInRectXDir(item); + if (this.isFrozenRow(item)) { + return visibleInXDir; + } + return visibleInXDir && this.rowCellInRectYDir(item); + } + + protected getCellGroup(item: Node): IGroup { + if (this.isFrozenRow(item)) { + return this.frozenHeadGroup; + } + return this.scrollGroup; + } + + protected offset() { + const { scrollX, scrollY, position, seriesNumberWidth } = this.headerConfig; + // 向右多移动的seriesNumberWidth是序号的宽度 + translateGroup( + this.scrollGroup, + position.x - scrollX + seriesNumberWidth, + position.y + this.getFrozenRowHeight() - scrollY, + ); + translateGroupX( + this.frozenHeadGroup, + position.x - scrollX + seriesNumberWidth, + ); + } + + public clip(): void { + const { width, viewportHeight, scrollX, scrollY, seriesNumberWidth } = + this.headerConfig; + this.scrollGroup.setClip({ + type: 'rect', + attrs: { + // 由于多移动了seriesNumberWidth跨度,所有需要向左切。 - 是反向剪裁(右 -> 左) + x: scrollX - seriesNumberWidth, + y: scrollY, + width, + height: viewportHeight, + }, + }); + this.frozenHeadGroup.setClip({ + type: 'rect', + attrs: { + x: scrollX - seriesNumberWidth, + y: 0, + width: this.headerConfig.width, + height: this.getFrozenRowHeight(), + }, + }); + } + + protected getFrozenRowHeight = () => { + // get row head height TODO: There may be a better solution + const frozenCount = this.getFrozenRowCount(); + let sum = 0; + for (let i = 0, len = frozenCount; i < len; i++) { + sum += get( + this.headerConfig.spreadsheet, + `facet.layoutResult.rowNodes[${i}].height`, + 0, + ); + } + return sum; + }; + + protected isFrozenRow(item: Node): boolean { + const frozenRowCount = this.getFrozenRowCount(); + return ( + frozenRowCount > 0 && item.rowIndex >= 0 && item.rowIndex < frozenRowCount + ); + } + + protected getFrozenRowCount(): number { + const { spreadsheet } = this.headerConfig; + const { frozenRowCount } = getFrozenOptionsPivot(spreadsheet.facet?.cfg); + return frozenRowCount; + } + + public clear(): void { + this.frozenHeadGroup.clear(); + this.scrollGroup.clear(); + // super.clear(); + } +} diff --git a/packages/s2-core/src/facet/header/col.ts b/packages/s2-core/src/facet/header/col.ts index 60c2df7002..b335d4485b 100644 --- a/packages/s2-core/src/facet/header/col.ts +++ b/packages/s2-core/src/facet/header/col.ts @@ -2,7 +2,7 @@ import type { IGroup, IShape } from '@antv/g-canvas'; import { each } from 'lodash'; import { ColCell } from '../../cell'; import { - FRONT_GROUND_GROUP_COL_SCROLL_Z_INDEX, + FRONT_GROUND_GROUP_SCROLL_Z_INDEX, KEY_GROUP_COL_SCROLL, } from '../../common/constant'; import type { SpreadSheet } from '../../sheet-type'; @@ -28,7 +28,7 @@ export class ColHeader extends BaseHeader { super(cfg); this.scrollGroup = this.addGroup({ name: KEY_GROUP_COL_SCROLL, - zIndex: FRONT_GROUND_GROUP_COL_SCROLL_Z_INDEX, + zIndex: FRONT_GROUND_GROUP_SCROLL_Z_INDEX, }); } diff --git a/packages/s2-core/src/facet/header/frozen-row.ts b/packages/s2-core/src/facet/header/frozen-row.ts new file mode 100644 index 0000000000..de65f96891 --- /dev/null +++ b/packages/s2-core/src/facet/header/frozen-row.ts @@ -0,0 +1,37 @@ +import type { Node } from '../layout/node'; +import { FrozenRowCell, RowCell } from '../../cell'; +import { BaseFrozenRowHeader } from './base-frozen-row'; + +export class FrozenRowHeader extends BaseFrozenRowHeader { + // To avoid the "performance impact" of traverse the data to adjust the y-coordinate, we can correct the y-coordinate to determine if a cell is within the visible viewport. + protected rowCellInRectYDir(item: Node): boolean { + const { viewportHeight, scrollY } = this.headerConfig; + const itemY = item.y - this.getFrozenRowHeight(); + return viewportHeight + scrollY > itemY && scrollY < itemY + item.height; + } + + protected onItemReady(item: Node): Node { + if (!this.isFrozenRow(item)) { + // relative to scroll group position, scroll group position translateY frozenHeight + return Object.assign({}, item, { y: item.y - this.getFrozenRowHeight() }); + } + return item; + } + + protected createCellInstance(item: Node): RowCell { + const { spreadsheet } = this.headerConfig; + if (this.isFrozenRow(item)) { + return new RowCell(item, spreadsheet, { + ...this.headerConfig, + // prevent the text in the frozen header row from having a sticky effect. + scrollY: 0, + }); + } + return new FrozenRowCell( + item, + spreadsheet, + this.headerConfig, + this.getFrozenRowHeight(), + ); + } +} diff --git a/packages/s2-core/src/facet/header/frozen-series-number.ts b/packages/s2-core/src/facet/header/frozen-series-number.ts new file mode 100644 index 0000000000..13cd25c390 --- /dev/null +++ b/packages/s2-core/src/facet/header/frozen-series-number.ts @@ -0,0 +1,79 @@ +import { RowCell, SeriesNumberCell } from '../../cell'; +import type { SpreadSheet } from '../../sheet-type/index'; +import type { PanelBBox } from '../bbox/panelBBox'; +import { Node } from '../layout/node'; +import { BaseFrozenRowHeader } from './base-frozen-row'; + +export class FrozenSeriesNumber extends BaseFrozenRowHeader { + /** + * Get seriesNumber header by config + * @param viewportBBox + * @param seriesNumberWidth + * @param leafNodes + * @param spreadsheet + * @param cornerWidth + */ + + public static getFrozenSeriesNumberHeader({ + viewportBBox, + seriesNumberWidth, + leafNodes, + spreadsheet, + cornerWidth, + frozenRowCount, + frozenRowHeight, + }: { + viewportBBox: PanelBBox; + seriesNumberWidth: number; + leafNodes: Node[]; + spreadsheet: SpreadSheet; + cornerWidth: number; + frozenRowCount: number; + frozenRowHeight: number; + }): FrozenSeriesNumber { + const { height, viewportHeight } = viewportBBox; + const seriesNodes: Node[] = []; + const isHierarchyTreeType = spreadsheet.isHierarchyTreeType(); + leafNodes.forEach((node: Node): void => { + // 1、is spreadsheet and node is not total(grand or sub) + // 2、is listSheet + const frozenRow = frozenRowCount > 0 && node.rowIndex < frozenRowCount; + const sNode = new Node({ + id: '', + key: '', + value: `${seriesNodes.length + 1}`, + }); + sNode.x = node.x; + sNode.y = frozenRow ? node.y : node.y - frozenRowHeight; + sNode.height = isHierarchyTreeType + ? node.getTotalHeightForTreeHierarchy() + : node.height; + sNode.width = seriesNumberWidth; + sNode.rowIndex = node.rowIndex; + seriesNodes.push(sNode); + }); + return new FrozenSeriesNumber({ + width: cornerWidth, + height, + viewportWidth: cornerWidth, + viewportHeight: + frozenRowCount > 0 ? viewportHeight - frozenRowHeight : viewportHeight, + position: { x: 0, y: viewportBBox.y }, + data: seriesNodes, + spreadsheet, + // There are no other lines before the serial number row + seriesNumberWidth: 0, + hierarchyType: spreadsheet.facet.cfg.hierarchyType, + linkFields: [], + }); + } + + protected createCellInstance(item: Node): RowCell { + const frozenRow = this.isFrozenRow(item); + const cell = new SeriesNumberCell(item, this.headerConfig.spreadsheet, { + ...this.headerConfig, + scrollY: frozenRow ? 0 : this.headerConfig.scrollY, + }); + return cell; + } +} diff --git a/packages/s2-core/src/facet/header/index.ts b/packages/s2-core/src/facet/header/index.ts index b3b69e5d36..331b71c8c2 100644 --- a/packages/s2-core/src/facet/header/index.ts +++ b/packages/s2-core/src/facet/header/index.ts @@ -3,3 +3,4 @@ export { CornerHeader, type CornerHeaderConfig } from './corner'; export { Frame } from './frame'; export { RowHeader, type RowHeaderConfig } from './row'; export { SeriesNumberHeader } from './series-number'; +export { FrozenRowHeader } from './frozen-row'; diff --git a/packages/s2-core/src/facet/header/row.ts b/packages/s2-core/src/facet/header/row.ts index 911460845f..2f26cec30f 100644 --- a/packages/s2-core/src/facet/header/row.ts +++ b/packages/s2-core/src/facet/header/row.ts @@ -1,5 +1,6 @@ import type { GM } from '@antv/g-gesture'; import { each, isEmpty } from 'lodash'; +import type { IGroup } from '@antv/g-canvas'; import { RowCell } from '../../cell'; import type { S2CellType, S2Options, ViewMeta } from '../../common/interface'; import type { Node } from '../layout/node'; @@ -33,42 +34,50 @@ export class RowHeader extends BaseHeader { } } - protected layout() { - const { - data, - spreadsheet, - width, - viewportHeight, - seriesNumberWidth, - scrollY, - scrollX, - } = this.headerConfig; + // row'cell only show when visible + protected rowCellInRect(item: Node): boolean { + const { width, viewportHeight, seriesNumberWidth, scrollY, scrollX } = + this.headerConfig; + return ( + viewportHeight + scrollY > item.y && // bottom + scrollY < item.y + item.height && // top + width - seriesNumberWidth + scrollX > item.x && // left + scrollX - seriesNumberWidth < item.x + item.width + ); // right + } + + protected createCellInstance(item: Node) { + return new RowCell(item, this.headerConfig.spreadsheet, this.headerConfig); + } + protected getCellGroup(item: Node): IGroup { + return this; + } + + protected onItemReady(item: Node): Node { + return item; + } + + protected layout() { + const { data, spreadsheet } = this.headerConfig; const rowCell = spreadsheet?.facet?.cfg?.rowCell; - // row'cell only show when visible - const rowCellInRect = (item: Node): boolean => { - return ( - viewportHeight + scrollY > item.y && // bottom - scrollY < item.y + item.height && // top - width - seriesNumberWidth + scrollX > item.x && // left - scrollX - seriesNumberWidth < item.x + item.width - ); // right - }; each(data, (item: Node) => { - if (rowCellInRect(item) && item.height !== 0) { + if (this.rowCellInRect(item) && item.height !== 0) { let cell: S2CellType; + const newItem = this.onItemReady(item); // 首先由外部控制UI展示 if (rowCell) { - cell = rowCell(item, spreadsheet, this.headerConfig); + cell = rowCell(newItem, spreadsheet, this.headerConfig); } // 如果外部没处理,就用默认的 if (isEmpty(cell)) { if (spreadsheet.isPivotMode()) { - cell = new RowCell(item, spreadsheet, this.headerConfig); + cell = this.createCellInstance(newItem); } } - item.belongsCell = cell; - this.add(cell); + newItem.belongsCell = cell; + const group = this.getCellGroup(newItem); + group.add(cell); } }); } diff --git a/packages/s2-core/src/facet/header/series-number.ts b/packages/s2-core/src/facet/header/series-number.ts index c0ca50c06a..86d097b9b6 100644 --- a/packages/s2-core/src/facet/header/series-number.ts +++ b/packages/s2-core/src/facet/header/series-number.ts @@ -37,18 +37,18 @@ export class SeriesNumberHeader extends BaseHeader { leafNodes.forEach((node: Node): void => { // 1、is spreadsheet and node is not total(grand or sub) // 2、is listSheet - if (!node.isTotals || isHierarchyTreeType) { - const sNode = new Node({ - id: '', - key: '', - value: `${seriesNodes.length + 1}`, - }); - sNode.x = node.x; - sNode.y = node.y; - sNode.height = node.height; - sNode.width = seriesNumberWidth; - seriesNodes.push(sNode); - } + const sNode = new Node({ + id: '', + key: '', + value: `${seriesNodes.length + 1}`, + }); + sNode.x = node.x; + sNode.y = node.y; + sNode.height = isHierarchyTreeType + ? node.getTotalHeightForTreeHierarchy() + : node.height; + sNode.width = seriesNumberWidth; + seriesNodes.push(sNode); }); return new SeriesNumberHeader({ width: cornerWidth, diff --git a/packages/s2-core/src/facet/header/table-col.ts b/packages/s2-core/src/facet/header/table-col.ts index db680d8360..7609844ca2 100644 --- a/packages/s2-core/src/facet/header/table-col.ts +++ b/packages/s2-core/src/facet/header/table-col.ts @@ -1,7 +1,7 @@ import type { IGroup } from '@antv/g-canvas'; import { TableColCell, TableCornerCell } from '../../cell'; import { - FRONT_GROUND_GROUP_COL_FROZEN_Z_INDEX, + FRONT_GROUND_GROUP_FROZEN_Z_INDEX, KEY_GROUP_COL_FROZEN, KEY_GROUP_COL_FROZEN_TRAILING, KEY_GROUP_FROZEN_COL_RESIZE_AREA, @@ -35,14 +35,14 @@ export class TableColHeader extends ColHeader { if (frozenColCount) { this.frozenColGroup = this.addGroup({ name: KEY_GROUP_COL_FROZEN, - zIndex: FRONT_GROUND_GROUP_COL_FROZEN_Z_INDEX, + zIndex: FRONT_GROUND_GROUP_FROZEN_Z_INDEX, }); } if (frozenTrailingColCount) { this.frozenTrailingColGroup = this.addGroup({ name: KEY_GROUP_COL_FROZEN_TRAILING, - zIndex: FRONT_GROUND_GROUP_COL_FROZEN_Z_INDEX, + zIndex: FRONT_GROUND_GROUP_FROZEN_Z_INDEX, }); } } diff --git a/packages/s2-core/src/facet/layout/node.ts b/packages/s2-core/src/facet/layout/node.ts index e9e7ee0c0f..143f53394a 100644 --- a/packages/s2-core/src/facet/layout/node.ts +++ b/packages/s2-core/src/facet/layout/node.ts @@ -335,4 +335,17 @@ export class Node { } return leafChild; } + + /** + * 获取树状模式下,当前节点以及其所有子节点的高度总和 + * */ + public getTotalHeightForTreeHierarchy(): number { + if (this.height === 0 || isEmpty(this.children)) { + return this.height; + } + return this.children.reduce( + (sum, child) => sum + child.getTotalHeightForTreeHierarchy(), + this.height, + ); + } } diff --git a/packages/s2-core/src/facet/pivot-facet.ts b/packages/s2-core/src/facet/pivot-facet.ts index b12e81320b..483493e57d 100644 --- a/packages/s2-core/src/facet/pivot-facet.ts +++ b/packages/s2-core/src/facet/pivot-facet.ts @@ -14,28 +14,155 @@ import { size, sumBy, } from 'lodash'; +import type { Group } from '@antv/g-canvas'; import { DEFAULT_TREE_ROW_WIDTH, LAYOUT_SAMPLE_COUNT, type IconTheme, type MultiData, + FrozenGroup, + KEY_GROUP_FROZEN_SPLIT_LINE, + FRONT_GROUND_GROUP_FROZEN_Z_INDEX, } from '../common'; import { EXTRA_FIELD, LayoutWidthTypes, VALUE_FIELD } from '../common/constant'; import { CellTypes } from '../common/constant/interaction'; import { DebuggerUtil } from '../common/debug'; -import type { LayoutResult, ViewMeta } from '../common/interface'; +import type { + LayoutResult, + S2TableSheetOptions, + SplitLine, + ViewMeta, +} from '../common/interface'; import { getDataCellId, handleDataItem } from '../utils/cell/data-cell'; import { getActionIconConfig } from '../utils/cell/header-cell'; import { getIndexRangeWithOffsets } from '../utils/facet'; import { getCellWidth, safeJsonParse } from '../utils/text'; import { getHeaderTotalStatus } from '../utils/dataset/pivot-data-set'; -import { BaseFacet } from './base-facet'; +import { getRowsForGrid } from '../utils/grid'; +import { renderLine } from '..'; +import { FrozenFacet } from './frozen-facet'; import { buildHeaderHierarchy } from './layout/build-header-hierarchy'; import type { Hierarchy } from './layout/hierarchy'; import { layoutCoordinate, layoutDataPosition } from './layout/layout-hooks'; import { Node } from './layout/node'; +import { getFrozenOptionsPivot } from './utils'; +import { FrozenRowHeader, RowHeader } from './header'; +import { FrozenSeriesNumber } from './header/frozen-series-number'; +import type { BaseHeader, BaseHeaderConfig } from './header/base'; + +export class PivotFacet extends FrozenFacet { + protected updateFrozenGroupGrid(): void { + [FrozenGroup.FROZEN_ROW].forEach((key) => { + if (!this.frozenGroupInfo[key].range) { + return; + } + let cols = []; + let rows = []; + if (key.toLowerCase().includes('row')) { + const [rowMin, rowMax] = this.frozenGroupInfo[key].range; + cols = this.gridInfo.cols; + rows = getRowsForGrid(rowMin, rowMax, this.viewCellHeights); + } + this.spreadsheet[`${key}Group`].updateGrid( + { + cols, + rows, + }, + `${key}Group`, + ); + }); + } + + protected getBizRevisedFrozenOptions(): S2TableSheetOptions { + return getFrozenOptionsPivot(this.cfg); + } + + protected renderFrozenGroupSplitLine = (scrollX: number, scrollY: number) => { + // remove previous splitline group + this.foregroundGroup.findById(KEY_GROUP_FROZEN_SPLIT_LINE)?.remove(); + if (this.enableFrozenTotalRow()) { + // 在分页条件下需要额外处理 Y 轴滚动值 + const relativeScrollY = Math.floor(scrollY - this.getPaginationScrollY()); + const splitLineGroup = this.foregroundGroup.addGroup({ + id: KEY_GROUP_FROZEN_SPLIT_LINE, + zIndex: FRONT_GROUND_GROUP_FROZEN_Z_INDEX, + }); + const style: SplitLine = get(this.cfg, 'spreadsheet.theme.splitLine'); + const horizontalBorderStyle = { + lineWidth: style?.horizontalBorderWidth, + stroke: style?.horizontalBorderColor, + opacity: style?.horizontalBorderColorOpacity, + }; + const { height: cornerHeight } = this.cornerBBox; + + const cellRange = this.getCellRange(); + const y = + cornerHeight + + this.getTotalHeightForRange(cellRange.start, cellRange.start); + const width = + this.panelBBox.viewportWidth + + this.layoutResult.rowsHierarchy.width + + this.getSeriesNumberWidth(); + renderLine( + splitLineGroup as Group, + { + x1: 0, + x2: width, + y1: y, + y2: y, + }, + { + ...horizontalBorderStyle, + }, + ); + + if (style.showShadow && relativeScrollY > 0) { + splitLineGroup.addShape('rect', { + attrs: { + x: 0, + y, + width, + height: style.shadowWidth, + fill: this.getShadowFill(90), + }, + }); + } + } + }; + + protected clip(scrollX: number, scrollY: number): void { + const { isFrozenRowHeader, frozenRowGroup } = this.spreadsheet; + if (!isFrozenRowHeader.call(this.spreadsheet)) { + // adapt: close the entire frozen header. + // 1. panelScrollGroup clip (default) + // 2. frozenRowGroup clip + this.panelScrollGroupClip(scrollX, scrollY); + if (this.enableFrozenTotalRow()) { + const paginationScrollY = this.getPaginationScrollY(); + frozenRowGroup.setClip({ + type: 'rect', + attrs: { + x: 0, + y: paginationScrollY, + width: this.panelBBox.width + scrollX, + height: frozenRowGroup.getBBox().height, + }, + }); + } + return; + } + super.clip(scrollX, scrollY); + } + + public getFrozenRowHeight = (): number => { + const { frozenRowCount } = this.getBizRevisedFrozenOptions(); + let sum = 0; + for (let i = 0, len = frozenRowCount; i < len; i++) { + sum += get(this.layoutResult, `rowNodes[${i}].height`, 0); + } + return sum; + }; -export class PivotFacet extends BaseFacet { get rowCellTheme() { return this.spreadsheet.theme.rowCell.cell; } @@ -881,4 +1008,36 @@ export class PivotFacet extends BaseFacet { }, }; } + + protected getRowHeader(): RowHeader { + if (!this.rowHeader) { + if (this.enableFrozenTotalRow()) { + const { viewportHeight, ...otherProps } = this.getRowHeaderCfg(); + return new FrozenRowHeader({ + ...otherProps, + viewportHeight: viewportHeight - this.getFrozenRowHeight(), + }); + } + } + return super.getRowHeader(); + } + + protected getSeriesNumberHeader(): BaseHeader { + if (this.enableFrozenTotalRow()) { + return FrozenSeriesNumber.getFrozenSeriesNumberHeader({ + viewportBBox: this.panelBBox, + seriesNumberWidth: this.getSeriesNumberWidth(), + leafNodes: this.layoutResult.rowsHierarchy.getNodes(0), + spreadsheet: this.spreadsheet, + cornerWidth: this.cornerBBox.width, + frozenRowCount: this.getBizRevisedFrozenOptions().frozenRowCount, + frozenRowHeight: this.getFrozenRowHeight(), + }); + } + return super.getSeriesNumberHeader(); + } + + public enableFrozenTotalRow(): boolean { + return !!this.getBizRevisedFrozenOptions().frozenRowCount; + } } diff --git a/packages/s2-core/src/facet/table-facet.ts b/packages/s2-core/src/facet/table-facet.ts index 23ab9e67e2..2adc48b44d 100644 --- a/packages/s2-core/src/facet/table-facet.ts +++ b/packages/s2-core/src/facet/table-facet.ts @@ -1,20 +1,8 @@ -import type { Group, IElement, IGroup } from '@antv/g-canvas'; -import { - get, - isBoolean, - isNil, - isNumber, - last, - maxBy, - set, - values, -} from 'lodash'; +import type { IElement, IGroup } from '@antv/g-canvas'; +import { get, isBoolean, isNumber, last, maxBy, set, values } from 'lodash'; import { TableDataCell } from '../cell'; import { - FRONT_GROUND_GROUP_COL_FROZEN_Z_INDEX, - FrozenGroup, KEY_GROUP_FROZEN_ROW_RESIZE_AREA, - KEY_GROUP_FROZEN_SPLIT_LINE, KEY_GROUP_ROW_RESIZE_AREA, LayoutWidthTypes, S2Event, @@ -26,8 +14,6 @@ import type { FilterParam, LayoutResult, ResizeInteractionOptions, - S2CellType, - SplitLine, SpreadSheetFacetCfg, TableSortParam, ViewMeta, @@ -36,16 +22,8 @@ import type { TableDataSet } from '../data-set'; import { getDataCellId } from '../utils/cell/data-cell'; import { getOccupiedWidthForTableCol } from '../utils/cell/table-col-cell'; import { getIndexRangeWithOffsets } from '../utils/facet'; -import { renderLine } from '../utils/g-renders'; import { getAllChildCells } from '../utils/get-all-child-cells'; -import { - getColsForGrid, - getFrozenRowsForGrid, - getRowsForGrid, -} from '../utils/grid'; -import type { Indexes, PanelIndexes } from '../utils/indexes'; import { getValidFrozenOptions } from '../utils/layout/frozen'; -import { BaseFacet } from './base-facet'; import { CornerBBox } from './bbox/cornerBBox'; import type { SeriesNumberHeader } from './header'; import type { ColHeader } from './header/col'; @@ -56,43 +34,80 @@ import { layoutCoordinate } from './layout/layout-hooks'; import { Node } from './layout/node'; import { calculateFrozenCornerCells, - calculateInViewIndexes, - getFrozenDataCellType, isFrozenTrailingRow, - splitInViewIndexesWithFrozen, - translateGroup, - translateGroupX, - translateGroupY, getFrozenLeafNodesCount, isTopLevelNode, } from './utils'; +import { FrozenFacet } from './frozen-facet'; -export class TableFacet extends BaseFacet { - public rowOffsets: number[]; +export class TableFacet extends FrozenFacet { + protected updateRowResizeArea(): void { + const { foregroundGroup, options } = this.spreadsheet; + const resize = get(options, 'interaction.resize'); - public frozenGroupInfo: Record< - FrozenGroup, - { - width?: number; - height?: number; - range?: number[]; + const shouldDrawResize = isBoolean(resize) + ? resize + : (resize as ResizeInteractionOptions)?.rowCellVertical; + if (!shouldDrawResize) { + return; + } + + const rowResizeGroup = foregroundGroup.findById(KEY_GROUP_ROW_RESIZE_AREA); + const rowResizeFrozenGroup = foregroundGroup.findById( + KEY_GROUP_FROZEN_ROW_RESIZE_AREA, + ); + if (rowResizeGroup) { + rowResizeGroup.set('children', []); + } + if (rowResizeFrozenGroup) { + rowResizeFrozenGroup.set('children', []); } - > = { - [FrozenGroup.FROZEN_COL]: { - width: 0, - }, - [FrozenGroup.FROZEN_ROW]: { - height: 0, - }, - [FrozenGroup.FROZEN_TRAILING_ROW]: { - height: 0, - }, - [FrozenGroup.FROZEN_TRAILING_COL]: { - width: 0, - }, - }; - public panelScrollGroupIndexes = []; + const allCells = getAllChildCells( + this.panelGroup.getChildren() as IElement[], + TableDataCell, + ).filter((cell: TableDataCell) => cell.shouldDrawResizeArea()); + + allCells?.forEach((cell) => { + cell.drawResizeArea(); + }); + } + + protected clip(scrollX: number, scrollY: number): void { + super.clip(scrollX, scrollY); + this.clipResizeAreaGroup(); + } + + protected clipResizeAreaGroup() { + const rowResizeGroup = this.spreadsheet.foregroundGroup.findById( + KEY_GROUP_ROW_RESIZE_AREA, + ); + if (rowResizeGroup) { + const colLeafNodes = this.layoutResult.colLeafNodes; + const { frozenRowGroup, frozenTrailingRowGroup } = this.spreadsheet; + let frozenRowGroupHeight = 0; + let frozenTrailingRowGroupHeight = 0; + if (frozenRowGroup) { + frozenRowGroupHeight = frozenRowGroup.getBBox().height; + } + if (frozenTrailingRowGroup) { + frozenTrailingRowGroupHeight = frozenTrailingRowGroup.getBBox().height; + } + const panelScrollGroupHeight = + this.panelBBox.height - + frozenRowGroupHeight - + frozenTrailingRowGroupHeight; + rowResizeGroup.setClip({ + type: 'rect', + attrs: { + x: 0, + y: frozenRowGroupHeight + this.cornerBBox.height, + width: colLeafNodes?.[0]?.width ?? 0, + height: panelScrollGroupHeight, + }, + }); + } + } public constructor(cfg: SpreadSheetFacetCfg) { super(cfg); @@ -472,12 +487,6 @@ export class TableFacet extends BaseFacet { return colWidth; } - protected getDefaultCellHeight() { - const { cellCfg } = this.cfg; - - return cellCfg?.height; - } - public getCellHeight(index: number) { if (this.rowOffsets) { const heightByField = get( @@ -494,32 +503,9 @@ export class TableFacet extends BaseFacet { return this.getDefaultCellHeight(); } - protected initRowOffsets() { - const { dataSet } = this.cfg; - const heightByField = get( - this.spreadsheet, - 'options.style.rowCfg.heightByField', - {}, - ); - if (Object.keys(heightByField).length) { - const data = dataSet.getDisplayDataSet(); - this.rowOffsets = [0]; - let lastOffset = 0; - data.forEach((_, idx) => { - const currentHeight = - heightByField?.[String(idx)] ?? this.getDefaultCellHeight(); - const currentOffset = lastOffset + currentHeight; - this.rowOffsets.push(currentOffset); - lastOffset = currentOffset; - }); - } - } - public getViewCellHeights() { const { dataSet } = this.cfg; - this.initRowOffsets(); - const defaultCellHeight = this.getDefaultCellHeight(); return { @@ -570,255 +556,7 @@ export class TableFacet extends BaseFacet { }; } - protected initFrozenGroupPosition = () => { - const { scrollY, scrollX } = this.getScrollOffset(); - const paginationScrollY = this.getPaginationScrollY(); - - translateGroup( - this.spreadsheet.frozenRowGroup, - this.cornerBBox.width - scrollX, - this.cornerBBox.height - paginationScrollY, - ); - translateGroup( - this.spreadsheet.frozenColGroup, - this.cornerBBox.width, - this.cornerBBox.height - scrollY - paginationScrollY, - ); - translateGroup( - this.spreadsheet.frozenTrailingColGroup, - this.cornerBBox.width, - this.cornerBBox.height - scrollY - paginationScrollY, - ); - translateGroup( - this.spreadsheet.frozenTopGroup, - this.cornerBBox.width, - this.cornerBBox.height - paginationScrollY, - ); - }; - - public getTotalHeightForRange = (start: number, end: number) => { - if (start < 0 || end < 0) { - return 0; - } - - if (this.rowOffsets) { - return this.rowOffsets[end + 1] - this.rowOffsets[start]; - } - - let totalHeight = 0; - for (let index = start; index < end + 1; index++) { - const height = this.getDefaultCellHeight(); - totalHeight += height; - } - return totalHeight; - }; - - private getShadowFill = (angle: number) => { - const style: SplitLine = get(this.cfg, 'spreadsheet.theme.splitLine'); - return `l (${angle}) 0:${style.shadowColors?.left} 1:${style.shadowColors?.right}`; - }; - - protected renderFrozenGroupSplitLine = (scrollX: number, scrollY: number) => { - const { - width: panelWidth, - height: panelHeight, - viewportWidth, - viewportHeight, - } = this.panelBBox; - const { height: cornerHeight } = this.cornerBBox; - const colLeafNodes = this.layoutResult.colNodes.filter((node) => { - return isTopLevelNode(node); - }); - const cellRange = this.getCellRange(); - const dataLength = cellRange.end - cellRange.start; - const { - frozenRowCount, - frozenColCount, - frozenTrailingColCount, - frozenTrailingRowCount, - } = getValidFrozenOptions( - this.spreadsheet.options, - colLeafNodes.length, - dataLength, - ); - - // 在分页条件下需要额外处理 Y 轴滚动值 - const relativeScrollY = Math.floor(scrollY - this.getPaginationScrollY()); - - // scroll boundary - const maxScrollX = Math.max(0, last(this.viewCellWidths) - viewportWidth); - const maxScrollY = Math.max( - 0, - this.viewCellHeights.getCellOffsetY(cellRange.end + 1) - - this.viewCellHeights.getCellOffsetY(cellRange.start) - - viewportHeight, - ); - - // remove previous splitline group - this.foregroundGroup.findById(KEY_GROUP_FROZEN_SPLIT_LINE)?.remove(); - - const style: SplitLine = get(this.cfg, 'spreadsheet.theme.splitLine'); - const splitLineGroup = this.foregroundGroup.addGroup({ - id: KEY_GROUP_FROZEN_SPLIT_LINE, - zIndex: FRONT_GROUND_GROUP_COL_FROZEN_Z_INDEX, - }); - - const verticalBorderStyle = { - lineWidth: style?.verticalBorderWidth, - stroke: style?.verticalBorderColor, - opacity: style?.verticalBorderColorOpacity, - lineDash: style?.borderDash, - }; - - const horizontalBorderStyle = { - lineWidth: style?.horizontalBorderWidth, - stroke: style?.horizontalBorderColor, - opacity: style?.horizontalBorderColorOpacity, - lineDash: style?.borderDash, - }; - - if (frozenColCount > 0) { - const x = colLeafNodes.reduce((prev, item, idx) => { - if (idx < frozenColCount) { - return prev + item.width; - } - return prev; - }, 0); - - const height = frozenTrailingRowCount > 0 ? panelHeight : viewportHeight; - - renderLine( - splitLineGroup as Group, - { - x1: x, - x2: x, - y1: cornerHeight, - y2: cornerHeight + height, - }, - { - ...verticalBorderStyle, - }, - ); - - if (style.showShadow && scrollX > 0) { - splitLineGroup.addShape('rect', { - attrs: { - x, - y: cornerHeight, - width: style.shadowWidth, - height, - fill: this.getShadowFill(0), - }, - }); - } - } - - if (frozenRowCount > 0) { - const y = - cornerHeight + - this.getTotalHeightForRange( - cellRange.start, - cellRange.start + frozenRowCount - 1, - ); - const width = frozenTrailingColCount > 0 ? panelWidth : viewportWidth; - renderLine( - splitLineGroup as Group, - { - x1: 0, - x2: width, - y1: y, - y2: y, - }, - { - ...horizontalBorderStyle, - }, - ); - - if (style.showShadow && relativeScrollY > 0) { - splitLineGroup.addShape('rect', { - attrs: { - x: 0, - y, - width, - height: style.shadowWidth, - fill: this.getShadowFill(90), - }, - }); - } - } - - if (frozenTrailingColCount > 0) { - // const width = colLeafNodes.reduceRight((prev, item, idx) => { - // if (idx >= colLeafNodes.length - frozenTrailingColCount) { - // return prev + item.width; - // } - // return prev; - // }, 0); - const { x } = colLeafNodes[colLeafNodes.length - frozenTrailingColCount]; - const height = frozenTrailingRowCount ? panelHeight : viewportHeight; - renderLine( - splitLineGroup as Group, - { - x1: x, - x2: x, - y1: cornerHeight, - y2: cornerHeight + height, - }, - { - ...verticalBorderStyle, - }, - ); - - if (style.showShadow && Math.floor(scrollX) < Math.floor(maxScrollX)) { - splitLineGroup.addShape('rect', { - attrs: { - x: x - style.shadowWidth, - y: cornerHeight, - width: style.shadowWidth, - height, - fill: this.getShadowFill(180), - }, - }); - } - } - - if (frozenTrailingRowCount > 0) { - const y = - this.panelBBox.maxY - - this.getTotalHeightForRange( - cellRange.end - frozenTrailingRowCount + 1, - cellRange.end, - ); - const width = frozenTrailingColCount > 0 ? panelWidth : viewportWidth; - renderLine( - splitLineGroup as Group, - { - x1: 0, - x2: width, - y1: y, - y2: y, - }, - { - ...horizontalBorderStyle, - }, - ); - - if (style.showShadow && relativeScrollY < Math.floor(maxScrollY)) { - splitLineGroup.addShape('rect', { - attrs: { - x: 0, - y: y - style.shadowWidth, - width, - height: style.shadowWidth, - fill: this.getShadowFill(270), - }, - }); - } - } - this.foregroundGroup.sort(); - }; - - protected renderFrozenPanelCornerGroup = () => { + private renderFrozenPanelCornerGroup = () => { const topLevelNodes = this.layoutResult.colNodes.filter((node) => { return isTopLevelNode(node); }); @@ -872,57 +610,6 @@ export class TableFacet extends BaseFacet { } }; - getRealFrozenColumns = ( - frozenColCount: number, - frozenTrailingColCount: number, - ) => { - let colCount = frozenColCount; - let trailingColCount = frozenTrailingColCount; - if (frozenColCount || frozenTrailingColCount) { - let nodes = this.layoutResult.colsHierarchy.getNodes(); - nodes = nodes.filter((node) => isTopLevelNode(node)); - ({ colCount, trailingColCount } = getFrozenLeafNodesCount( - nodes, - frozenColCount, - frozenTrailingColCount, - )); - } - return { colCount, trailingColCount }; - }; - - addCell = (cell: S2CellType) => { - const { - frozenRowCount, - frozenColCount, - frozenTrailingRowCount, - frozenTrailingColCount, - } = this.spreadsheet.options; - const colLength = this.layoutResult.colsHierarchy.getLeaves().length; - const cellRange = this.getCellRange(); - - const { colCount, trailingColCount } = this.getRealFrozenColumns( - frozenColCount, - frozenTrailingColCount, - ); - - const frozenCellType = getFrozenDataCellType( - cell.getMeta(), - { - frozenRowCount, - frozenColCount: colCount, - frozenTrailingRowCount, - frozenTrailingColCount: trailingColCount, - }, - colLength, - cellRange, - ); - - const group = FrozenCellGroupMap[frozenCellType]; - if (group) { - (this.spreadsheet[group] as Group).add(cell); - } - }; - public init() { super.init(); const { width, height } = this.panelBBox; @@ -957,108 +644,9 @@ export class TableFacet extends BaseFacet { return this.columnHeader; } - protected updateRowResizeArea() { - const { foregroundGroup, options } = this.spreadsheet; - const resize = get(options, 'interaction.resize'); - - const shouldDrawResize = isBoolean(resize) - ? resize - : (resize as ResizeInteractionOptions)?.rowCellVertical; - if (!shouldDrawResize) { - return; - } - - const rowResizeGroup = foregroundGroup.findById(KEY_GROUP_ROW_RESIZE_AREA); - const rowResizeFrozenGroup = foregroundGroup.findById( - KEY_GROUP_FROZEN_ROW_RESIZE_AREA, - ); - if (rowResizeGroup) { - rowResizeGroup.set('children', []); - } - if (rowResizeFrozenGroup) { - rowResizeFrozenGroup.set('children', []); - } - const allCells = getAllChildCells( - this.panelGroup.getChildren() as IElement[], - TableDataCell, - ).filter((cell: TableDataCell) => cell.shouldDrawResizeArea()); - - allCells.forEach((cell) => { - cell.drawResizeArea(); - }); - } - public render() { - this.calculateFrozenGroupInfo(); this.renderFrozenPanelCornerGroup(); super.render(); - this.initFrozenGroupPosition(); - } - - private getFrozenOptions = () => { - const colLength = this.layoutResult.colLeafNodes.length; - const cellRange = this.getCellRange(); - - return getValidFrozenOptions( - this.cfg, - colLength, - cellRange.end - cellRange.start + 1, - ); - }; - - public calculateFrozenGroupInfo() { - const { - frozenColCount, - frozenRowCount, - frozenTrailingColCount, - frozenTrailingRowCount, - } = this.getFrozenOptions(); - - const colLeafNodes = this.layoutResult.colNodes.filter((node) => { - return isTopLevelNode(node); - }); - const viewCellHeights = this.viewCellHeights; - const cellRange = this.getCellRange(); - const { frozenCol, frozenTrailingCol, frozenRow, frozenTrailingRow } = - this.frozenGroupInfo; - - if (frozenColCount > 0) { - frozenCol.width = - colLeafNodes[frozenColCount - 1].x + - colLeafNodes[frozenColCount - 1].width - - 0; - frozenCol.range = [0, frozenColCount - 1]; - } - - if (frozenRowCount > 0) { - frozenRow.height = - viewCellHeights.getCellOffsetY(cellRange.start + frozenRowCount) - - viewCellHeights.getCellOffsetY(cellRange.start); - frozenRow.range = [cellRange.start, cellRange.start + frozenRowCount - 1]; - } - - if (frozenTrailingColCount > 0) { - frozenTrailingCol.width = - colLeafNodes[colLeafNodes.length - 1].x - - colLeafNodes[colLeafNodes.length - frozenTrailingColCount].x + - colLeafNodes[colLeafNodes.length - 1].width; - frozenTrailingCol.range = [ - colLeafNodes.length - frozenTrailingColCount, - colLeafNodes.length - 1, - ]; - } - - if (frozenTrailingRowCount > 0) { - frozenTrailingRow.height = - viewCellHeights.getCellOffsetY(cellRange.end + 1) - - viewCellHeights.getCellOffsetY( - cellRange.end + 1 - frozenTrailingRowCount, - ); - frozenTrailingRow.range = [ - cellRange.end - frozenTrailingRowCount + 1, - cellRange.end, - ]; - } } protected getRowHeader() { @@ -1068,240 +656,4 @@ export class TableFacet extends BaseFacet { protected getSeriesNumberHeader(): SeriesNumberHeader { return null; } - - protected translateRelatedGroups( - scrollX: number, - scrollY: number, - hRowScroll: number, - ) { - const { - frozenColGroup, - frozenTrailingColGroup, - frozenRowGroup, - frozenTrailingRowGroup, - } = this.spreadsheet; - [frozenRowGroup, frozenTrailingRowGroup].forEach((g) => { - translateGroupX(g, this.cornerBBox.width - scrollX); - }); - - [frozenColGroup, frozenTrailingColGroup].forEach((g) => { - translateGroupY(g, this.cornerBBox.height - scrollY); - }); - - super.translateRelatedGroups(scrollX, scrollY, hRowScroll); - this.updateRowResizeArea(); - this.renderFrozenGroupSplitLine(scrollX, scrollY); - } - - public calculateXYIndexes(scrollX: number, scrollY: number): PanelIndexes { - const colLength = this.layoutResult.colLeafNodes.length; - const cellRange = this.getCellRange(); - - const { viewportHeight: height, viewportWidth: width } = this.panelBBox; - - const { - frozenColCount, - frozenRowCount, - frozenTrailingColCount, - frozenTrailingRowCount, - } = this.getFrozenOptions(); - - const finalViewport = { - width, - height, - x: 0, - y: 0, - }; - - if (frozenTrailingColCount > 0 || frozenColCount > 0) { - const { frozenTrailingCol, frozenCol } = this.frozenGroupInfo; - finalViewport.width -= frozenTrailingCol.width + frozenCol.width; - finalViewport.x += frozenCol.width; - } - - if (frozenTrailingRowCount > 0 || frozenRowCount > 0) { - const { frozenRow, frozenTrailingRow } = this.frozenGroupInfo; - // canvas 高度小于row height和trailingRow height的时候 height 为 0 - if (finalViewport.height < frozenRow.height + frozenTrailingRow.height) { - finalViewport.height = 0; - finalViewport.y = 0; - } else { - finalViewport.height -= frozenRow.height + frozenTrailingRow.height; - finalViewport.y += frozenRow.height; - } - } - - // https://github.com/antvis/S2/issues/2255 - const indexes = this.spreadsheet.dataSet.isEmpty() - ? ([] as unknown as Indexes) - : calculateInViewIndexes( - scrollX, - scrollY, - this.viewCellWidths, - this.viewCellHeights, - finalViewport, - this.getRealScrollX(this.cornerBBox.width), - ); - - this.panelScrollGroupIndexes = indexes; - - const { colCount, trailingColCount } = this.getRealFrozenColumns( - frozenColCount, - frozenTrailingColCount, - ); - - return splitInViewIndexesWithFrozen( - indexes, - { - frozenColCount: colCount, - frozenRowCount, - frozenTrailingColCount: trailingColCount, - frozenTrailingRowCount, - }, - colLength, - cellRange, - ); - } - - // 对 panelScrollGroup 以及四个方向的 frozenGroup 做 Clip,避免有透明度时冻结分组和滚动分组展示重叠 - protected clip(scrollX: number, scrollY: number) { - const colLeafNodes = this.layoutResult.colLeafNodes; - const paginationScrollY = this.getPaginationScrollY(); - const { - frozenRowGroup, - frozenColGroup, - frozenTrailingColGroup, - frozenTrailingRowGroup, - panelScrollGroup, - } = this.spreadsheet; - const frozenColGroupWidth = frozenColGroup.getBBox().width; - const frozenRowGroupHeight = frozenRowGroup.getBBox().height; - const frozenTrailingColBBox = frozenTrailingColGroup.getBBox(); - const frozenTrailingRowGroupHeight = - frozenTrailingRowGroup.getBBox().height; - const panelScrollGroupWidth = - this.panelBBox.width - - frozenColGroupWidth - - frozenTrailingColGroup.getBBox().width; - const panelScrollGroupHeight = - this.panelBBox.height - - frozenRowGroupHeight - - frozenTrailingRowGroupHeight; - - panelScrollGroup.setClip({ - type: 'rect', - attrs: { - x: scrollX + frozenColGroupWidth, - y: scrollY + frozenRowGroupHeight, - width: panelScrollGroupWidth, - height: panelScrollGroupHeight, - }, - }); - - frozenRowGroup.setClip({ - type: 'rect', - attrs: { - x: scrollX + frozenColGroupWidth, - y: paginationScrollY, - width: panelScrollGroupWidth, - height: frozenRowGroupHeight, - }, - }); - - frozenTrailingRowGroup.setClip({ - type: 'rect', - attrs: { - x: scrollX + frozenColGroupWidth, - y: frozenTrailingRowGroup.getBBox().minY, - width: panelScrollGroupWidth, - height: frozenTrailingRowGroupHeight, - }, - }); - - const colClipArea = { - y: scrollY + frozenRowGroupHeight, - height: panelScrollGroupHeight, - }; - - frozenColGroup.setClip({ - type: 'rect', - attrs: { - ...colClipArea, - x: 0, - width: frozenColGroupWidth, - }, - }); - - frozenTrailingColGroup.setClip({ - type: 'rect', - attrs: { - ...colClipArea, - x: frozenTrailingColBBox.minX, - width: frozenTrailingColBBox.width, - }, - }); - - const rowResizeGroup = this.spreadsheet.foregroundGroup.findById( - KEY_GROUP_ROW_RESIZE_AREA, - ); - - if (rowResizeGroup) { - rowResizeGroup.setClip({ - type: 'rect', - attrs: { - x: 0, - y: frozenRowGroupHeight + this.cornerBBox.height, - width: colLeafNodes?.[0]?.width ?? 0, - height: panelScrollGroupHeight, - }, - }); - } - } - - public updatePanelScrollGroup() { - super.updatePanelScrollGroup(); - [ - FrozenGroup.FROZEN_COL, - FrozenGroup.FROZEN_ROW, - FrozenGroup.FROZEN_TRAILING_COL, - FrozenGroup.FROZEN_TRAILING_ROW, - ].forEach((key) => { - if (!this.frozenGroupInfo[key].range) { - return; - } - - let cols = []; - let rows = []; - - if (key.toLowerCase().includes('row')) { - const [rowMin, rowMax] = this.frozenGroupInfo[key].range; - cols = this.gridInfo.cols; - rows = getRowsForGrid(rowMin, rowMax, this.viewCellHeights); - if (key === FrozenGroup.FROZEN_TRAILING_ROW) { - const { minY } = this.spreadsheet.frozenTrailingRowGroup.getBBox(); - rows = getFrozenRowsForGrid( - rowMin, - rowMax, - Math.ceil(minY), - this.viewCellHeights, - ); - } - } else { - const [colMin, colMax] = this.frozenGroupInfo[key].range; - const nodes = this.layoutResult.colNodes.filter((node) => - isTopLevelNode(node), - ); - cols = getColsForGrid(colMin, colMax, nodes); - rows = this.gridInfo.rows; - } - - this.spreadsheet[`${key}Group`].updateGrid( - { - cols, - rows, - }, - `${key}Group`, - ); - }); - } } diff --git a/packages/s2-core/src/facet/utils.ts b/packages/s2-core/src/facet/utils.ts index 5e8713520a..520957141e 100644 --- a/packages/s2-core/src/facet/utils.ts +++ b/packages/s2-core/src/facet/utils.ts @@ -7,7 +7,9 @@ import type { ColumnNode, Columns, Pagination, + S2TableSheetOptions, ScrollSpeedRatio, + SpreadSheetFacetCfg, } from '../common/interface'; import type { Fields } from '../common/interface'; import type { Indexes } from '../utils/indexes'; @@ -532,3 +534,52 @@ export const areAllFieldsEmpty = (fields: Fields) => { isEmpty(fields.customTreeItems) ); }; + +/** + * get frozen options pivot-sheet (business limit) + * @param options + * @returns + */ +export const getFrozenOptionsPivot = ( + options: Pick< + SpreadSheetFacetCfg, + | 'hierarchyType' + | 'totals' + | 'pagination' + | 'frozenEntireHeadRowPivot' + | 'showSeriesNumber' + | 'valueInCols' + >, +): S2TableSheetOptions => { + const { + totals, + valueInCols, + pagination, + frozenEntireHeadRowPivot, + hierarchyType, + showSeriesNumber, + } = options; + let frozenRowCount = 0; + const { showGrandTotals, reverseLayout } = totals?.row || {}; + const grandTotalInHeadRow = showGrandTotals && reverseLayout; + const enablePagination = pagination && pagination.pageSize; + if (enablePagination || !frozenEntireHeadRowPivot) { + frozenRowCount = 0; + } else if (hierarchyType === 'grid') { + if (grandTotalInHeadRow && valueInCols) { + frozenRowCount = 1; + } + } else if (hierarchyType === 'tree') { + frozenRowCount = 1; + if (showSeriesNumber && !grandTotalInHeadRow) { + frozenRowCount = 0; + } + } + + return { + frozenRowCount, + frozenColCount: 0, + frozenTrailingColCount: 0, + frozenTrailingRowCount: 0, + }; +}; diff --git a/packages/s2-core/src/sheet-type/pivot-sheet.ts b/packages/s2-core/src/sheet-type/pivot-sheet.ts index 8ce4509308..a8bea5c20a 100644 --- a/packages/s2-core/src/sheet-type/pivot-sheet.ts +++ b/packages/s2-core/src/sheet-type/pivot-sheet.ts @@ -1,9 +1,11 @@ import type { Event as CanvasEvent } from '@antv/g-canvas'; import { clone, last } from 'lodash'; -import { DataCell } from '../cell'; +import { BaseCell, DataCell, SeriesNumberCell } from '../cell'; import { EXTRA_FIELD, InterceptType, + KEY_GROUP_PANEL_FROZEN_ROW, + PANEL_GROUP_FROZEN_GROUP_Z_INDEX, S2Event, getTooltipOperatorSortMenus, } from '../common/constant'; @@ -20,6 +22,7 @@ import { PivotDataSet } from '../data-set'; import { CustomTreePivotDataSet } from '../data-set/custom-tree-pivot-data-set'; import { PivotFacet } from '../facet'; import type { Node } from '../facet/layout/node'; +import { FrozenGroup } from '../group/frozen-group'; import { SpreadSheet } from './spread-sheet'; export class PivotSheet extends SpreadSheet { @@ -227,4 +230,21 @@ export class PivotSheet extends SpreadSheet { forceRender: true, }); } + + protected initPanelGroupChildren(): void { + super.initPanelGroupChildren(); + const commonParams = { + zIndex: PANEL_GROUP_FROZEN_GROUP_Z_INDEX, + s2: this, + }; + this.frozenRowGroup = new FrozenGroup({ + KEY_GROUP_PANEL_FROZEN_ROW, + ...commonParams, + }); + this.panelGroup.add(this.frozenRowGroup); + } + + protected isCellType(cell?: any): boolean { + return cell instanceof BaseCell && !(cell instanceof SeriesNumberCell); + } } diff --git a/packages/s2-core/src/sheet-type/spread-sheet.ts b/packages/s2-core/src/sheet-type/spread-sheet.ts index f3d48faad9..d8a870f05c 100644 --- a/packages/s2-core/src/sheet-type/spread-sheet.ts +++ b/packages/s2-core/src/sheet-type/spread-sheet.ts @@ -575,6 +575,10 @@ export abstract class SpreadSheet extends EE { return this.options?.mappingDisplayDataItem; } + protected isCellType(cell?: any) { + return cell instanceof BaseCell; + } + // 获取当前cell实例 public getCell( target: CanvasEvent['target'], @@ -582,7 +586,7 @@ export abstract class SpreadSheet extends EE { let parent = target; // 一直索引到g顶层的canvas来检查是否在指定的cell中 while (parent && !(parent instanceof Canvas)) { - if (parent instanceof BaseCell) { + if (this.isCellType(parent)) { // 在单元格中,返回true return parent as T; } diff --git a/packages/s2-core/src/utils/indexes.ts b/packages/s2-core/src/utils/indexes.ts index 5dfdbfa7e7..122f83ce57 100644 --- a/packages/s2-core/src/utils/indexes.ts +++ b/packages/s2-core/src/utils/indexes.ts @@ -1,6 +1,6 @@ import { isEmpty } from 'lodash'; -export type Indexes = [number, number, number, number]; +export type Indexes = [number?, number?, number?, number?]; export type PanelIndexes = { center: Indexes; diff --git a/s2-site/docs/api/general/S2Options.zh.md b/s2-site/docs/api/general/S2Options.zh.md index 412d0e0c8a..9762c153a1 100644 --- a/s2-site/docs/api/general/S2Options.zh.md +++ b/s2-site/docs/api/general/S2Options.zh.md @@ -54,6 +54,7 @@ const s2Options = { | dataSet | [DataSet](#dataset) | | | 自定义数据集 | | supportCSSTransform | `boolean` | | `false` | 开启后支持 CSS transform, 解决父元素设置 `transform` 后,鼠标坐标响应不正确的问题 | | devicePixelRatio | `number` | | `window.devicePixelRatio` | 自定义设备像素比 | +| frozenEntireHeadRowPivot | `boolean` | | `false` | 交叉表模式冻结首行(整行) | diff --git a/s2-site/docs/manual/advanced/frozen-row-pivot.en.md b/s2-site/docs/manual/advanced/frozen-row-pivot.en.md new file mode 100644 index 0000000000..6ac41d2ade --- /dev/null +++ b/s2-site/docs/manual/advanced/frozen-row-pivot.en.md @@ -0,0 +1,31 @@ +--- +title: 交叉模式冻结首行 +order: 10 +--- + +Currently, only the ability to freeze the first row as a whole is provided, mainly for the following scenarios: + +- Scenario 1: Tree mode with the first row frozen. Enabling row numbering without enabling row totals is not supported. +- Scenario 2: Tile mode with values in the column header, enabling row totals and top positioning. + +For both of these scenarios, pagination is not supported. The freezing of the first row can be controlled by passing these properties in the s2Options: + +```ts +const s2Options = { + frozenEntireHeadRowPivot: boolean; + totals: { + row: { + showGrandTotals: true, + reverseLayout: true, + }, + }, +} +``` + +picture & demo: + +preview +preview + + + diff --git a/s2-site/docs/manual/advanced/frozen-row-pivot.zh.md b/s2-site/docs/manual/advanced/frozen-row-pivot.zh.md new file mode 100644 index 0000000000..f71bf8d6fd --- /dev/null +++ b/s2-site/docs/manual/advanced/frozen-row-pivot.zh.md @@ -0,0 +1,32 @@ +--- +title: 交叉模式冻结首行 +order: 10 +--- + +目前仅提供**首行整行**冻结能力,主要包括以下场景: + +- 场景一:树状模式,首行整行冻结。开启行序号未开启行总计场景不支持; +- 场景二:平铺模式,数值置于列头,开启行总计&位置置顶 + +以上两种场景,开启分页场景下暂不支持。首行整行冻结通过在 `s2Options` 中传入这些属性控制: + +```ts +const s2Options = { + // 是否开启冻结首行整行 + frozenEntireHeadRowPivot: boolean; + // 平铺模式,需要开启行总计&位置置顶 + totals: { + row: { + showGrandTotals: true, + reverseLayout: true, + }, + }, +} +``` + +效果如图: +preview +preview + + + diff --git a/s2-site/examples/interaction/advanced/demo/frozen-pivot-grid.ts b/s2-site/examples/interaction/advanced/demo/frozen-pivot-grid.ts new file mode 100644 index 0000000000..46d31e372b --- /dev/null +++ b/s2-site/examples/interaction/advanced/demo/frozen-pivot-grid.ts @@ -0,0 +1,24 @@ +import { PivotSheet } from '@antv/s2'; + +fetch( + 'https://gw.alipayobjects.com/os/bmw-prod/2a5dbbc8-d0a7-4d02-b7c9-34f6ca63cff6.json', +) + .then((res) => res.json()) + .then((dataCfg) => { + const container = document.getElementById('container'); + + const s2Options = { + width: 600, + height: 300, + frozenEntireHeadRowPivot: true, + totals: { + row: { + showGrandTotals: true, + reverseLayout: true, + }, + }, + }; + const s2 = new PivotSheet(container, dataCfg, s2Options); + + s2.render(); + }); diff --git a/s2-site/examples/interaction/advanced/demo/frozen-pivot-tree.ts b/s2-site/examples/interaction/advanced/demo/frozen-pivot-tree.ts new file mode 100644 index 0000000000..ecfb40a893 --- /dev/null +++ b/s2-site/examples/interaction/advanced/demo/frozen-pivot-tree.ts @@ -0,0 +1,19 @@ +import { PivotSheet } from '@antv/s2'; + +fetch( + 'https://gw.alipayobjects.com/os/bmw-prod/2a5dbbc8-d0a7-4d02-b7c9-34f6ca63cff6.json', +) + .then((res) => res.json()) + .then((dataCfg) => { + const container = document.getElementById('container'); + + const s2Options = { + width: 600, + height: 300, + hierarchyType: 'tree', + frozenEntireHeadRowPivot: true, + }; + const s2 = new PivotSheet(container, dataCfg, s2Options); + + s2.render(); + }); diff --git a/s2-site/examples/interaction/advanced/demo/meta.json b/s2-site/examples/interaction/advanced/demo/meta.json index d68eaec408..39576b6ffb 100644 --- a/s2-site/examples/interaction/advanced/demo/meta.json +++ b/s2-site/examples/interaction/advanced/demo/meta.json @@ -107,6 +107,22 @@ "en": "Reaching the boundary of a scrolling area" }, "screenshot": "https://gw.alipayobjects.com/zos/antfincdn/JRAt1kb93/Kapture%2525202022-06-06%252520at%25252011.28.43.gif" + }, + { + "filename": "frozen-pivot-grid.ts", + "title": { + "zh": "交叉平铺模式冻结总计行", + "en": "pivot mode freezes total rows" + }, + "screenshot": "https://gw.alipayobjects.com/zos/antfincdn/JRAt1kb93/Kapture%2525202022-06-06%252520at%25252011.28.43.gif" + }, + { + "filename": "frozen-pivot-tree.ts", + "title": { + "zh": "交叉树状模式冻结首行", + "en": "tree mode freezes head rows" + }, + "screenshot": "https://gw.alipayobjects.com/zos/antfincdn/JRAt1kb93/Kapture%2525202022-06-06%252520at%25252011.28.43.gif" } ] }