피벗 크로스탭 (Pivot Table)

행 축, 컬럼 축과 집계 값을 AXDGPivotOptions로 구성해 교차 집계 결과를 표시하는 방법을 학습합니다.

#pivot#rows#columns#values#aggregate
검토일: 2026-08-17
GitHub
import * as React from 'react';
import { Card, Select, Space } from 'antd';
import {
  AXDataGrid,
  AXDGColumn,
  AXDGPivotAggregate,
  AXDGPivotField,
  AXDGPivotValueItemRenderProps,
} from '@axboot/datagrid';
import { useContainerSize } from '../hooks/useContainerSize';
import DataGridContainer from '../components/DataGridContainer';

type FieldKey = 'region' | 'channel' | 'product' | 'quarter' | 'month' | 'sales' | 'quantity';

interface SalesItem {
  region: string;
  channel: string;
  product: string;
  quarter: string;
  month: string;
  sales: number;
  quantity: number;
}

const fieldMap: Record<FieldKey, AXDGPivotField> = {
  region: { key: 'region', label: 'Region', width: 120 },
  channel: { key: 'channel', label: 'Channel', width: 120 },
  product: { key: 'product', label: 'Product', width: 140 },
  quarter: { key: 'quarter', label: 'Quarter', width: 110 },
  month: { key: 'month', label: 'Month', width: 110 },
  sales: { key: 'sales', label: 'Sales', width: 120, align: 'right' },
  quantity: { key: 'quantity', label: 'Quantity', width: 120, align: 'right' },
};

const dimensionOptions: { label: string; value: FieldKey }[] = [
  { label: 'Region', value: 'region' },
  { label: 'Channel', value: 'channel' },
  { label: 'Product', value: 'product' },
  { label: 'Quarter', value: 'quarter' },
  { label: 'Month', value: 'month' },
];

const valueOptions: { label: string; value: FieldKey }[] = [
  { label: 'Sales', value: 'sales' },
  { label: 'Quantity', value: 'quantity' },
];

const aggregateOptions: { label: string; value: AXDGPivotAggregate<SalesItem> }[] = [
  { label: 'Sum', value: 'sum' },
  { label: 'Count', value: 'count' },
  { label: 'Average', value: 'avg' },
  { label: 'Min', value: 'min' },
  { label: 'Max', value: 'max' },
];

const data = createSalesData();

const columns: AXDGColumn<SalesItem>[] = [
  { key: 'region', label: 'Region', width: 120 },
  { key: 'channel', label: 'Channel', width: 120 },
  { key: 'product', label: 'Product', width: 140 },
  { key: 'quarter', label: 'Quarter', width: 110 },
  { key: 'month', label: 'Month', width: 110 },
  { key: 'sales', label: 'Sales', width: 120, align: 'right' },
  { key: 'quantity', label: 'Quantity', width: 120, align: 'right' },
];

function PivotExample() {
  const [rowKeys, setRowKeys] = React.useState<FieldKey[]>(['region', 'product']);
  const [columnKeys, setColumnKeys] = React.useState<FieldKey[]>(['quarter']);
  const [valueKeys, setValueKeys] = React.useState<FieldKey[]>(['sales']);
  const [aggregate, setAggregate] = React.useState<AXDGPivotAggregate<SalesItem>>('sum');
  const containerRef = React.useRef<HTMLDivElement>(null);
  const { width: containerWidth, height: containerHeight } = useContainerSize(containerRef);

  const pivot = React.useMemo(
    () => ({
      rows: rowKeys.map(key => fieldMap[key]),
      columns: columnKeys.map(key => fieldMap[key]),
      values: valueKeys.map(key => ({
        ...fieldMap[key],
        label: `${getAggregateLabel(aggregate)} ${fieldMap[key].label}`,
        aggregate,
        itemRender: (params: AXDGPivotValueItemRenderProps<SalesItem>) => renderPivotValue(key, params),
        getClipboardText: ({ value }: { value: any }) => formatPivotValue(key, value),
      })),
      emptyValue: 0,
    }),
    [aggregate, columnKeys, rowKeys, valueKeys],
  );

  return (
    <>
      <Card size={'small'} style={{ marginBottom: 12 }}>
        <Space wrap>
          <FieldSelect label={'Rows'} value={rowKeys} options={dimensionOptions} onChange={setRowKeys} />
          <FieldSelect label={'Columns'} value={columnKeys} options={dimensionOptions} onChange={setColumnKeys} />
          <FieldSelect label={'Values'} value={valueKeys} options={valueOptions} onChange={setValueKeys} />
          <ControlLabel label={'Aggregate'}>
            <Select style={{ width: 130 }} value={aggregate} options={aggregateOptions} onChange={setAggregate} />
          </ControlLabel>
        </Space>
      </Card>

      <DataGridContainer
        ref={containerRef}
        style={{ height: 560 }}
      >
        <AXDataGrid<SalesItem>
          width={containerWidth}
          height={containerHeight}
          headerHeight={50}
          data={data}
          columns={columns}
          pivot={pivot}
          frozenColumnIndex={2}
          showLineNumber
          columnSortable
          sort={{
            sortParams: [{ key: 'region', orderBy: 'asc' }],
            onChange: params => console.log('sort disabled while pivoting', params),
          }}
          variant={'vertical-bordered'}
        />
      </DataGridContainer>
    </>
  );
}

function FieldSelect({
  label,
  value,
  options,
  onChange,
}: {
  label: string;
  value: FieldKey[];
  options: { label: string; value: FieldKey }[];
  onChange: (value: FieldKey[]) => void;
}) {
  return (
    <ControlLabel label={label}>
      <Select mode={'multiple'} style={{ minWidth: 220 }} value={value} options={options} onChange={onChange} />
    </ControlLabel>
  );
}

function ControlLabel({ label, children }: { label: string; children: React.ReactNode }) {
  return (
    <Space size={6}>
      <span className={'text-[12px] text-gray-500'}>{label}</span>
      {children}
    </Space>
  );
}

function getAggregateLabel(aggregate: AXDGPivotAggregate<SalesItem>) {
  if (aggregate === 'avg') return 'Avg';
  if (typeof aggregate === 'string') return aggregate[0].toUpperCase() + aggregate.slice(1);
  return 'Custom';
}

function formatPivotValue(key: FieldKey, value: any) {
  const numberValue = typeof value === 'number' ? value : Number(value);
  if (!Number.isFinite(numberValue)) return value ?? '';
  if (key === 'sales') return `$${numberValue.toLocaleString()}`;
  if (key === 'quantity') return `${numberValue.toLocaleString()} ea`;
  return numberValue.toLocaleString();
}

function renderPivotValue(
  key: FieldKey,
  { value, columnValues, sourceItems }: AXDGPivotValueItemRenderProps<SalesItem>,
) {
  const text = formatPivotValue(key, value);
  const title = `${sourceItems.length} source rows`;

  if (key === 'sales' && columnValues[0] === 'West') {
    return <strong title={title}>{text}</strong>;
  }

  return <span title={title}>{text}</span>;
}

function createSalesData() {
  const regions = ['North', 'South', 'East', 'West'];
  const channels = ['Online', 'Retail'];
  const products = ['Desk', 'Chair', 'Lamp'];
  const quarters = ['Q1', 'Q2', 'Q3', 'Q4'];

  return regions.flatMap((region, regionIndex) =>
    channels.flatMap((channel, channelIndex) =>
      products.flatMap((product, productIndex) =>
        quarters.flatMap((quarter, quarterIndex) =>
          Array.from({ length: 3 }, (_, monthIndex) => {
            const base = (regionIndex + 1) * 100 + (productIndex + 1) * 30 + (quarterIndex + 1) * 15;
            return {
              values: {
                region,
                channel,
                product,
                quarter,
                month: `${quarter}-M${monthIndex + 1}`,
                sales: base + channelIndex * 17 + monthIndex * 6,
                quantity: (productIndex + 1) * 3 + quarterIndex + monthIndex + channelIndex,
              },
            };
          }),
        ),
      ),
    ),
  );
}

export default PivotExample;

Pivot 구성

피벗은 일반 columns를 중첩해서 만드는 기능이 아닙니다. 원본 그리드의 columnsdata를 전달한 뒤, 별도의 pivot 객체에 행 축·컬럼 축·집계 값을 정의합니다.

const pivot: AXDGPivotOptions<SalesRow> = {
  rows: [
    { key: 'region', label: 'Region', width: 120 },
    { key: 'product', label: 'Product', width: 140 },
  ],
  columns: [
    { key: 'quarter', label: 'Quarter', width: 110 },
  ],
  values: [
    {
      key: 'sales',
      label: 'Sales',
      width: 120,
      align: 'right',
      aggregate: 'sum',
    },
  ],
  emptyValue: 0,
};

<AXDataGrid<SalesRow> columns={columns} data={data} pivot={pivot} {...sizeProps} />

aggregate'sum' | 'count' | 'avg' | 'min' | 'max' | 'first' 중 하나이거나 사용자 정의 함수입니다. 사용자 함수는 대상 값, 원본 AXDGDataItem, 현재 행·컬럼 축 값과 AXDGPivotValue를 받습니다.

렌더링과 복사

각 value에는 itemRendergetClipboardText를 지정할 수 있습니다. 두 콜백 모두 일반 셀 정보뿐 아니라 sourceItems, rowValues, columnValues, pivotValue, aggregate 컨텍스트를 제공합니다. 화면의 통화 포맷과 클립보드 문자열을 같은 규칙으로 맞추려면 두 콜백을 함께 구성하세요.

피벗이 활성화되면 표시 컬럼과 행은 축 조합으로 다시 만들어집니다. 행 선택, 정렬, Frozen 범위를 함께 사용할 경우 위 라이브 데모처럼 실제 조합을 브라우저에서 검증하는 것이 안전합니다.