diff --git a/frontend/components/admin/dashboard/charts/Chart.tsx b/frontend/components/admin/dashboard/charts/Chart.tsx index 012f3c77..2ff4de59 100644 --- a/frontend/components/admin/dashboard/charts/Chart.tsx +++ b/frontend/components/admin/dashboard/charts/Chart.tsx @@ -6,6 +6,7 @@ import { LineChart } from "./LineChart"; import { AreaChart } from "./AreaChart"; import { PieChart } from "./PieChart"; import { StackedBarChart } from "./StackedBarChart"; +import { ComboChart } from "./ComboChart"; import { ChartConfig, ChartData, ElementSubtype } from "../types"; interface ChartProps { @@ -58,8 +59,7 @@ export function Chart({ chartType, data, config, width, height }: ChartProps) { return ; case "combo": - // Combo 차트는 일단 Bar + Line 조합으로 표시 (추후 구현 가능) - return ; + return ; default: return ( diff --git a/frontend/components/admin/dashboard/charts/ComboChart.tsx b/frontend/components/admin/dashboard/charts/ComboChart.tsx new file mode 100644 index 00000000..ce373d71 --- /dev/null +++ b/frontend/components/admin/dashboard/charts/ComboChart.tsx @@ -0,0 +1,323 @@ +"use client"; + +import React, { useEffect, useRef } from "react"; +import * as d3 from "d3"; +import { ChartConfig, ChartData } from "../types"; + +interface ComboChartProps { + data: ChartData; + config: ChartConfig; + width?: number; + height?: number; +} + +/** + * D3 콤보 차트 컴포넌트 (막대 + 선) + * - 첫 번째 데이터셋: 막대 차트 + * - 나머지 데이터셋: 선 차트 + */ +export function ComboChart({ data, config, width = 600, height = 400 }: ComboChartProps) { + const svgRef = useRef(null); + + useEffect(() => { + if (!svgRef.current || !data.labels.length || !data.datasets.length) return; + + const svg = d3.select(svgRef.current); + svg.selectAll("*").remove(); + + const margin = { top: 40, right: 80, bottom: 60, left: 60 }; + const chartWidth = width - margin.left - margin.right; + const chartHeight = height - margin.top - margin.bottom; + + const g = svg.append("g").attr("transform", `translate(${margin.left},${margin.top})`); + + // X축 스케일 (카테고리) + const xScale = d3.scaleBand().domain(data.labels).range([0, chartWidth]).padding(0.2); + + // Y축 스케일 (값) + const maxValue = d3.max(data.datasets.flatMap((ds) => ds.data)) || 0; + const yScale = d3 + .scaleLinear() + .domain([0, maxValue * 1.1]) + .range([chartHeight, 0]) + .nice(); + + // X축 그리기 + g.append("g") + .attr("transform", `translate(0,${chartHeight})`) + .call(d3.axisBottom(xScale)) + .selectAll("text") + .attr("transform", "rotate(-45)") + .style("text-anchor", "end") + .style("font-size", "12px"); + + // Y축 그리기 + g.append("g").call(d3.axisLeft(yScale)).style("font-size", "12px"); + + // 그리드 라인 + if (config.showGrid !== false) { + g.append("g") + .attr("class", "grid") + .call( + d3 + .axisLeft(yScale) + .tickSize(-chartWidth) + .tickFormat(() => ""), + ) + .style("stroke-dasharray", "3,3") + .style("stroke", "#e0e0e0") + .style("opacity", 0.5); + } + + // 색상 팔레트 + const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"]; + + // 첫 번째 데이터셋: 막대 차트 + if (data.datasets.length > 0) { + const barDataset = data.datasets[0]; + const bars = g + .selectAll(".bar") + .data(barDataset.data) + .enter() + .append("rect") + .attr("class", "bar") + .attr("x", (_, j) => xScale(data.labels[j]) || 0) + .attr("y", chartHeight) + .attr("width", xScale.bandwidth()) + .attr("height", 0) + .attr("fill", barDataset.color || colors[0]) + .attr("rx", 4); + + // 애니메이션 + if (config.enableAnimation !== false) { + bars + .transition() + .duration(config.animationDuration || 750) + .attr("y", (d) => yScale(d)) + .attr("height", (d) => chartHeight - yScale(d)); + } else { + bars.attr("y", (d) => yScale(d)).attr("height", (d) => chartHeight - yScale(d)); + } + + // 툴팁 + if (config.showTooltip !== false) { + bars + .on("mouseover", function (event, d) { + d3.select(this).attr("opacity", 0.7); + + const [mouseX, mouseY] = d3.pointer(event, g.node()); + const tooltipText = `${barDataset.label}: ${d}`; + + const tooltip = g + .append("g") + .attr("class", "tooltip") + .attr("transform", `translate(${mouseX},${mouseY - 10})`); + + const text = tooltip + .append("text") + .attr("text-anchor", "middle") + .attr("fill", "white") + .attr("font-size", "12px") + .attr("dy", "-0.5em") + .text(tooltipText); + + const bbox = (text.node() as SVGTextElement).getBBox(); + const padding = 8; + + tooltip + .insert("rect", "text") + .attr("x", bbox.x - padding) + .attr("y", bbox.y - padding) + .attr("width", bbox.width + padding * 2) + .attr("height", bbox.height + padding * 2) + .attr("fill", "rgba(0,0,0,0.85)") + .attr("rx", 6); + }) + .on("mouseout", function () { + d3.select(this).attr("opacity", 1); + g.selectAll(".tooltip").remove(); + }); + } + } + + // 나머지 데이터셋: 선 차트 + for (let i = 1; i < data.datasets.length; i++) { + const dataset = data.datasets[i]; + const lineColor = dataset.color || colors[i % colors.length]; + + // 라인 생성기 + const line = d3 + .line() + .x((_, j) => (xScale(data.labels[j]) || 0) + xScale.bandwidth() / 2) + .y((d) => yScale(d)) + .curve(d3.curveMonotoneX); + + // 라인 그리기 + const path = g + .append("path") + .datum(dataset.data) + .attr("fill", "none") + .attr("stroke", lineColor) + .attr("stroke-width", 2) + .attr("d", line); + + // 애니메이션 + if (config.enableAnimation !== false) { + const totalLength = path.node()?.getTotalLength() || 0; + path + .attr("stroke-dasharray", `${totalLength} ${totalLength}`) + .attr("stroke-dashoffset", totalLength) + .transition() + .duration(config.animationDuration || 750) + .attr("stroke-dashoffset", 0); + } + + // 포인트 그리기 + const circles = g + .selectAll(`.point-${i}`) + .data(dataset.data) + .enter() + .append("circle") + .attr("class", `point-${i}`) + .attr("cx", (_, j) => (xScale(data.labels[j]) || 0) + xScale.bandwidth() / 2) + .attr("cy", (d) => yScale(d)) + .attr("r", 0) + .attr("fill", lineColor) + .attr("stroke", "white") + .attr("stroke-width", 2); + + // 애니메이션 + if (config.enableAnimation !== false) { + circles + .transition() + .delay((_, j) => j * 50) + .duration(300) + .attr("r", 4); + } else { + circles.attr("r", 4); + } + + // 툴팁 + if (config.showTooltip !== false) { + circles + .on("mouseover", function (event, d) { + d3.select(this).attr("r", 6); + + const [mouseX, mouseY] = d3.pointer(event, g.node()); + const tooltipText = `${dataset.label}: ${d}`; + + const tooltip = g + .append("g") + .attr("class", "tooltip") + .attr("transform", `translate(${mouseX},${mouseY - 10})`); + + const text = tooltip + .append("text") + .attr("text-anchor", "middle") + .attr("fill", "white") + .attr("font-size", "12px") + .attr("dy", "-0.5em") + .text(tooltipText); + + const bbox = (text.node() as SVGTextElement).getBBox(); + const padding = 8; + + tooltip + .insert("rect", "text") + .attr("x", bbox.x - padding) + .attr("y", bbox.y - padding) + .attr("width", bbox.width + padding * 2) + .attr("height", bbox.height + padding * 2) + .attr("fill", "rgba(0,0,0,0.85)") + .attr("rx", 6); + }) + .on("mouseout", function () { + d3.select(this).attr("r", 4); + g.selectAll(".tooltip").remove(); + }); + } + } + + // 차트 제목 + if (config.title) { + svg + .append("text") + .attr("x", width / 2) + .attr("y", 20) + .attr("text-anchor", "middle") + .style("font-size", "16px") + .style("font-weight", "bold") + .text(config.title); + } + + // X축 라벨 + if (config.xAxisLabel) { + svg + .append("text") + .attr("x", width / 2) + .attr("y", height - 5) + .attr("text-anchor", "middle") + .style("font-size", "12px") + .style("fill", "#666") + .text(config.xAxisLabel); + } + + // Y축 라벨 + if (config.yAxisLabel) { + svg + .append("text") + .attr("transform", "rotate(-90)") + .attr("x", -height / 2) + .attr("y", 15) + .attr("text-anchor", "middle") + .style("font-size", "12px") + .style("fill", "#666") + .text(config.yAxisLabel); + } + + // 범례 + if (config.showLegend !== false && data.datasets.length > 0) { + const legend = svg.append("g").attr("transform", `translate(${width - margin.right + 10}, ${margin.top})`); + + data.datasets.forEach((dataset, i) => { + const legendRow = legend.append("g").attr("transform", `translate(0, ${i * 25})`); + + // 범례 아이콘 (첫 번째는 사각형, 나머지는 라인) + if (i === 0) { + legendRow + .append("rect") + .attr("width", 15) + .attr("height", 15) + .attr("fill", dataset.color || colors[i % colors.length]) + .attr("rx", 3); + } else { + legendRow + .append("line") + .attr("x1", 0) + .attr("y1", 7) + .attr("x2", 15) + .attr("y2", 7) + .attr("stroke", dataset.color || colors[i % colors.length]) + .attr("stroke-width", 2); + + legendRow + .append("circle") + .attr("cx", 7.5) + .attr("cy", 7) + .attr("r", 3) + .attr("fill", dataset.color || colors[i % colors.length]); + } + + legendRow + .append("text") + .attr("x", 20) + .attr("y", 12) + .style("font-size", "11px") + .style("fill", "#666") + .text(dataset.label); + }); + } + }, [data, config, width, height]); + + return ; +} diff --git a/frontend/components/admin/dashboard/charts/index.ts b/frontend/components/admin/dashboard/charts/index.ts index 7eb547f9..f6b98ce1 100644 --- a/frontend/components/admin/dashboard/charts/index.ts +++ b/frontend/components/admin/dashboard/charts/index.ts @@ -4,3 +4,4 @@ export { LineChart } from "./LineChart"; export { AreaChart } from "./AreaChart"; export { PieChart } from "./PieChart"; export { StackedBarChart } from "./StackedBarChart"; +export { ComboChart } from "./ComboChart";