그리드? 일단 추가랑 복사기능 되게 했음
This commit is contained in:
620
frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts
Normal file
620
frontend/lib/registry/components/pivot-grid/utils/pivotEngine.ts
Normal file
@@ -0,0 +1,620 @@
|
||||
/**
|
||||
* PivotGrid 데이터 처리 엔진
|
||||
* 원시 데이터를 피벗 구조로 변환합니다.
|
||||
*/
|
||||
|
||||
import {
|
||||
PivotFieldConfig,
|
||||
PivotResult,
|
||||
PivotHeaderNode,
|
||||
PivotFlatRow,
|
||||
PivotFlatColumn,
|
||||
PivotCellValue,
|
||||
DateGroupInterval,
|
||||
AggregationType,
|
||||
} from "../types";
|
||||
import { aggregate, formatNumber, formatDate } from "./aggregation";
|
||||
|
||||
// ==================== 헬퍼 함수 ====================
|
||||
|
||||
/**
|
||||
* 필드 값 추출 (날짜 그룹핑 포함)
|
||||
*/
|
||||
function getFieldValue(
|
||||
row: Record<string, any>,
|
||||
field: PivotFieldConfig
|
||||
): string {
|
||||
const rawValue = row[field.field];
|
||||
|
||||
if (rawValue === null || rawValue === undefined) {
|
||||
return "(빈 값)";
|
||||
}
|
||||
|
||||
// 날짜 그룹핑 처리
|
||||
if (field.groupInterval && field.dataType === "date") {
|
||||
const date = new Date(rawValue);
|
||||
if (isNaN(date.getTime())) return String(rawValue);
|
||||
|
||||
switch (field.groupInterval) {
|
||||
case "year":
|
||||
return String(date.getFullYear());
|
||||
case "quarter":
|
||||
return `Q${Math.ceil((date.getMonth() + 1) / 3)}`;
|
||||
case "month":
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
||||
case "week":
|
||||
const weekNum = getWeekNumber(date);
|
||||
return `${date.getFullYear()}-W${String(weekNum).padStart(2, "0")}`;
|
||||
case "day":
|
||||
return formatDate(date, "YYYY-MM-DD");
|
||||
default:
|
||||
return String(rawValue);
|
||||
}
|
||||
}
|
||||
|
||||
return String(rawValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* 주차 계산
|
||||
*/
|
||||
function getWeekNumber(date: Date): number {
|
||||
const d = new Date(
|
||||
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
);
|
||||
const dayNum = d.getUTCDay() || 7;
|
||||
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
|
||||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
|
||||
}
|
||||
|
||||
/**
|
||||
* 경로를 키로 변환
|
||||
*/
|
||||
export function pathToKey(path: string[]): string {
|
||||
return path.join("||");
|
||||
}
|
||||
|
||||
/**
|
||||
* 키를 경로로 변환
|
||||
*/
|
||||
export function keyToPath(key: string): string[] {
|
||||
return key.split("||");
|
||||
}
|
||||
|
||||
// ==================== 헤더 생성 ====================
|
||||
|
||||
/**
|
||||
* 계층적 헤더 노드 생성
|
||||
*/
|
||||
function buildHeaderTree(
|
||||
data: Record<string, any>[],
|
||||
fields: PivotFieldConfig[],
|
||||
expandedPaths: Set<string>
|
||||
): PivotHeaderNode[] {
|
||||
if (fields.length === 0) return [];
|
||||
|
||||
// 첫 번째 필드로 그룹화
|
||||
const firstField = fields[0];
|
||||
const groups = new Map<string, Record<string, any>[]>();
|
||||
|
||||
data.forEach((row) => {
|
||||
const value = getFieldValue(row, firstField);
|
||||
if (!groups.has(value)) {
|
||||
groups.set(value, []);
|
||||
}
|
||||
groups.get(value)!.push(row);
|
||||
});
|
||||
|
||||
// 정렬
|
||||
const sortedKeys = Array.from(groups.keys()).sort((a, b) => {
|
||||
if (firstField.sortOrder === "desc") {
|
||||
return b.localeCompare(a, "ko");
|
||||
}
|
||||
return a.localeCompare(b, "ko");
|
||||
});
|
||||
|
||||
// 노드 생성
|
||||
const nodes: PivotHeaderNode[] = [];
|
||||
const remainingFields = fields.slice(1);
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
const groupData = groups.get(key)!;
|
||||
const path = [key];
|
||||
const pathKey = pathToKey(path);
|
||||
|
||||
const node: PivotHeaderNode = {
|
||||
value: key,
|
||||
caption: key,
|
||||
level: 0,
|
||||
isExpanded: expandedPaths.has(pathKey),
|
||||
path: path,
|
||||
span: 1,
|
||||
};
|
||||
|
||||
// 자식 노드 생성 (확장된 경우만)
|
||||
if (remainingFields.length > 0 && node.isExpanded) {
|
||||
node.children = buildChildNodes(
|
||||
groupData,
|
||||
remainingFields,
|
||||
path,
|
||||
expandedPaths,
|
||||
1
|
||||
);
|
||||
// span 계산
|
||||
node.span = calculateSpan(node.children);
|
||||
}
|
||||
|
||||
nodes.push(node);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 자식 노드 재귀 생성
|
||||
*/
|
||||
function buildChildNodes(
|
||||
data: Record<string, any>[],
|
||||
fields: PivotFieldConfig[],
|
||||
parentPath: string[],
|
||||
expandedPaths: Set<string>,
|
||||
level: number
|
||||
): PivotHeaderNode[] {
|
||||
if (fields.length === 0) return [];
|
||||
|
||||
const field = fields[0];
|
||||
const groups = new Map<string, Record<string, any>[]>();
|
||||
|
||||
data.forEach((row) => {
|
||||
const value = getFieldValue(row, field);
|
||||
if (!groups.has(value)) {
|
||||
groups.set(value, []);
|
||||
}
|
||||
groups.get(value)!.push(row);
|
||||
});
|
||||
|
||||
const sortedKeys = Array.from(groups.keys()).sort((a, b) => {
|
||||
if (field.sortOrder === "desc") {
|
||||
return b.localeCompare(a, "ko");
|
||||
}
|
||||
return a.localeCompare(b, "ko");
|
||||
});
|
||||
|
||||
const nodes: PivotHeaderNode[] = [];
|
||||
const remainingFields = fields.slice(1);
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
const groupData = groups.get(key)!;
|
||||
const path = [...parentPath, key];
|
||||
const pathKey = pathToKey(path);
|
||||
|
||||
const node: PivotHeaderNode = {
|
||||
value: key,
|
||||
caption: key,
|
||||
level: level,
|
||||
isExpanded: expandedPaths.has(pathKey),
|
||||
path: path,
|
||||
span: 1,
|
||||
};
|
||||
|
||||
if (remainingFields.length > 0 && node.isExpanded) {
|
||||
node.children = buildChildNodes(
|
||||
groupData,
|
||||
remainingFields,
|
||||
path,
|
||||
expandedPaths,
|
||||
level + 1
|
||||
);
|
||||
node.span = calculateSpan(node.children);
|
||||
}
|
||||
|
||||
nodes.push(node);
|
||||
}
|
||||
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* span 계산 (colspan/rowspan)
|
||||
*/
|
||||
function calculateSpan(children?: PivotHeaderNode[]): number {
|
||||
if (!children || children.length === 0) return 1;
|
||||
return children.reduce((sum, child) => sum + child.span, 0);
|
||||
}
|
||||
|
||||
// ==================== 플랫 구조 변환 ====================
|
||||
|
||||
/**
|
||||
* 헤더 트리를 플랫 행으로 변환
|
||||
*/
|
||||
function flattenRows(nodes: PivotHeaderNode[]): PivotFlatRow[] {
|
||||
const result: PivotFlatRow[] = [];
|
||||
|
||||
function traverse(node: PivotHeaderNode) {
|
||||
result.push({
|
||||
path: node.path,
|
||||
level: node.level,
|
||||
caption: node.caption,
|
||||
isExpanded: node.isExpanded,
|
||||
hasChildren: !!(node.children && node.children.length > 0),
|
||||
});
|
||||
|
||||
if (node.isExpanded && node.children) {
|
||||
for (const child of node.children) {
|
||||
traverse(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
traverse(node);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더 트리를 플랫 열로 변환 (각 레벨별)
|
||||
*/
|
||||
function flattenColumns(
|
||||
nodes: PivotHeaderNode[],
|
||||
maxLevel: number
|
||||
): PivotFlatColumn[][] {
|
||||
const levels: PivotFlatColumn[][] = Array.from(
|
||||
{ length: maxLevel + 1 },
|
||||
() => []
|
||||
);
|
||||
|
||||
function traverse(node: PivotHeaderNode, currentLevel: number) {
|
||||
levels[currentLevel].push({
|
||||
path: node.path,
|
||||
level: currentLevel,
|
||||
caption: node.caption,
|
||||
span: node.span,
|
||||
});
|
||||
|
||||
if (node.children && node.isExpanded) {
|
||||
for (const child of node.children) {
|
||||
traverse(child, currentLevel + 1);
|
||||
}
|
||||
} else if (currentLevel < maxLevel) {
|
||||
// 확장되지 않은 노드는 다음 레벨들에서 span으로 처리
|
||||
for (let i = currentLevel + 1; i <= maxLevel; i++) {
|
||||
levels[i].push({
|
||||
path: node.path,
|
||||
level: i,
|
||||
caption: "",
|
||||
span: node.span,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
traverse(node, 0);
|
||||
}
|
||||
|
||||
return levels;
|
||||
}
|
||||
|
||||
/**
|
||||
* 열 헤더의 최대 깊이 계산
|
||||
*/
|
||||
function getMaxColumnLevel(
|
||||
nodes: PivotHeaderNode[],
|
||||
totalFields: number
|
||||
): number {
|
||||
let maxLevel = 0;
|
||||
|
||||
function traverse(node: PivotHeaderNode, level: number) {
|
||||
maxLevel = Math.max(maxLevel, level);
|
||||
if (node.children && node.isExpanded) {
|
||||
for (const child of node.children) {
|
||||
traverse(child, level + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
traverse(node, 0);
|
||||
}
|
||||
|
||||
return Math.min(maxLevel, totalFields - 1);
|
||||
}
|
||||
|
||||
// ==================== 데이터 매트릭스 생성 ====================
|
||||
|
||||
/**
|
||||
* 데이터 매트릭스 생성
|
||||
*/
|
||||
function buildDataMatrix(
|
||||
data: Record<string, any>[],
|
||||
rowFields: PivotFieldConfig[],
|
||||
columnFields: PivotFieldConfig[],
|
||||
dataFields: PivotFieldConfig[],
|
||||
flatRows: PivotFlatRow[],
|
||||
flatColumnLeaves: string[][]
|
||||
): Map<string, PivotCellValue[]> {
|
||||
const matrix = new Map<string, PivotCellValue[]>();
|
||||
|
||||
// 각 셀에 대해 해당하는 데이터 집계
|
||||
for (const row of flatRows) {
|
||||
for (const colPath of flatColumnLeaves) {
|
||||
const cellKey = `${pathToKey(row.path)}|||${pathToKey(colPath)}`;
|
||||
|
||||
// 해당 행/열 경로에 맞는 데이터 필터링
|
||||
const filteredData = data.filter((record) => {
|
||||
// 행 조건 확인
|
||||
for (let i = 0; i < row.path.length; i++) {
|
||||
const field = rowFields[i];
|
||||
if (!field) continue;
|
||||
const value = getFieldValue(record, field);
|
||||
if (value !== row.path[i]) return false;
|
||||
}
|
||||
|
||||
// 열 조건 확인
|
||||
for (let i = 0; i < colPath.length; i++) {
|
||||
const field = columnFields[i];
|
||||
if (!field) continue;
|
||||
const value = getFieldValue(record, field);
|
||||
if (value !== colPath[i]) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// 데이터 필드별 집계
|
||||
const cellValues: PivotCellValue[] = dataFields.map((dataField) => {
|
||||
const values = filteredData.map((r) => r[dataField.field]);
|
||||
const aggregatedValue = aggregate(
|
||||
values,
|
||||
dataField.summaryType || "sum"
|
||||
);
|
||||
const formattedValue = formatNumber(
|
||||
aggregatedValue,
|
||||
dataField.format
|
||||
);
|
||||
|
||||
return {
|
||||
field: dataField.field,
|
||||
value: aggregatedValue,
|
||||
formattedValue,
|
||||
};
|
||||
});
|
||||
|
||||
matrix.set(cellKey, cellValues);
|
||||
}
|
||||
}
|
||||
|
||||
return matrix;
|
||||
}
|
||||
|
||||
/**
|
||||
* 열 leaf 노드 경로 추출
|
||||
*/
|
||||
function getColumnLeaves(nodes: PivotHeaderNode[]): string[][] {
|
||||
const leaves: string[][] = [];
|
||||
|
||||
function traverse(node: PivotHeaderNode) {
|
||||
if (!node.isExpanded || !node.children || node.children.length === 0) {
|
||||
leaves.push(node.path);
|
||||
} else {
|
||||
for (const child of node.children) {
|
||||
traverse(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const node of nodes) {
|
||||
traverse(node);
|
||||
}
|
||||
|
||||
// 열 필드가 없을 경우 빈 경로 추가
|
||||
if (leaves.length === 0) {
|
||||
leaves.push([]);
|
||||
}
|
||||
|
||||
return leaves;
|
||||
}
|
||||
|
||||
// ==================== 총합계 계산 ====================
|
||||
|
||||
/**
|
||||
* 총합계 계산
|
||||
*/
|
||||
function calculateGrandTotals(
|
||||
data: Record<string, any>[],
|
||||
rowFields: PivotFieldConfig[],
|
||||
columnFields: PivotFieldConfig[],
|
||||
dataFields: PivotFieldConfig[],
|
||||
flatRows: PivotFlatRow[],
|
||||
flatColumnLeaves: string[][]
|
||||
): {
|
||||
row: Map<string, PivotCellValue[]>;
|
||||
column: Map<string, PivotCellValue[]>;
|
||||
grand: PivotCellValue[];
|
||||
} {
|
||||
const rowTotals = new Map<string, PivotCellValue[]>();
|
||||
const columnTotals = new Map<string, PivotCellValue[]>();
|
||||
|
||||
// 행별 총합 (각 행의 모든 열 합계)
|
||||
for (const row of flatRows) {
|
||||
const filteredData = data.filter((record) => {
|
||||
for (let i = 0; i < row.path.length; i++) {
|
||||
const field = rowFields[i];
|
||||
if (!field) continue;
|
||||
const value = getFieldValue(record, field);
|
||||
if (value !== row.path[i]) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const cellValues: PivotCellValue[] = dataFields.map((dataField) => {
|
||||
const values = filteredData.map((r) => r[dataField.field]);
|
||||
const aggregatedValue = aggregate(values, dataField.summaryType || "sum");
|
||||
return {
|
||||
field: dataField.field,
|
||||
value: aggregatedValue,
|
||||
formattedValue: formatNumber(aggregatedValue, dataField.format),
|
||||
};
|
||||
});
|
||||
|
||||
rowTotals.set(pathToKey(row.path), cellValues);
|
||||
}
|
||||
|
||||
// 열별 총합 (각 열의 모든 행 합계)
|
||||
for (const colPath of flatColumnLeaves) {
|
||||
const filteredData = data.filter((record) => {
|
||||
for (let i = 0; i < colPath.length; i++) {
|
||||
const field = columnFields[i];
|
||||
if (!field) continue;
|
||||
const value = getFieldValue(record, field);
|
||||
if (value !== colPath[i]) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const cellValues: PivotCellValue[] = dataFields.map((dataField) => {
|
||||
const values = filteredData.map((r) => r[dataField.field]);
|
||||
const aggregatedValue = aggregate(values, dataField.summaryType || "sum");
|
||||
return {
|
||||
field: dataField.field,
|
||||
value: aggregatedValue,
|
||||
formattedValue: formatNumber(aggregatedValue, dataField.format),
|
||||
};
|
||||
});
|
||||
|
||||
columnTotals.set(pathToKey(colPath), cellValues);
|
||||
}
|
||||
|
||||
// 대총합
|
||||
const grandValues: PivotCellValue[] = dataFields.map((dataField) => {
|
||||
const values = data.map((r) => r[dataField.field]);
|
||||
const aggregatedValue = aggregate(values, dataField.summaryType || "sum");
|
||||
return {
|
||||
field: dataField.field,
|
||||
value: aggregatedValue,
|
||||
formattedValue: formatNumber(aggregatedValue, dataField.format),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
row: rowTotals,
|
||||
column: columnTotals,
|
||||
grand: grandValues,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 메인 함수 ====================
|
||||
|
||||
/**
|
||||
* 피벗 데이터 처리
|
||||
*/
|
||||
export function processPivotData(
|
||||
data: Record<string, any>[],
|
||||
fields: PivotFieldConfig[],
|
||||
expandedRowPaths: string[][] = [],
|
||||
expandedColumnPaths: string[][] = []
|
||||
): PivotResult {
|
||||
// 영역별 필드 분리
|
||||
const rowFields = fields
|
||||
.filter((f) => f.area === "row" && f.visible !== false)
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
|
||||
|
||||
const columnFields = fields
|
||||
.filter((f) => f.area === "column" && f.visible !== false)
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
|
||||
|
||||
const dataFields = fields
|
||||
.filter((f) => f.area === "data" && f.visible !== false)
|
||||
.sort((a, b) => (a.areaIndex || 0) - (b.areaIndex || 0));
|
||||
|
||||
const filterFields = fields.filter(
|
||||
(f) => f.area === "filter" && f.visible !== false
|
||||
);
|
||||
|
||||
// 필터 적용
|
||||
let filteredData = data;
|
||||
for (const filterField of filterFields) {
|
||||
if (filterField.filterValues && filterField.filterValues.length > 0) {
|
||||
filteredData = filteredData.filter((row) => {
|
||||
const value = getFieldValue(row, filterField);
|
||||
if (filterField.filterType === "exclude") {
|
||||
return !filterField.filterValues!.includes(value);
|
||||
}
|
||||
return filterField.filterValues!.includes(value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 확장 경로 Set 변환
|
||||
const expandedRowSet = new Set(expandedRowPaths.map(pathToKey));
|
||||
const expandedColSet = new Set(expandedColumnPaths.map(pathToKey));
|
||||
|
||||
// 기본 확장: 첫 번째 레벨 모두 확장
|
||||
if (expandedRowPaths.length === 0 && rowFields.length > 0) {
|
||||
const firstField = rowFields[0];
|
||||
const uniqueValues = new Set(
|
||||
filteredData.map((row) => getFieldValue(row, firstField))
|
||||
);
|
||||
uniqueValues.forEach((val) => expandedRowSet.add(val));
|
||||
}
|
||||
|
||||
if (expandedColumnPaths.length === 0 && columnFields.length > 0) {
|
||||
const firstField = columnFields[0];
|
||||
const uniqueValues = new Set(
|
||||
filteredData.map((row) => getFieldValue(row, firstField))
|
||||
);
|
||||
uniqueValues.forEach((val) => expandedColSet.add(val));
|
||||
}
|
||||
|
||||
// 헤더 트리 생성
|
||||
const rowHeaders = buildHeaderTree(filteredData, rowFields, expandedRowSet);
|
||||
const columnHeaders = buildHeaderTree(
|
||||
filteredData,
|
||||
columnFields,
|
||||
expandedColSet
|
||||
);
|
||||
|
||||
// 플랫 구조 변환
|
||||
const flatRows = flattenRows(rowHeaders);
|
||||
const flatColumnLeaves = getColumnLeaves(columnHeaders);
|
||||
const maxColumnLevel = getMaxColumnLevel(columnHeaders, columnFields.length);
|
||||
const flatColumns = flattenColumns(columnHeaders, maxColumnLevel);
|
||||
|
||||
// 데이터 매트릭스 생성
|
||||
const dataMatrix = buildDataMatrix(
|
||||
filteredData,
|
||||
rowFields,
|
||||
columnFields,
|
||||
dataFields,
|
||||
flatRows,
|
||||
flatColumnLeaves
|
||||
);
|
||||
|
||||
// 총합계 계산
|
||||
const grandTotals = calculateGrandTotals(
|
||||
filteredData,
|
||||
rowFields,
|
||||
columnFields,
|
||||
dataFields,
|
||||
flatRows,
|
||||
flatColumnLeaves
|
||||
);
|
||||
|
||||
return {
|
||||
rowHeaders,
|
||||
columnHeaders,
|
||||
dataMatrix,
|
||||
flatRows,
|
||||
flatColumns: flatColumnLeaves.map((path, idx) => ({
|
||||
path,
|
||||
level: path.length - 1,
|
||||
caption: path[path.length - 1] || "",
|
||||
span: 1,
|
||||
})),
|
||||
grandTotals,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user