287 lines
9.7 KiB
TypeScript
287 lines
9.7 KiB
TypeScript
"use client"
|
|
import type { CSSProperties } from "react";
|
|
import { Bar, CartesianGrid, ComposedChart, Legend, Line, Tooltip, XAxis, YAxis } from "recharts";
|
|
import {
|
|
ChartContainer,
|
|
type ChartConfig
|
|
} from "./lib/chart";
|
|
import type { ContentType } from "recharts/types/component/DefaultLegendContent";
|
|
import type { set } from "date-fns";
|
|
import React from "react";
|
|
|
|
// 툴크 커스텀 css
|
|
/** 차트 기본 스타일 */
|
|
const customStyles = {
|
|
legendContainer: {
|
|
display: "flex",
|
|
justifyContent: "center",
|
|
gap: "20px",
|
|
fontSize: "9px",
|
|
fontWeight : "bold",
|
|
marginTop: "10px",
|
|
} as CSSProperties,
|
|
};
|
|
|
|
type LineBarChartProps = {
|
|
chartConfig: ChartConfig | undefined;
|
|
chartData: any[] | undefined;
|
|
chartTitle: string;
|
|
chartClassName?: string;
|
|
graphSize?: number;
|
|
yFieldText?: string | undefined;
|
|
yFieldText2?: string | undefined;
|
|
showLegend?: boolean;
|
|
setXAxisYearTick?: boolean;
|
|
}
|
|
|
|
type LegendEntry = {
|
|
value: string;
|
|
color: string;
|
|
type?: string;
|
|
payload?: unknown;
|
|
};
|
|
|
|
type LegendProps = {
|
|
payload?: LegendEntry[];
|
|
};
|
|
|
|
// 커스텀 툴팁
|
|
const CustomTooltip = ({ active, payload, label }: { active?: boolean; payload?: Array<{ name: string; value: number; color: string }>; label?: string }) => {
|
|
if (active && payload && payload.length) {
|
|
return (
|
|
<div className="w-[180px] rounded border border-border bg-accent p-2.5 text-accent-foreground shadow-[0px_2px_10px_rgba(0,0,0,0.2)]">
|
|
<p className="m-3 text-[12px] font-bold text-accent-foreground">{label}</p>
|
|
{payload.map((item, index) => (
|
|
<div key={index} style={{ marginTop: "10px", display: "flex", alignItems: "center", gap: "8px" }}>
|
|
{/* 색상 박스 추가 (범례 색상과 동일) */}
|
|
<div style={{ width: "10px", height: "10px", backgroundColor: item.color, borderRadius: "50%" }}></div>
|
|
<span className="text-[11px] font-bold text-accent-foreground">{` ${item.name} : ${item.value.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",")}`}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// 커스텀 범례 (글씨 크기 키우기 + 스타일 추가)
|
|
const CustomLegend = (props: LegendProps) => {
|
|
const { payload } = props;
|
|
if (!payload) return null;
|
|
|
|
return (
|
|
<div style={customStyles.legendContainer}>
|
|
{payload.map((entry: LegendEntry, index: number) => (
|
|
<div key={`legend-${index}`} className="flex items-center gap-2">
|
|
<div className="w-[14px] h-[14px] ml-10" style={{ backgroundColor: entry.color }}></div>
|
|
<span className="text-[12px] text-card-foreground">{entry.value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default function LineBarchart({
|
|
chartConfig,
|
|
chartData,
|
|
chartTitle,
|
|
chartClassName = 'w-full h-full',
|
|
graphSize = 40,
|
|
yFieldText,
|
|
yFieldText2,
|
|
showLegend = true,
|
|
setXAxisYearTick = false
|
|
}: LineBarChartProps) {
|
|
if (!chartData || chartData.length === 0) {
|
|
return <div>데이터가 없습니다.</div>;
|
|
}
|
|
|
|
// 데이터의 최대값 계산
|
|
const getMaxValue = (data: any[], keyPrefix: string) => {
|
|
if (!data || data.length === 0) return 0;
|
|
return Math.max(...data.map(item =>
|
|
Object.keys(item)
|
|
.filter(key => key.includes(keyPrefix))
|
|
.map(key => item[key])
|
|
).flat());
|
|
};
|
|
|
|
const lineMaxValue = getMaxValue(chartData, 'lineVal');
|
|
const barMaxValue = getMaxValue(chartData, 'barVal');
|
|
|
|
/**
|
|
* 최대값과 구간 수에 따른 범례 만들기
|
|
* maxValue : 최대값, segments : 구간 수 (구간수가 5면 눈금은 6)
|
|
*/
|
|
const makeNiceScale = (maxValue: number, segments = 5) => {
|
|
// 최대값이 이상할 경우 0, 0으로 반환
|
|
if (!isFinite(maxValue) || maxValue <= 0) {
|
|
return { max: 0, ticks: [0] };
|
|
}
|
|
|
|
const exponent = Math.floor(Math.log10(maxValue));
|
|
const base = Math.pow(10, exponent);
|
|
const fraction = maxValue / base;
|
|
|
|
let niceFraction: number;
|
|
if (fraction <= 1) niceFraction = 1;
|
|
else if (fraction <= 2) niceFraction = 2;
|
|
else if (fraction <= 3) niceFraction = 3;
|
|
else if (fraction <= 4) niceFraction = 4;
|
|
else if (fraction <= 5) niceFraction = 5;
|
|
else if (fraction <= 6) niceFraction = 6;
|
|
else if (fraction <= 7) niceFraction = 7;
|
|
else if (fraction <= 8) niceFraction = 8;
|
|
else if (fraction <= 9) niceFraction = 9;
|
|
else niceFraction = 10;
|
|
|
|
const niceMax = niceFraction * base; // round up to 1/2/5 * 10^k
|
|
const step = niceMax / segments;
|
|
const ticks = Array.from({ length: segments + 1 }, (_, i) => Number((i * step).toFixed(10)));
|
|
return { max: niceMax, ticks };
|
|
};
|
|
|
|
const leftScale = makeNiceScale(lineMaxValue, 5);
|
|
const rightScale = makeNiceScale(barMaxValue, 5);
|
|
|
|
const getTicks = (data : any []) => {
|
|
const monthSet = new Set<string>();
|
|
const rtnData : string[] = [];
|
|
for(const item of data){
|
|
const month = String(item.label).slice(0,7);
|
|
if(!monthSet.has(month)){
|
|
monthSet.add(month);
|
|
rtnData.push(item.label);
|
|
}
|
|
}
|
|
return rtnData;
|
|
}
|
|
|
|
let monthTicks : string[] = [];
|
|
if(setXAxisYearTick){
|
|
monthTicks = getTicks(chartData);
|
|
}
|
|
|
|
const CustomAxisText : React.FC<{ viewBox: any, fieldText: string, position:string }> = ({ viewBox, fieldText, position }) => {
|
|
const { x, y, width, height } = viewBox; // 축 영역
|
|
const padding = 5; // 차트 바깥으로 띄우기
|
|
|
|
if('left' == position) {
|
|
return (
|
|
<text
|
|
x={x - padding} // 맨 좌측 바깥
|
|
y={y + height / 2 - 12} // 수직 중앙
|
|
textAnchor="middle"
|
|
transform={`rotate(-90, ${x - padding}, ${y + height / 2})`}
|
|
className="text-[12px] font-bold"
|
|
fill="var(--muted-foreground)"
|
|
>
|
|
{fieldText}
|
|
</text>
|
|
);
|
|
}else{
|
|
return (
|
|
<text
|
|
x={x + height / 2 } // 맨 좌측 바깥
|
|
y={y - width + padding } // 수직 중앙
|
|
textAnchor="middle"
|
|
transform={`rotate(90, ${x}, ${y})`}
|
|
className="text-[12px] font-bold"
|
|
fill="var(--muted-foreground)"
|
|
>
|
|
{fieldText}
|
|
</text>
|
|
);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className={chartClassName}>
|
|
{chartTitle && chartTitle !== "" && chartTitle !== "undefined" ? (
|
|
<div style={{ textAlign: "left", fontSize: "13px", fontWeight: "bold", marginTop:"13px", marginBottom: "10px" }}>
|
|
{chartTitle}
|
|
</div>
|
|
) : null}
|
|
<div className="h-[calc(100%-42px)] w-[calc(100%-40px)] ">
|
|
<ChartContainer config={chartConfig ?? {}} className="h-full w-[100%] ml-5">
|
|
<ComposedChart
|
|
data={chartData}
|
|
height={graphSize * 7.5}
|
|
width={graphSize * 12.5}
|
|
margin={{ top: 5, right: 20, left: 30, bottom: 5 }}
|
|
>
|
|
<CartesianGrid strokeDasharray="3 3" />
|
|
{setXAxisYearTick?
|
|
<XAxis dataKey="label" ticks={monthTicks} tickFormatter={(value) => value.slice(0,7)} className="text-[12px] font-bold" /> :
|
|
<XAxis dataKey="label" className="text-[12px] font-bold" />
|
|
}
|
|
{/* 왼쪽 Y축: lineVal */}
|
|
{yFieldText && <YAxis
|
|
yAxisId="left"
|
|
className="text-[12px] font-bold"
|
|
tickFormatter={(value) =>
|
|
typeof value === "number" ? value.toLocaleString() : value
|
|
}
|
|
domain={[0, leftScale.max]}
|
|
ticks={leftScale.ticks}
|
|
orientation="left"
|
|
label={(props) => <CustomAxisText {...props} fieldText={yFieldText} position="left" />}
|
|
/>}
|
|
{/* 오른쪽 Y축: barVal */}
|
|
{yFieldText2 && <YAxis
|
|
yAxisId="right"
|
|
className="text-[12px] font-bold"
|
|
orientation="right"
|
|
tickFormatter={(value) =>
|
|
typeof value === "number" ? value.toLocaleString() : value
|
|
}
|
|
domain={[0, rightScale.max]}
|
|
ticks={rightScale.ticks}
|
|
label={(props) => <CustomAxisText {...props} fieldText={yFieldText2} position="right" />}
|
|
reversed={true}
|
|
/>}
|
|
<Tooltip content={<CustomTooltip active={undefined} payload={undefined} label={undefined} />} />
|
|
{showLegend && <Legend content={CustomLegend as ContentType}/>}
|
|
{/* 바 차트 (오른쪽 Y축 사용) */}
|
|
{Object.keys(chartData[0]).map((key, index) => {
|
|
const config = chartConfig?.[key];
|
|
if(key.includes("barVal")) {
|
|
return (
|
|
<Bar
|
|
key={`bar-${key}-${index}`}
|
|
yAxisId="right"
|
|
dataKey={key}
|
|
fill={config?.color || "var(--foreground)"}
|
|
name={String(config?.label || "Y축")}
|
|
maxBarSize={80}
|
|
/>
|
|
);
|
|
}
|
|
return null;
|
|
})}
|
|
{/* 라인 차트 (왼쪽 Y축 사용) */}
|
|
{Object.keys(chartData[0]).map((key, index) => {
|
|
const config = chartConfig?.[key];
|
|
if(key.includes("lineVal")) {
|
|
return (
|
|
<Line
|
|
key={`line-${key}-${index}`}
|
|
yAxisId="left"
|
|
type="linear"
|
|
dataKey={key}
|
|
stroke={config?.color || "var(--foreground)"}
|
|
strokeWidth={2}
|
|
dot={{r: 0}}
|
|
name={String(config?.label || "X축")}
|
|
/>
|
|
);
|
|
}
|
|
return null;
|
|
})}
|
|
</ComposedChart>
|
|
</ChartContainer>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|