387 lines
9.6 KiB
TypeScript
387 lines
9.6 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* PivotChart 컴포넌트
|
||
|
|
* 피벗 데이터를 차트로 시각화
|
||
|
|
*/
|
||
|
|
|
||
|
|
import React, { useMemo } from "react";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
import { PivotResult, PivotChartConfig, PivotFieldConfig } from "../types";
|
||
|
|
import { pathToKey } from "../utils/pivotEngine";
|
||
|
|
import {
|
||
|
|
BarChart,
|
||
|
|
Bar,
|
||
|
|
LineChart,
|
||
|
|
Line,
|
||
|
|
AreaChart,
|
||
|
|
Area,
|
||
|
|
PieChart,
|
||
|
|
Pie,
|
||
|
|
Cell,
|
||
|
|
XAxis,
|
||
|
|
YAxis,
|
||
|
|
CartesianGrid,
|
||
|
|
Tooltip,
|
||
|
|
Legend,
|
||
|
|
ResponsiveContainer,
|
||
|
|
} from "recharts";
|
||
|
|
|
||
|
|
// ==================== 타입 ====================
|
||
|
|
|
||
|
|
interface PivotChartProps {
|
||
|
|
pivotResult: PivotResult;
|
||
|
|
config: PivotChartConfig;
|
||
|
|
dataFields: PivotFieldConfig[];
|
||
|
|
className?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ==================== 색상 ====================
|
||
|
|
|
||
|
|
const COLORS = [
|
||
|
|
"#4472C4", // 파랑
|
||
|
|
"#ED7D31", // 주황
|
||
|
|
"#A5A5A5", // 회색
|
||
|
|
"#FFC000", // 노랑
|
||
|
|
"#5B9BD5", // 하늘
|
||
|
|
"#70AD47", // 초록
|
||
|
|
"#264478", // 진한 파랑
|
||
|
|
"#9E480E", // 진한 주황
|
||
|
|
"#636363", // 진한 회색
|
||
|
|
"#997300", // 진한 노랑
|
||
|
|
];
|
||
|
|
|
||
|
|
// ==================== 데이터 변환 ====================
|
||
|
|
|
||
|
|
function transformDataForChart(
|
||
|
|
pivotResult: PivotResult,
|
||
|
|
dataFields: PivotFieldConfig[]
|
||
|
|
): any[] {
|
||
|
|
const { flatRows, flatColumns, dataMatrix, grandTotals } = pivotResult;
|
||
|
|
|
||
|
|
// 행 기준 차트 데이터 생성
|
||
|
|
return flatRows.map((row) => {
|
||
|
|
const dataPoint: any = {
|
||
|
|
name: row.caption,
|
||
|
|
path: row.path,
|
||
|
|
};
|
||
|
|
|
||
|
|
// 각 열에 대한 데이터 추가
|
||
|
|
flatColumns.forEach((col) => {
|
||
|
|
const cellKey = `${pathToKey(row.path)}|||${pathToKey(col.path)}`;
|
||
|
|
const values = dataMatrix.get(cellKey);
|
||
|
|
|
||
|
|
if (values && values.length > 0) {
|
||
|
|
const columnName = col.caption || "전체";
|
||
|
|
dataPoint[columnName] = values[0].value;
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
// 총계 추가
|
||
|
|
const rowTotal = grandTotals.row.get(pathToKey(row.path));
|
||
|
|
if (rowTotal && rowTotal.length > 0) {
|
||
|
|
dataPoint["총계"] = rowTotal[0].value;
|
||
|
|
}
|
||
|
|
|
||
|
|
return dataPoint;
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function transformDataForPie(
|
||
|
|
pivotResult: PivotResult,
|
||
|
|
dataFields: PivotFieldConfig[]
|
||
|
|
): any[] {
|
||
|
|
const { flatRows, grandTotals } = pivotResult;
|
||
|
|
|
||
|
|
return flatRows.map((row, idx) => {
|
||
|
|
const rowTotal = grandTotals.row.get(pathToKey(row.path));
|
||
|
|
return {
|
||
|
|
name: row.caption,
|
||
|
|
value: rowTotal?.[0]?.value || 0,
|
||
|
|
color: COLORS[idx % COLORS.length],
|
||
|
|
};
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// ==================== 차트 컴포넌트 ====================
|
||
|
|
|
||
|
|
const CustomTooltip: React.FC<any> = ({ active, payload, label }) => {
|
||
|
|
if (!active || !payload || !payload.length) return null;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div className="bg-background border border-border rounded-lg shadow-lg p-2">
|
||
|
|
<p className="text-sm font-medium mb-1">{label}</p>
|
||
|
|
{payload.map((entry: any, idx: number) => (
|
||
|
|
<p key={idx} className="text-xs" style={{ color: entry.color }}>
|
||
|
|
{entry.name}: {entry.value?.toLocaleString()}
|
||
|
|
</p>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 막대 차트
|
||
|
|
const PivotBarChart: React.FC<{
|
||
|
|
data: any[];
|
||
|
|
columns: string[];
|
||
|
|
height: number;
|
||
|
|
showLegend: boolean;
|
||
|
|
stacked?: boolean;
|
||
|
|
}> = ({ data, columns, height, showLegend, stacked }) => {
|
||
|
|
return (
|
||
|
|
<ResponsiveContainer width="100%" height={height}>
|
||
|
|
<BarChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||
|
|
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||
|
|
<XAxis
|
||
|
|
dataKey="name"
|
||
|
|
tick={{ fontSize: 11 }}
|
||
|
|
tickLine={false}
|
||
|
|
axisLine={{ stroke: "#e5e5e5" }}
|
||
|
|
/>
|
||
|
|
<YAxis
|
||
|
|
tick={{ fontSize: 11 }}
|
||
|
|
tickLine={false}
|
||
|
|
axisLine={{ stroke: "#e5e5e5" }}
|
||
|
|
tickFormatter={(value) => value.toLocaleString()}
|
||
|
|
/>
|
||
|
|
<Tooltip content={<CustomTooltip />} />
|
||
|
|
{showLegend && (
|
||
|
|
<Legend
|
||
|
|
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
|
||
|
|
iconType="square"
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
{columns.map((col, idx) => (
|
||
|
|
<Bar
|
||
|
|
key={col}
|
||
|
|
dataKey={col}
|
||
|
|
fill={COLORS[idx % COLORS.length]}
|
||
|
|
stackId={stacked ? "stack" : undefined}
|
||
|
|
radius={stacked ? 0 : [4, 4, 0, 0]}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</BarChart>
|
||
|
|
</ResponsiveContainer>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 선 차트
|
||
|
|
const PivotLineChart: React.FC<{
|
||
|
|
data: any[];
|
||
|
|
columns: string[];
|
||
|
|
height: number;
|
||
|
|
showLegend: boolean;
|
||
|
|
}> = ({ data, columns, height, showLegend }) => {
|
||
|
|
return (
|
||
|
|
<ResponsiveContainer width="100%" height={height}>
|
||
|
|
<LineChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||
|
|
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||
|
|
<XAxis
|
||
|
|
dataKey="name"
|
||
|
|
tick={{ fontSize: 11 }}
|
||
|
|
tickLine={false}
|
||
|
|
axisLine={{ stroke: "#e5e5e5" }}
|
||
|
|
/>
|
||
|
|
<YAxis
|
||
|
|
tick={{ fontSize: 11 }}
|
||
|
|
tickLine={false}
|
||
|
|
axisLine={{ stroke: "#e5e5e5" }}
|
||
|
|
tickFormatter={(value) => value.toLocaleString()}
|
||
|
|
/>
|
||
|
|
<Tooltip content={<CustomTooltip />} />
|
||
|
|
{showLegend && (
|
||
|
|
<Legend
|
||
|
|
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
|
||
|
|
iconType="line"
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
{columns.map((col, idx) => (
|
||
|
|
<Line
|
||
|
|
key={col}
|
||
|
|
type="monotone"
|
||
|
|
dataKey={col}
|
||
|
|
stroke={COLORS[idx % COLORS.length]}
|
||
|
|
strokeWidth={2}
|
||
|
|
dot={{ r: 4, fill: COLORS[idx % COLORS.length] }}
|
||
|
|
activeDot={{ r: 6 }}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</LineChart>
|
||
|
|
</ResponsiveContainer>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 영역 차트
|
||
|
|
const PivotAreaChart: React.FC<{
|
||
|
|
data: any[];
|
||
|
|
columns: string[];
|
||
|
|
height: number;
|
||
|
|
showLegend: boolean;
|
||
|
|
}> = ({ data, columns, height, showLegend }) => {
|
||
|
|
return (
|
||
|
|
<ResponsiveContainer width="100%" height={height}>
|
||
|
|
<AreaChart data={data} margin={{ top: 20, right: 30, left: 20, bottom: 5 }}>
|
||
|
|
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
|
||
|
|
<XAxis
|
||
|
|
dataKey="name"
|
||
|
|
tick={{ fontSize: 11 }}
|
||
|
|
tickLine={false}
|
||
|
|
axisLine={{ stroke: "#e5e5e5" }}
|
||
|
|
/>
|
||
|
|
<YAxis
|
||
|
|
tick={{ fontSize: 11 }}
|
||
|
|
tickLine={false}
|
||
|
|
axisLine={{ stroke: "#e5e5e5" }}
|
||
|
|
tickFormatter={(value) => value.toLocaleString()}
|
||
|
|
/>
|
||
|
|
<Tooltip content={<CustomTooltip />} />
|
||
|
|
{showLegend && (
|
||
|
|
<Legend
|
||
|
|
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
|
||
|
|
iconType="square"
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
{columns.map((col, idx) => (
|
||
|
|
<Area
|
||
|
|
key={col}
|
||
|
|
type="monotone"
|
||
|
|
dataKey={col}
|
||
|
|
fill={COLORS[idx % COLORS.length]}
|
||
|
|
stroke={COLORS[idx % COLORS.length]}
|
||
|
|
fillOpacity={0.3}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</AreaChart>
|
||
|
|
</ResponsiveContainer>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 파이 차트
|
||
|
|
const PivotPieChart: React.FC<{
|
||
|
|
data: any[];
|
||
|
|
height: number;
|
||
|
|
showLegend: boolean;
|
||
|
|
}> = ({ data, height, showLegend }) => {
|
||
|
|
return (
|
||
|
|
<ResponsiveContainer width="100%" height={height}>
|
||
|
|
<PieChart>
|
||
|
|
<Pie
|
||
|
|
data={data}
|
||
|
|
dataKey="value"
|
||
|
|
nameKey="name"
|
||
|
|
cx="50%"
|
||
|
|
cy="50%"
|
||
|
|
outerRadius={height / 3}
|
||
|
|
label={({ name, percent }) =>
|
||
|
|
`${name} (${(percent * 100).toFixed(1)}%)`
|
||
|
|
}
|
||
|
|
labelLine
|
||
|
|
>
|
||
|
|
{data.map((entry, idx) => (
|
||
|
|
<Cell key={idx} fill={entry.color || COLORS[idx % COLORS.length]} />
|
||
|
|
))}
|
||
|
|
</Pie>
|
||
|
|
<Tooltip content={<CustomTooltip />} />
|
||
|
|
{showLegend && (
|
||
|
|
<Legend
|
||
|
|
wrapperStyle={{ fontSize: 11, paddingTop: 10 }}
|
||
|
|
iconType="circle"
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</PieChart>
|
||
|
|
</ResponsiveContainer>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
// ==================== 메인 컴포넌트 ====================
|
||
|
|
|
||
|
|
export const PivotChart: React.FC<PivotChartProps> = ({
|
||
|
|
pivotResult,
|
||
|
|
config,
|
||
|
|
dataFields,
|
||
|
|
className,
|
||
|
|
}) => {
|
||
|
|
// 차트 데이터 변환
|
||
|
|
const chartData = useMemo(() => {
|
||
|
|
if (config.type === "pie") {
|
||
|
|
return transformDataForPie(pivotResult, dataFields);
|
||
|
|
}
|
||
|
|
return transformDataForChart(pivotResult, dataFields);
|
||
|
|
}, [pivotResult, dataFields, config.type]);
|
||
|
|
|
||
|
|
// 열 이름 목록 (파이 차트 제외)
|
||
|
|
const columns = useMemo(() => {
|
||
|
|
if (config.type === "pie" || chartData.length === 0) return [];
|
||
|
|
|
||
|
|
const firstItem = chartData[0];
|
||
|
|
return Object.keys(firstItem).filter(
|
||
|
|
(key) => key !== "name" && key !== "path"
|
||
|
|
);
|
||
|
|
}, [chartData, config.type]);
|
||
|
|
|
||
|
|
const height = config.height || 300;
|
||
|
|
const showLegend = config.showLegend !== false;
|
||
|
|
|
||
|
|
if (!config.enabled) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"border-t border-border bg-background p-4",
|
||
|
|
className
|
||
|
|
)}
|
||
|
|
>
|
||
|
|
{/* 차트 렌더링 */}
|
||
|
|
{config.type === "bar" && (
|
||
|
|
<PivotBarChart
|
||
|
|
data={chartData}
|
||
|
|
columns={columns}
|
||
|
|
height={height}
|
||
|
|
showLegend={showLegend}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{config.type === "stackedBar" && (
|
||
|
|
<PivotBarChart
|
||
|
|
data={chartData}
|
||
|
|
columns={columns}
|
||
|
|
height={height}
|
||
|
|
showLegend={showLegend}
|
||
|
|
stacked
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{config.type === "line" && (
|
||
|
|
<PivotLineChart
|
||
|
|
data={chartData}
|
||
|
|
columns={columns}
|
||
|
|
height={height}
|
||
|
|
showLegend={showLegend}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{config.type === "area" && (
|
||
|
|
<PivotAreaChart
|
||
|
|
data={chartData}
|
||
|
|
columns={columns}
|
||
|
|
height={height}
|
||
|
|
showLegend={showLegend}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{config.type === "pie" && (
|
||
|
|
<PivotPieChart
|
||
|
|
data={chartData}
|
||
|
|
height={height}
|
||
|
|
showLegend={showLegend}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
};
|
||
|
|
|
||
|
|
export default PivotChart;
|
||
|
|
|