/** * PivotGrid 데이터 처리 엔진 * 원시 데이터를 피벗 구조로 변환합니다. */ import { PivotFieldConfig, PivotResult, PivotHeaderNode, PivotFlatRow, PivotFlatColumn, PivotCellValue, DateGroupInterval, AggregationType, SummaryDisplayMode, } from "../types"; import { aggregate, formatNumber, formatDate } from "./aggregation"; // ==================== 헬퍼 함수 ==================== /** * 필드 값 추출 (날짜 그룹핑 포함) */ function getFieldValue( row: Record, 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[], fields: PivotFieldConfig[], expandedPaths: Set ): PivotHeaderNode[] { if (fields.length === 0) return []; // 첫 번째 필드로 그룹화 const firstField = fields[0]; const groups = new Map[]>(); 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[], fields: PivotFieldConfig[], parentPath: string[], expandedPaths: Set, level: number ): PivotHeaderNode[] { if (fields.length === 0) return []; const field = fields[0]; const groups = new Map[]>(); 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[], rowFields: PivotFieldConfig[], columnFields: PivotFieldConfig[], dataFields: PivotFieldConfig[], flatRows: PivotFlatRow[], flatColumnLeaves: string[][] ): Map { const matrix = new Map(); // 각 셀에 대해 해당하는 데이터 집계 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; } // ==================== Summary Display Mode 적용 ==================== /** * Summary Display Mode에 따른 값 변환 */ function applyDisplayMode( value: number, displayMode: SummaryDisplayMode | undefined, rowTotal: number, columnTotal: number, grandTotal: number, prevValue: number | null, runningTotal: number, format?: PivotFieldConfig["format"] ): { value: number; formattedValue: string } { if (!displayMode || displayMode === "absoluteValue") { return { value, formattedValue: formatNumber(value, format), }; } let resultValue: number; let formatOverride: PivotFieldConfig["format"] | undefined; switch (displayMode) { case "percentOfRowTotal": resultValue = rowTotal === 0 ? 0 : (value / rowTotal) * 100; formatOverride = { type: "percent", precision: 2, suffix: "%" }; break; case "percentOfColumnTotal": resultValue = columnTotal === 0 ? 0 : (value / columnTotal) * 100; formatOverride = { type: "percent", precision: 2, suffix: "%" }; break; case "percentOfGrandTotal": resultValue = grandTotal === 0 ? 0 : (value / grandTotal) * 100; formatOverride = { type: "percent", precision: 2, suffix: "%" }; break; case "percentOfRowGrandTotal": resultValue = rowTotal === 0 ? 0 : (value / rowTotal) * 100; formatOverride = { type: "percent", precision: 2, suffix: "%" }; break; case "percentOfColumnGrandTotal": resultValue = columnTotal === 0 ? 0 : (value / columnTotal) * 100; formatOverride = { type: "percent", precision: 2, suffix: "%" }; break; case "runningTotalByRow": case "runningTotalByColumn": resultValue = runningTotal; break; case "differenceFromPrevious": resultValue = prevValue === null ? 0 : value - prevValue; break; case "percentDifferenceFromPrevious": resultValue = prevValue === null || prevValue === 0 ? 0 : ((value - prevValue) / Math.abs(prevValue)) * 100; formatOverride = { type: "percent", precision: 2, suffix: "%" }; break; default: resultValue = value; } return { value: resultValue, formattedValue: formatNumber(resultValue, formatOverride || format), }; } /** * 데이터 매트릭스에 Summary Display Mode 적용 */ function applyDisplayModeToMatrix( matrix: Map, dataFields: PivotFieldConfig[], flatRows: PivotFlatRow[], flatColumnLeaves: string[][], rowTotals: Map, columnTotals: Map, grandTotals: PivotCellValue[] ): Map { // displayMode가 있는 데이터 필드가 있는지 확인 const hasDisplayMode = dataFields.some( (df) => df.summaryDisplayMode || df.showValuesAs ); if (!hasDisplayMode) return matrix; const newMatrix = new Map(); // 누계를 위한 추적 (행별, 열별) const rowRunningTotals: Map = new Map(); // fieldIndex -> 누계 const colRunningTotals: Map> = new Map(); // colKey -> fieldIndex -> 누계 // 행 순서대로 처리 for (const row of flatRows) { // 이전 열 값 추적 (차이 계산용) const prevColValues: (number | null)[] = dataFields.map(() => null); for (let colIdx = 0; colIdx < flatColumnLeaves.length; colIdx++) { const colPath = flatColumnLeaves[colIdx]; const cellKey = `${pathToKey(row.path)}|||${pathToKey(colPath)}`; const values = matrix.get(cellKey); if (!values) { newMatrix.set(cellKey, []); continue; } const rowKey = pathToKey(row.path); const colKey = pathToKey(colPath); // 총합 가져오기 const rowTotal = rowTotals.get(rowKey); const colTotal = columnTotals.get(colKey); const newValues: PivotCellValue[] = values.map((val, fieldIdx) => { const dataField = dataFields[fieldIdx]; const displayMode = dataField.summaryDisplayMode || dataField.showValuesAs; if (!displayMode || displayMode === "absoluteValue") { prevColValues[fieldIdx] = val.value; return val; } // 누계 계산 // 행 방향 누계 if (!rowRunningTotals.has(rowKey)) { rowRunningTotals.set(rowKey, dataFields.map(() => 0)); } const rowRunning = rowRunningTotals.get(rowKey)!; rowRunning[fieldIdx] += val.value || 0; // 열 방향 누계 if (!colRunningTotals.has(colKey)) { colRunningTotals.set(colKey, new Map()); } const colRunning = colRunningTotals.get(colKey)!; if (!colRunning.has(fieldIdx)) { colRunning.set(fieldIdx, 0); } colRunning.set(fieldIdx, (colRunning.get(fieldIdx) || 0) + (val.value || 0)); const result = applyDisplayMode( val.value || 0, displayMode, rowTotal?.[fieldIdx]?.value || 0, colTotal?.[fieldIdx]?.value || 0, grandTotals[fieldIdx]?.value || 0, prevColValues[fieldIdx], displayMode === "runningTotalByRow" ? rowRunning[fieldIdx] : colRunning.get(fieldIdx) || 0, dataField.format ); prevColValues[fieldIdx] = val.value; return { field: val.field, value: result.value, formattedValue: result.formattedValue, }; }); newMatrix.set(cellKey, newValues); } } return newMatrix; } // ==================== 총합계 계산 ==================== /** * 총합계 계산 */ function calculateGrandTotals( data: Record[], rowFields: PivotFieldConfig[], columnFields: PivotFieldConfig[], dataFields: PivotFieldConfig[], flatRows: PivotFlatRow[], flatColumnLeaves: string[][] ): { row: Map; column: Map; grand: PivotCellValue[]; } { const rowTotals = new Map(); const columnTotals = new Map(); // 행별 총합 (각 행의 모든 열 합계) 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[], 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)); // 참고: 필터링은 PivotGridComponent에서 이미 처리됨 // 여기서는 추가 필터링 없이 전달받은 데이터 사용 const filteredData = data; // 확장 경로 Set 변환 (잘못된 형식 필터링) const validRowPaths = (expandedRowPaths || []).filter( (p): p is string[] => Array.isArray(p) && p.length > 0 && p.every(item => typeof item === "string") ); const validColPaths = (expandedColumnPaths || []).filter( (p): p is string[] => Array.isArray(p) && p.length > 0 && p.every(item => typeof item === "string") ); const expandedRowSet = new Set(validRowPaths.map(pathToKey)); const expandedColSet = new Set(validColPaths.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); // 데이터 매트릭스 생성 let dataMatrix = buildDataMatrix( filteredData, rowFields, columnFields, dataFields, flatRows, flatColumnLeaves ); // 총합계 계산 const grandTotals = calculateGrandTotals( filteredData, rowFields, columnFields, dataFields, flatRows, flatColumnLeaves ); // Summary Display Mode 적용 dataMatrix = applyDisplayModeToMatrix( dataMatrix, dataFields, flatRows, flatColumnLeaves, grandTotals.row, grandTotals.column, grandTotals.grand ); return { rowHeaders, columnHeaders, dataMatrix, flatRows, flatColumns: flatColumnLeaves.map((path, idx) => ({ path, level: path.length - 1, caption: path[path.length - 1] || "", span: 1, })), grandTotals, }; }