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 5381ce1e05..79e5f18202 100644 --- a/packages/s2-core/__tests__/unit/facet/pivot-facet-spec.ts +++ b/packages/s2-core/__tests__/unit/facet/pivot-facet-spec.ts @@ -73,6 +73,7 @@ jest.mock('@/sheet-type', () => { emit: jest.fn(), isHierarchyTreeType: jest.fn(), isHierarchyGridTreeType: jest.fn(), + isHierarchyGridTreeColType: jest.fn().mockReturnValue(false), facet: { getFreezeCornerDiffWidth: jest.fn(), getColLeafNodes: jest.fn().mockReturnValue([]), diff --git a/packages/s2-core/__tests__/util/helpers.ts b/packages/s2-core/__tests__/util/helpers.ts index 8736224029..45cd6b2268 100644 --- a/packages/s2-core/__tests__/util/helpers.ts +++ b/packages/s2-core/__tests__/util/helpers.ts @@ -222,6 +222,7 @@ export const createFakeSpreadSheet = (config?: { s2.getCell = jest.fn(); s2.isHierarchyTreeType = jest.fn(); s2.isHierarchyGridTreeType = jest.fn(); + s2.isHierarchyGridTreeColType = jest.fn().mockReturnValue(false); s2.getCanvasElement = () => s2.container.getContextService().getDomElement() as any; s2.getCanvasConfig = () => s2.container.getConfig(); diff --git a/packages/s2-core/src/cell/col-cell.ts b/packages/s2-core/src/cell/col-cell.ts index d25515c684..5c15ef1408 100644 --- a/packages/s2-core/src/cell/col-cell.ts +++ b/packages/s2-core/src/cell/col-cell.ts @@ -26,12 +26,13 @@ import type { FrozenFacet } from '../facet'; import { Frame } from '../facet/header/frame'; import { type ColHeaderConfig } from '../facet/header/interface'; import { + getCellBoxByType, getHorizontalTextIconPosition, getVerticalIconPosition, getVerticalTextPosition, } from '../utils/cell/cell'; import { adjustTextIconPositionWhileScrolling } from '../utils/cell/text-scrolling'; -import { renderIcon, renderLine } from '../utils/g-renders'; +import { renderIcon, renderLine, renderTreeIcon } from '../utils/g-renders'; import { batchSetStyle } from '../utils/g-utils'; import { getHiddenColumnContinuousSiblingNodes, @@ -43,6 +44,7 @@ import { getResizeAreaAttrs, shouldAddResizeArea, } from '../utils/interaction/resize'; +import { isMobile } from '../utils/is-mobile'; import { normalizeTextAlign } from '../utils/normalize'; import { HeaderCell } from './header-cell'; @@ -59,6 +61,51 @@ export class ColCell extends HeaderCell { return [CellBorderPosition.TOP, CellBorderPosition.RIGHT]; } + /** + * column grid-tree 模式下,折叠的节点需要跨越子维度行形成合并单元格 + * 通过重写 getBBoxByType 实现视觉上的跨行效果,不影响全局布局 + */ + public getBBoxByType(type = CellClipBox.BORDER_BOX): SimpleBBox { + // 只在 column grid-tree 模式下的折叠节点才需要跨行 + if ( + !this.spreadsheet.isHierarchyGridTreeColType() || + !this.meta.isCollapsed + ) { + return super.getBBoxByType(type); + } + + // 获取列头层级信息,计算需要跨越的高度 + const { hierarchy } = this.meta; + const sampleNodes = hierarchy?.sampleNodesForAllLevels || []; + let spanHeight = 0; + + // 从当前层级到最大层级的所有行高之和 + for (let i = this.meta.level; i <= (hierarchy?.maxLevel ?? 0); i++) { + const levelSample = sampleNodes[i]; + + spanHeight += levelSample?.height ?? 0; + } + + // 构造扩展后的 BORDER_BOX(保持 x, y 不变,只改变 height) + const expandedBorderBox: SimpleBBox = { + x: this.meta.x, + y: this.meta.y, + width: this.meta.width, + height: spanHeight || this.meta.height, + }; + + // 使用 getCellBoxByType 处理 padding/border,确保 CONTENT_BOX 正确计算 + const cellStyle = (this.getStyle() || + this.theme.dataCell) as DefaultCellTheme; + + return getCellBoxByType( + expandedBorderBox, + this.getBorderPositions(), + cellStyle?.cell!, + type, + ); + } + protected initCell() { super.initCell(); // 1、draw rect background @@ -74,6 +121,8 @@ export class ColCell extends HeaderCell { protected afterDrawText() { // 绘制字段标记 -- icon this.drawActionAndConditionIcons(); + // 绘制树状模式收起展开的 icon (grid-tree 模式) + this.drawTreeIcon(); // draw borders this.drawBorders(); // draw resize ares @@ -117,6 +166,79 @@ export class ColCell extends HeaderCell { return false; } + /** + * 是否显示列头树状模式的展开/折叠图标 (grid-tree 模式) + */ + protected showTreeIcon() { + // 只有 column grid-tree 模式需要显示展开/折叠图标 + if (!this.spreadsheet.isHierarchyGridTreeColType()) { + return false; + } + + // 已折叠的节点需要显示展开图标 + if (this.meta.isCollapsed) { + return true; + } + + // 未折叠的非叶子节点显示折叠图标 + return !this.meta.isLeaf; + } + + private onTreeIconClick() { + const { device } = this.spreadsheet.options; + + if (isMobile(device)) { + return; + } + + this.emitCollapseEvent(); + } + + private emitCollapseEvent() { + this.spreadsheet.emit(S2Event.COL_CELL_COLLAPSED__PRIVATE, { + isCollapsed: !this.meta.isCollapsed, + node: this.meta, + }); + } + + protected drawTreeIcon() { + if (!this.showTreeIcon()) { + return; + } + + const { isCollapsed } = this.meta; + const { x } = this.getBBoxByType(CellClipBox.CONTENT_BOX); + const { fill } = this.getTextStyle(); + const { size } = this.getStyle()!.icon!; + + const iconX = x; + const iconY = this.getIconPosition().y; + + this.treeIcon = renderTreeIcon({ + group: this, + iconCfg: { + x: iconX, + y: iconY, + width: size, + height: size, + fill, + }, + isCollapsed, + onClick: () => { + this.onTreeIconClick(); + }, + }); + + // 移动端, 点击热区为整个单元格 + const { device } = this.spreadsheet.options; + + if (isMobile(device)) { + this.addMobileTouchListener(() => { + this.emitCollapseEvent(); + }); + } + } + /** * 计算文本位置时候需要,留给后代根据情况(固定列)覆盖 * @param viewport diff --git a/packages/s2-core/src/common/constant/events/basic.ts b/packages/s2-core/src/common/constant/events/basic.ts index 0e21dbd13e..d4802f12e9 100644 --- a/packages/s2-core/src/common/constant/events/basic.ts +++ b/packages/s2-core/src/common/constant/events/basic.ts @@ -34,6 +34,12 @@ export enum S2Event { COL_CELL_HIDDEN = 'col-cell:hidden', COL_CELL_RENDER = 'col-cell:render', COL_CELL_SELECTED = 'col-cell:selected', + COL_CELL_COLLAPSED = 'col-cell:collapsed', + COL_CELL_ALL_COLLAPSED = 'col-cell:all-collapsed', + + // 列头内部通信 event + COL_CELL_COLLAPSED__PRIVATE = 'col-cell:collapsed__private', + COL_CELL_ALL_COLLAPSED__PRIVATE = 'col-cell:all-collapsed__private', /** ================ Data Cell ================ */ DATA_CELL_HOVER = 'data-cell:hover', diff --git a/packages/s2-core/src/common/interface/collapse.ts b/packages/s2-core/src/common/interface/collapse.ts index 5d6861a1ac..38dac2b2b4 100644 --- a/packages/s2-core/src/common/interface/collapse.ts +++ b/packages/s2-core/src/common/interface/collapse.ts @@ -1,8 +1,14 @@ import type { Node } from '../../facet/layout/node'; -import type { RowCellStyle } from './style'; +import type { ColCellStyle, RowCellStyle } from './style'; export type RowCellCollapsedParams = { isCollapsed: boolean; node: Node; collapseFields?: RowCellStyle['collapseFields']; }; + +export type ColCellCollapsedParams = { + isCollapsed: boolean; + node: Node; + collapseFields?: ColCellStyle['collapseFields']; +}; diff --git a/packages/s2-core/src/common/interface/emitter.ts b/packages/s2-core/src/common/interface/emitter.ts index d4c34f0a28..ee0c03bd6f 100644 --- a/packages/s2-core/src/common/interface/emitter.ts +++ b/packages/s2-core/src/common/interface/emitter.ts @@ -11,6 +11,7 @@ import type { InteractionName, S2Event } from '../../common/constant'; import type { CellMeta, CellScrollPosition, + ColCellCollapsedParams, Data, FilterParam, HiddenColumnsInfo, @@ -154,6 +155,10 @@ export interface EmitterType { ) => void; [S2Event.COL_CELL_RENDER]: (cell: ColCell) => void; [S2Event.COL_CELL_SELECTED]: CellSelectedHandler; + [S2Event.COL_CELL_COLLAPSED]: (data: ColCellCollapsedParams) => void; + [S2Event.COL_CELL_COLLAPSED__PRIVATE]: (data: ColCellCollapsedParams) => void; + [S2Event.COL_CELL_ALL_COLLAPSED]: (isCollapsed: boolean) => void; + [S2Event.COL_CELL_ALL_COLLAPSED__PRIVATE]: (isCollapsed: boolean) => void; /** ================ Corner Cell ================ */ [S2Event.CORNER_CELL_MOUSE_MOVE]: CanvasEventHandler; diff --git a/packages/s2-core/src/common/interface/s2Options.ts b/packages/s2-core/src/common/interface/s2Options.ts index e3a269469e..de1a962cb2 100644 --- a/packages/s2-core/src/common/interface/s2Options.ts +++ b/packages/s2-core/src/common/interface/s2Options.ts @@ -361,6 +361,14 @@ export interface S2PivotSheetOptions { */ hierarchyType?: HierarchyType; + /** + * 列头布局类型 + * - grid: 平铺网格 (默认) + * - grid-tree: 树状平铺(平铺布局 + 展开折叠) + * @description 列头仅支持 grid 和 grid-tree 模式 + */ + columnHierarchyType?: 'grid' | 'grid-tree'; + /** * 小计/总计配置 * @see https://s2.antv.antgroup.com/manual/basic/totals diff --git a/packages/s2-core/src/common/interface/style.ts b/packages/s2-core/src/common/interface/style.ts index 262abccd94..c7b3312c36 100644 --- a/packages/s2-core/src/common/interface/style.ts +++ b/packages/s2-core/src/common/interface/style.ts @@ -116,6 +116,26 @@ export interface ColCellStyle extends BaseCellStyle, CellTextWordWrapStyle { * 数值挂列头时, 是否隐藏数值 (即 s2DataConfig.fields.values 只有一个数值时生效) */ hideValue?: boolean; + + /** + * 收起所有列头节点 (grid-tree 模式生效) + * @description 优先级 `collapseFields` > `expandDepth` > `collapseAll` + */ + collapseAll?: boolean | null; + + /** + * 折叠列头节点 (grid-tree 模式生效) + * id 级别: { ['root[&]家具']: true } 即 家具 对应的节点会被折叠 + * field 级别: { type: true } : 即 所有 type 对应的维值都会被折叠 + * @description 优先级 `collapseFields` > `expandDepth` > `collapseAll` + */ + collapseFields?: Record | null; + + /** + * 列头节点默认展开到第几层 (从 0 开始, grid-tree 模式生效) + * @description 优先级 `collapseFields` > `expandDepth` > `collapseAll` + */ + expandDepth?: number | null; } export interface CornerCellStyle extends CellTextWordWrapStyle {} diff --git a/packages/s2-core/src/data-set/interface.ts b/packages/s2-core/src/data-set/interface.ts index 30c1719c9f..21fc10dda1 100644 --- a/packages/s2-core/src/data-set/interface.ts +++ b/packages/s2-core/src/data-set/interface.ts @@ -62,6 +62,11 @@ export interface GetCellDataParams { */ rowNode?: Node; + /** + * 列头节点,用于 column grid-tree 模式 + */ + colNode?: Node; + /** * 是否是行头 */ diff --git a/packages/s2-core/src/data-set/pivot-data-set.ts b/packages/s2-core/src/data-set/pivot-data-set.ts index 1b0eb53832..0f4892f3bf 100644 --- a/packages/s2-core/src/data-set/pivot-data-set.ts +++ b/packages/s2-core/src/data-set/pivot-data-set.ts @@ -441,7 +441,13 @@ export class PivotDataSet extends BaseDataSet { } public getCellData(params: GetCellDataParams): ViewMetaData | undefined { - const { query = {}, rowNode, isTotals = false, totalStatus } = params || {}; + const { + query = {}, + rowNode, + colNode, + isTotals = false, + totalStatus, + } = params || {}; const { rows: originRows, columns } = this.fields; let rows = originRows; @@ -490,9 +496,15 @@ export class PivotDataSet extends BaseDataSet { // grid-tree 模式下折叠的节点,使用小计聚合逻辑计算数据 // 折叠节点没有原始数据,需要聚合其子节点的数据 - const isGridTree = this.spreadsheet?.isHierarchyGridTreeType(); + const isRowGridTree = this.spreadsheet?.isHierarchyGridTreeType(); + const isColGridTree = this.spreadsheet?.isHierarchyGridTreeColType(); + + if (isRowGridTree && rowNode?.isCollapsed) { + return this.getTotalValue(query, totalStatus); + } - if (isGridTree && rowNode?.isCollapsed) { + // column grid-tree 模式下折叠的列节点,使用小计聚合逻辑 + if (isColGridTree && colNode?.isCollapsed) { return this.getTotalValue(query, totalStatus); } } diff --git a/packages/s2-core/src/facet/layout/build-col-grid-tree-hierarchy.ts b/packages/s2-core/src/facet/layout/build-col-grid-tree-hierarchy.ts new file mode 100644 index 0000000000..742dc16038 --- /dev/null +++ b/packages/s2-core/src/facet/layout/build-col-grid-tree-hierarchy.ts @@ -0,0 +1,377 @@ +import { isEmpty, isNumber } from 'lodash'; +import { EMPTY_FIELD_VALUE, EXTRA_FIELD } from '../../common/constant'; +import { i18n } from '../../common/i18n'; +import { filterOutDetail } from '../../utils/data-set-operate'; +import { addTotals } from '../../utils/layout/add-totals'; +import { generateId, resolveNillString } from '../../utils/layout/generate-id'; +import { getDimsCondition } from '../../utils/layout/get-dims-condition-by-node'; +import { whetherLeafByLevel } from '../../utils/layout/whether-leaf-by-level'; +import type { + FieldValue, + GridHeaderParams, + HeaderNodesParams, +} from '../layout/interface'; +import { layoutArrange, layoutHierarchy } from '../layout/layout-hooks'; +import { Node } from '../layout/node'; +import { TotalClass } from '../layout/total-class'; +import { TotalMeasure } from '../layout/total-measure'; + +/** + * 计算列头节点是否折叠 + * 注意:真正的叶子节点(没有子节点)不应该有折叠状态 + */ +const calculateColCollapsedState = (options: { + spreadsheet: HeaderNodesParams['spreadsheet']; + nodeId: string; + currentField: string; + level: number; + isTotals: boolean; + isTotalMeasure: boolean; + isLeaf: boolean; +}): boolean => { + const { + spreadsheet, + nodeId, + currentField, + level, + isTotals, + isTotalMeasure, + isLeaf, + } = options; + + // 真正的叶子节点没有子节点,不应有折叠状态 + if (isLeaf) { + return false; + } + + // 使用 colCell 的折叠配置 + const { collapseFields, collapseAll, expandDepth } = + spreadsheet.options.style?.colCell ?? {}; + + const isDefaultCollapsed = + collapseFields?.[nodeId] ?? collapseFields?.[currentField]; + const isLevelCollapsed = isNumber(expandDepth) ? level >= expandDepth : null; + + return isTotals || isTotalMeasure + ? false + : isDefaultCollapsed ?? isLevelCollapsed ?? collapseAll ?? false; +}; + +/** + * 处理字段值,返回节点的值、查询条件和各种标志 + */ +const processColFieldValue = (options: { + fieldValue: FieldValue; + currentField: string; + query: Record; + parentNode: HeaderNodesParams['parentNode']; + spreadsheet: HeaderNodesParams['spreadsheet']; + level: number; + fields: string[]; + addMeasureInTotalQuery: boolean | undefined; +}) => { + const { + fieldValue, + currentField, + query, + parentNode, + spreadsheet, + level, + fields, + addMeasureInTotalQuery, + } = options; + + const isTotals = TotalClass.isTotalClassInstance(fieldValue); + const isTotalMeasure = TotalMeasure.isTotalMeasureInstance(fieldValue); + + let value: string; + let nodeQuery: Record; + let isLeaf = false; + let isGrandTotals = false; + let isSubTotals = false; + let isTotalRoot = false; + + if (isTotals) { + isGrandTotals = fieldValue.isGrandTotals; + isSubTotals = fieldValue.isSubTotals; + isTotalRoot = fieldValue.isTotalRoot; + value = i18n(fieldValue.label); + nodeQuery = isTotalRoot + ? { ...query } + : { ...query, [currentField]: value }; + if (addMeasureInTotalQuery) { + nodeQuery[EXTRA_FIELD] = spreadsheet?.dataSet?.fields.values![0]; + } + + isLeaf = whetherLeafByLevel({ spreadsheet, level, fields }); + } else if (isTotalMeasure) { + value = i18n(fieldValue.label); + nodeQuery = { ...query, [EXTRA_FIELD]: value }; + isGrandTotals = parentNode.isGrandTotals!; + isSubTotals = parentNode.isSubTotals!; + isLeaf = whetherLeafByLevel({ spreadsheet, level, fields }); + } else { + value = fieldValue; + nodeQuery = + value === EMPTY_FIELD_VALUE + ? { ...query } + : { ...query, [currentField]: value }; + isLeaf = whetherLeafByLevel({ spreadsheet, level, fields }); + } + + return { + value, + nodeQuery, + isLeaf, + isGrandTotals, + isSubTotals, + isTotalRoot, + isTotals, + isTotalMeasure, + }; +}; + +/** + * 生成 grid-tree 模式的列头节点 + */ +const generateGridTreeColHeaderNodes = (params: HeaderNodesParams) => { + const { + currentField, + fields, + fieldValues, + hierarchy, + parentNode, + level, + query, + addMeasureInTotalQuery, + addTotalMeasureInTotal, + spreadsheet, + handler, + } = params; + + for (const originalFieldValue of fieldValues) { + const fieldValue = resolveNillString( + originalFieldValue as string, + ) as FieldValue; + const adjustedField = currentField; + + const processed = processColFieldValue({ + fieldValue, + currentField, + query, + parentNode, + spreadsheet, + level, + fields, + addMeasureInTotalQuery, + }); + + const nodeId = generateId(parentNode.id, processed.value); + + if (nodeId) { + const isCollapsed = calculateColCollapsedState({ + spreadsheet, + nodeId, + currentField, + level, + isTotals: processed.isTotals, + isTotalMeasure: processed.isTotalMeasure, + isLeaf: processed.isLeaf, + }); + + const node = new Node({ + id: nodeId, + value: processed.value, + level, + field: adjustedField, + parent: parentNode, + isTotals: processed.isTotals || processed.isTotalMeasure, + isGrandTotals: processed.isGrandTotals, + isSubTotals: processed.isSubTotals, + isTotalMeasure: processed.isTotalMeasure, + isCollapsed, + isTotalRoot: processed.isTotalRoot, + hierarchy, + query: processed.nodeQuery, + spreadsheet, + isLeaf: processed.isLeaf || isCollapsed, + }); + + const expandCurrentNode = layoutHierarchy( + spreadsheet, + parentNode, + node, + hierarchy, + ); + const hiddenColumnsInfo = spreadsheet?.facet?.getHiddenColumnsInfo(node); + + if ( + level > hierarchy.maxLevel && + !processed.isGrandTotals && + !parentNode.isGrandTotals && + !parentNode.isSubTotals && + !node.isSubTotals && + !hiddenColumnsInfo + ) { + hierarchy.sampleNodesForAllLevels.push(node); + hierarchy.maxLevel = level; + hierarchy.sampleNodeForLastLevel = node; + } + + // grid-tree 模式下,折叠的节点也是叶子节点 + const isLeafNode = processed.isLeaf || isCollapsed || !expandCurrentNode; + + if (isLeafNode) { + node.isLeaf = true; + hierarchy.pushIndexNode(node); + node.colIndex = hierarchy.getIndexNodes().length - 1; + } else { + handler?.({ + addTotalMeasureInTotal, + addMeasureInTotalQuery, + parentNode: node, + currentField: fields[level + 1], + fields, + hierarchy, + spreadsheet, + } as HeaderNodesParams); + } + } + } +}; + +const buildNormalColGridTreeHierarchy = (params: GridHeaderParams) => { + const { parentNode, currentField, fields, spreadsheet } = params; + const dataSet = spreadsheet.dataSet; + const { values = [] } = dataSet.fields; + const index = fields.indexOf(currentField); + const fieldValues: FieldValue[] = []; + + let query: Record = {}; + + query = getDimsCondition(parentNode, true); + const dimValues = dataSet.getDimensionValues(currentField, query); + + const arrangedValues = layoutArrange( + spreadsheet, + dimValues as FieldValue[], + parentNode, + currentField, + ); + + fieldValues.push(...((arrangedValues as FieldValue[]) || [])); + + if (isEmpty(fieldValues) && currentField) { + if (currentField === EXTRA_FIELD) { + fieldValues.push(...values); + } else { + fieldValues.push(EMPTY_FIELD_VALUE); + } + } + + addTotals({ + currentField, + lastField: fields[index - 1], + isFirstField: index === 0, + fieldValues, + spreadsheet, + }); + + const displayFieldValues = filterOutDetail(fieldValues as string[]); + + generateGridTreeColHeaderNodes({ + ...params, + fieldValues: displayFieldValues, + level: index, + parentNode, + query, + // eslint-disable-next-line @typescript-eslint/no-use-before-define + handler: buildColGridTreeHierarchy, + }); +}; + +const buildTotalColGridTreeHierarchy = (params: GridHeaderParams) => { + const { + addTotalMeasureInTotal, + parentNode, + currentField, + fields, + hierarchy, + spreadsheet, + } = params; + + const index = fields.indexOf(currentField); + const dataSet = spreadsheet.dataSet; + const { values = [] } = dataSet.fields; + const fieldValues: FieldValue[] = []; + + let query: Record = {}; + const totalsConfig = spreadsheet.getTotalsConfig(currentField); + const defaultDimensionGroup = parentNode.isGrandTotals + ? totalsConfig.grandTotalsGroupDimensions || [] + : totalsConfig.subTotalsGroupDimensions || []; + const dimensionGroup = !dataSet.isEmpty() ? defaultDimensionGroup : []; + + if (dimensionGroup?.includes(currentField)) { + query = getDimsCondition(parentNode); + const dimValues = dataSet.getDimensionValues(currentField, query); + + fieldValues.push( + ...(dimValues || []).map( + (value) => + new TotalClass({ + label: value as string, + isSubTotals: parentNode.isSubTotals!, + isGrandTotals: parentNode.isGrandTotals!, + isTotalRoot: false, + }), + ), + ); + if (isEmpty(fieldValues) && currentField) { + fieldValues.push(EMPTY_FIELD_VALUE); + } + } else if (addTotalMeasureInTotal && currentField === EXTRA_FIELD) { + query = getDimsCondition(parentNode); + fieldValues.push(...values.map((v) => new TotalMeasure(v))); + } else if (whetherLeafByLevel({ spreadsheet, level: index, fields })) { + parentNode.isLeaf = true; + hierarchy.pushIndexNode(parentNode); + parentNode.colIndex = hierarchy.getIndexNodes().length - 1; + + return; + } else { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + buildTotalColGridTreeHierarchy({ + ...params, + currentField: fields[index + 1], + }); + + return; + } + + const displayFieldValues = filterOutDetail(fieldValues as string[]); + + generateGridTreeColHeaderNodes({ + ...params, + fieldValues: displayFieldValues, + level: index, + parentNode, + query, + // eslint-disable-next-line @typescript-eslint/no-use-before-define + handler: buildColGridTreeHierarchy, + }); +}; + +/** + * 构建 grid-tree 模式的列头层级结构 + * grid-tree 模式特点: + * 1. 每个维度占据独立的行(与 grid 模式相同) + * 2. 支持展开/折叠子节点(与 tree 模式相同) + */ +export function buildColGridTreeHierarchy(params: GridHeaderParams) { + if (params.parentNode.isTotals) { + buildTotalColGridTreeHierarchy(params); + } else { + buildNormalColGridTreeHierarchy(params); + } +} diff --git a/packages/s2-core/src/facet/layout/build-header-hierarchy.ts b/packages/s2-core/src/facet/layout/build-header-hierarchy.ts index 0def51c3b5..151c6733fa 100644 --- a/packages/s2-core/src/facet/layout/build-header-hierarchy.ts +++ b/packages/s2-core/src/facet/layout/build-header-hierarchy.ts @@ -10,6 +10,7 @@ import type { BuildHeaderResult, HeaderParams, } from '../layout/interface'; +import { buildColGridTreeHierarchy } from './build-col-grid-tree-hierarchy'; import { buildGridHierarchy } from './build-gird-hierarchy'; import { buildGridTreeHierarchy } from './build-grid-tree-hierarchy'; import { buildCustomTreeHierarchy } from './build-row-custom-tree-hierarchy'; @@ -168,12 +169,48 @@ const handleTableHierarchy = (params: HeaderParams) => { buildTableHierarchy(params); }; +const handleGridTreeColHierarchy = (params: HeaderParams) => { + const { + isValueInCols, + moreThanOneValue, + rootNode, + hierarchy, + fields, + isCustomTreeFields, + spreadsheet, + } = params; + + // grid-tree 模式使用专门的列头层级构建器 + const addTotalMeasureInTotal = isValueInCols && moreThanOneValue; + const addMeasureInTotalQuery = isValueInCols && !moreThanOneValue; + + if (isCustomTreeFields) { + handleCustomTreeHierarchy(params); + } else { + buildColGridTreeHierarchy({ + spreadsheet, + addTotalMeasureInTotal, + addMeasureInTotalQuery, + parentNode: rootNode, + currentField: (fields as string[])[0], + fields: fields as string[], + hierarchy, + }); + } +}; + const handleColHeaderHierarchy = (params: HeaderParams) => { const { spreadsheet } = params; const isPivotMode = spreadsheet.isPivotMode(); if (isPivotMode) { - handleGridRowColHierarchy(params); + if (spreadsheet.isHierarchyGridTreeColType()) { + // column grid-tree 模式:平铺布局 + 展开折叠 + handleGridTreeColHierarchy(params); + } else { + // grid 模式:纯平铺布局 + handleGridRowColHierarchy(params); + } } else { handleTableHierarchy(params); } diff --git a/packages/s2-core/src/facet/pivot-facet.ts b/packages/s2-core/src/facet/pivot-facet.ts index ebb5b22308..da43d2a9c8 100644 --- a/packages/s2-core/src/facet/pivot-facet.ts +++ b/packages/s2-core/src/facet/pivot-facet.ts @@ -163,6 +163,7 @@ export class PivotFacet extends FrozenFacet { const data = (dataSet as PivotDataSet).getCellData({ query: dataQuery, rowNode: row, + colNode: col, isTotals, totalStatus, }); diff --git a/packages/s2-core/src/sheet-type/pivot-sheet.ts b/packages/s2-core/src/sheet-type/pivot-sheet.ts index 46ad79535b..fa26e8f23a 100644 --- a/packages/s2-core/src/sheet-type/pivot-sheet.ts +++ b/packages/s2-core/src/sheet-type/pivot-sheet.ts @@ -2,6 +2,8 @@ import { clone, isString, last } from 'lodash'; import { DataCell } from '../cell'; import { EXTRA_FIELD, S2Event } from '../common/constant'; import type { + ColCellCollapsedParams, + ColCellStyle, RowCellCollapsedParams, RowCellStyle, SortMethod, @@ -57,6 +59,10 @@ export class PivotSheet extends SpreadSheet { return this.options.hierarchyType === 'grid-tree'; } + public isHierarchyGridTreeColType(): boolean { + return this.options.columnHierarchyType === 'grid-tree'; + } + /** * Scroll Freeze Row Header */ @@ -95,11 +101,18 @@ export class PivotSheet extends SpreadSheet { protected bindEvents() { this.off(S2Event.ROW_CELL_COLLAPSED__PRIVATE); this.off(S2Event.ROW_CELL_ALL_COLLAPSED__PRIVATE); + this.off(S2Event.COL_CELL_COLLAPSED__PRIVATE); + this.off(S2Event.COL_CELL_ALL_COLLAPSED__PRIVATE); this.on(S2Event.ROW_CELL_COLLAPSED__PRIVATE, this.handleRowCellCollapsed); this.on( S2Event.ROW_CELL_ALL_COLLAPSED__PRIVATE, this.handleRowCellToggleCollapseAll, ); + this.on(S2Event.COL_CELL_COLLAPSED__PRIVATE, this.handleColCellCollapsed); + this.on( + S2Event.COL_CELL_ALL_COLLAPSED__PRIVATE, + this.handleColCellToggleCollapseAll, + ); } protected async handleRowCellCollapsed(data: RowCellCollapsedParams) { @@ -145,6 +158,49 @@ export class PivotSheet extends SpreadSheet { this.emit(S2Event.ROW_CELL_ALL_COLLAPSED, collapseAll); } + protected async handleColCellCollapsed(data: ColCellCollapsedParams) { + const { isCollapsed, node } = data; + const { collapseFields: defaultCollapsedFields } = + this.options.style?.colCell ?? {}; + + const collapseFields: ColCellStyle['collapseFields'] = { + ...defaultCollapsedFields, + [node.id]: isCollapsed, + }; + + this.setOptions({ + style: { + colCell: { + collapseFields, + }, + }, + }); + + await this.render(false); + this.emit(S2Event.COL_CELL_COLLAPSED, { + isCollapsed, + collapseFields, + node, + }); + } + + protected async handleColCellToggleCollapseAll(isCollapsed: boolean) { + const collapseAll = !isCollapsed; + + this.setOptions({ + style: { + colCell: { + collapseAll, + collapseFields: null, + expandDepth: null, + }, + }, + }); + + await this.render(false); + this.emit(S2Event.COL_CELL_ALL_COLLAPSED, collapseAll); + } + public async groupSortByMethod(sortMethod: SortMethod, meta: Node) { const { rows, columns } = this.dataCfg.fields; const { hideValue } = this.options.style!.colCell!; diff --git a/packages/s2-core/src/sheet-type/spread-sheet.ts b/packages/s2-core/src/sheet-type/spread-sheet.ts index 080b09f80b..e2baf4b8e6 100644 --- a/packages/s2-core/src/sheet-type/spread-sheet.ts +++ b/packages/s2-core/src/sheet-type/spread-sheet.ts @@ -117,6 +117,8 @@ export abstract class SpreadSheet extends EE { public abstract isHierarchyGridTreeType(): boolean; + public abstract isHierarchyGridTreeColType(): boolean; + public abstract isFrozenRowHeader(): boolean; public abstract isValueInCols(): boolean; diff --git a/packages/s2-core/src/sheet-type/table-sheet.ts b/packages/s2-core/src/sheet-type/table-sheet.ts index 38080d930f..86cccdac01 100644 --- a/packages/s2-core/src/sheet-type/table-sheet.ts +++ b/packages/s2-core/src/sheet-type/table-sheet.ts @@ -47,6 +47,10 @@ export class TableSheet extends SpreadSheet { return false; } + public isHierarchyGridTreeColType(): boolean { + return false; + } + /** * Scroll Freeze Row Header */ diff --git a/s2-site/common/style.en.md b/s2-site/common/style.en.md index 068aed4234..293f1a38f7 100644 --- a/s2-site/common/style.en.md +++ b/s2-site/common/style.en.md @@ -35,6 +35,9 @@ object is **required** , *default: null* Function description: Column header cel | widthByField | Set the width according to the measurement value (drag or preset width scene), `field` corresponds to the `field` or column header id in `s2DataConfig.fields.columns` , [see details](/manual/advanced/custom/cell-size#%E8%B0%83%E6%95%B4%E8%A1%8C%E5%A4%B4%E5%8D%95%E5%85%83%E6%A0%BC%E5%AE%BD%E9%AB%98) | `Record` | - | | | heightByField | Set the height according to the measurement value (drag or preset height scene), the `field` corresponds to the `field` or column header id in `s2DataConfig.fields.columns` , [see details](/manual/advanced/custom/cell-size#%E8%B0%83%E6%95%B4%E8%A1%8C%E5%A4%B4%E5%8D%95%E5%85%83%E6%A0%BC%E5%AE%BD%E9%AB%98) | `Record` | - | | | hideValue | The default value hangs the column header, which will display the column header and the value at the same time, and hide the value to make it more beautiful. (that is, `s2DataConfig.fields.values` and it is only valid for a single value, and it is recommended to use [hidden column headers](https://s2.antv.vision/manual/advanced/interaction/hide-columns#2-%E9%80%8F%E8%A7%86%E8%A1%A8) for multiple values) | `boolean` | false | | +| collapseFields | Custom folded nodes for column headers in grid-tree mode.
Supports two formats: id ( `'root[&] Furniture'` ) and dimension field ( `'type'` ). The priority is higher than `collapseAll` and `expandDepth` , and the priority is the lowest when it is set to `null`. | `Record` | | | +| collapseAll | Whether to collapse all column headers by default in grid-tree mode. | `boolean` | `false` | | +| expandDepth | In the grid-tree mode, the column header expands the expanded level by default (the level starts from 0), and when it is set to `null` , the priority is the lowest | `number` | | | ### RowCell diff --git a/s2-site/common/style.zh.md b/s2-site/common/style.zh.md index e136a502de..838a723f8f 100644 --- a/s2-site/common/style.zh.md +++ b/s2-site/common/style.zh.md @@ -40,6 +40,9 @@ order: 3 | widthByField | 根据度量值设置宽度(拖拽或者预设宽度场景), `field` 对应 `s2DataConfig.fields.columns` 中的 `field` 或 列头 id (优先级大于 `width`) [查看详情](/manual/advanced/custom/cell-size#%E8%B0%83%E6%95%B4%E8%A1%8C%E5%A4%B4%E5%8D%95%E5%85%83%E6%A0%BC%E5%AE%BD%E9%AB%98) | `Record` | - | | | heightByField | 根据度量值设置高度(拖拽或者预设高度场景), `field` 对应 `s2DataConfig.fields.columns` 中的 `field` 或 列头 id (优先级大于 `height`) [查看详情](/manual/advanced/custom/cell-size#%E8%B0%83%E6%95%B4%E8%A1%8C%E5%A4%B4%E5%8D%95%E5%85%83%E6%A0%BC%E5%AE%BD%E9%AB%98) | `Record` | - | | | hideValue | 默认数值挂列头,会同时显示列头和数值,隐藏数值,使其更美观。(即 `s2DataConfig.fields.values` 且仅在单数值时有效,多数值时推荐使用 [隐藏列头](https://s2.antv.vision/manual/advanced/interaction/hide-columns#2-%E9%80%8F%E8%A7%86%E8%A1%A8)) | `boolean` | false | | +| collapseFields | grid-tree 模式下列头自定义折叠节点。
支持 id (`'root[&] 家具'`) 和 维度 field (`'type'`) 两种格式,优先级大于 `collapseAll` 和 `expandDepth`, 设置为 `null` 时优先级最低。 | `Record` | | | +| collapseAll | 在 grid-tree 模式下列头是否默认收起全部。 | `boolean` | `false` | | +| expandDepth | 在 grid-tree 模式下列头默认展开的层级(层级从 0 开始), 设置为 `null` 时优先级最低 | `number` | | | ### RowCell diff --git a/s2-site/docs/api/general/s2-options.zh.md b/s2-site/docs/api/general/s2-options.zh.md index afde2aff8e..ddeb1cfa08 100644 --- a/s2-site/docs/api/general/s2-options.zh.md +++ b/s2-site/docs/api/general/s2-options.zh.md @@ -20,6 +20,7 @@ const s2Options = { | height | `number` | | `480` | 表格高度 | | debug | `boolean` | | `false` | 是否开启调试模式 | | hierarchyType | `"grid" \| "tree" \| "grid-tree"` | | `grid` | 行头的展示方式,grid:平铺网格结构,tree:树状结构,grid-tree:树状平铺(平铺布局 + 展开折叠)。 支持 [自定义结构](/manual/advanced/custom/custom-header) | +| columnHierarchyType | `"grid" \| "grid-tree"` | | `grid` | 列头的展示方式,grid:平铺网格结构,grid-tree:树状平铺(平铺布局 + 展开折叠)。 | | conditions | [Conditions](#conditions) | | | 字段标记,条件格式配置 | | totals | [Totals](#totals) | | | 小计总计配置 | | tooltip | [Tooltip](#tooltip) | | | tooltip 配置 | diff --git a/s2-site/docs/manual/basic/sheet-type/pivot-mode.en.md b/s2-site/docs/manual/basic/sheet-type/pivot-mode.en.md index c6a8771b9c..2e60d1fe8d 100644 --- a/s2-site/docs/manual/basic/sheet-type/pivot-mode.en.md +++ b/s2-site/docs/manual/basic/sheet-type/pivot-mode.en.md @@ -206,6 +206,22 @@ const s2Options = { +#### Column Grid Tree Mode + +Similar to the grid tree mode of row headers, each dimension level of the column header has an independent row while supporting expansion and collapse. + +```ts +const s2Options = { + columnHierarchyType: 'grid-tree', + style: { + colCell: { + // Default expand level (starts from 0) + expandDepth: 0, + }, + }, +} +``` + ### Data Summarization It supports pivot capabilities for [subtotals and grand totals](/en/manual/basic/totals). diff --git a/s2-site/docs/manual/basic/sheet-type/pivot-mode.zh.md b/s2-site/docs/manual/basic/sheet-type/pivot-mode.zh.md index 02624468ae..582c1fcfd0 100644 --- a/s2-site/docs/manual/basic/sheet-type/pivot-mode.zh.md +++ b/s2-site/docs/manual/basic/sheet-type/pivot-mode.zh.md @@ -207,6 +207,22 @@ const s2Options = { +#### 列头树状平铺模式 (Column Grid Tree) + +类似行头的树状平铺模式,在列头上每个维度层级有独立的行,同时支持展开折叠。 + +```ts +const s2Options = { + columnHierarchyType: 'grid-tree', + style: { + colCell: { + // 默认展开层级 (从 0 开始) + expandDepth: 0, + }, + }, +} +``` + ### 数据汇总 支持 [小计/总计](/manual/basic/totals) 的透视能力。 diff --git a/s2-site/examples/basic/pivot/demo/col-grid-tree.ts b/s2-site/examples/basic/pivot/demo/col-grid-tree.ts new file mode 100644 index 0000000000..968d46497e --- /dev/null +++ b/s2-site/examples/basic/pivot/demo/col-grid-tree.ts @@ -0,0 +1,45 @@ +import { PivotSheet, S2Options, setLang } from '@antv/s2'; + +fetch( + 'https://assets.antv.antgroup.com/s2/expanded-en-data-config.json', +) + .then((res) => res.json()) + .then(async (dataCfg) => { + const container = document.getElementById('container'); + + const s2Options: S2Options = { + width: 1200, + height: 800, + // column grid-tree mode: tile layout + expand/collapse for columns + columnHierarchyType: 'grid-tree', + style: { + colCell: { + // Default expand depth (starts from 0) + expandDepth: 0, + }, + }, + // Configure col totals + totals: { + col: { + showGrandTotals: true, + showSubTotals: true, + subTotalsDimensions: ['type'], + grandTotalsLabel: 'Total', + subTotalsLabel: 'SubTotal', + // Auto calculate + calcSubTotals: { + aggregation: 'SUM', + }, + calcGrandTotals: { + aggregation: 'SUM', + }, + }, + }, + }; + + setLang('en_US'); + + const s2 = new PivotSheet(container!, dataCfg, s2Options); + + await s2.render(); + }); diff --git a/s2-site/examples/basic/pivot/demo/meta.json b/s2-site/examples/basic/pivot/demo/meta.json index b6c6137d34..9b9a4b13a1 100644 --- a/s2-site/examples/basic/pivot/demo/meta.json +++ b/s2-site/examples/basic/pivot/demo/meta.json @@ -28,6 +28,14 @@ }, "new": true, "screenshot": "https://mdn.alipayobjects.com/huamei_qa8qxu/afts/img/A*TveZTrlRjlYAAAAAQiAAAAgAemJ7AQ/fmt.avif" + }, + { + "filename": "col-grid-tree.ts", + "title": { + "zh": "列头树状平铺模式", + "en": "Column Grid Tree mode" + }, + "new": true } ] }