diff --git a/frontend/components/admin/dashboard/CHART_SYSTEM_PLAN.md b/frontend/components/admin/dashboard/CHART_SYSTEM_PLAN.md index 69a6ea3a..798a409b 100644 --- a/frontend/components/admin/dashboard/CHART_SYSTEM_PLAN.md +++ b/frontend/components/admin/dashboard/CHART_SYSTEM_PLAN.md @@ -654,7 +654,7 @@ LIMIT 10; **구현 시작일**: 2025-10-14 **목표 완료일**: 2025-10-20 -**현재 진행률**: 40% (Phase 1 완료 + Phase 2 완료 ✅) +**현재 진행률**: 90% (Phase 1-5 완료, D3 차트 추가 구현 ✅) --- @@ -701,8 +701,42 @@ LIMIT 10; --- -## 🎉 Phase 2 완료 요약 +## 🎉 전체 구현 완료 요약 -- **DatabaseConfig**: Mock 데이터 제거 → 실제 API 호출 +### Phase 1: 데이터 소스 UI ✅ + +- `DataSourceSelector`: DB vs API 선택 UI +- `DatabaseConfig`: 현재 DB / 외부 DB 선택 및 API 연동 +- `ApiConfig`: REST API 설정 +- `dataSourceUtils`: 유틸리티 함수 + +### Phase 2: 서버 API 통합 ✅ + +- `GET /api/external-db-connections`: 외부 커넥션 목록 조회 +- `POST /api/external-db-connections/:id/execute`: 외부 DB 쿼리 실행 +- `POST /api/dashboards/execute-query`: 현재 DB 쿼리 실행 - **QueryEditor**: 현재 DB / 외부 DB 분기 처리 완료 -- **API 통합**: 모든 필요한 API가 이미 구현되어 있었고, 프론트엔드 통합 완료 + +### Phase 3: 차트 설정 UI ✅ + +- `ChartConfigPanel`: X/Y축 매핑, 스타일 설정, 색상 팔레트 +- 다중 Y축 선택 지원 +- 설정 미리보기 + +### Phase 4: D3 차트 컴포넌트 ✅ + +- **D3 차트 구현** (6종): + - `BarChart.tsx`: 막대 차트 + - `LineChart.tsx`: 선 차트 + - `AreaChart.tsx`: 영역 차트 + - `PieChart.tsx`: 원/도넛 차트 + - `StackedBarChart.tsx`: 누적 막대 차트 + - `Chart.tsx`: 통합 컴포넌트 +- **Recharts 완전 제거**: D3로 완전히 대체 + +### Phase 5: 통합 ✅ + +- `CanvasElement`: 차트 렌더링 통합 완료 +- `ChartRenderer`: D3 기반으로 완전히 교체 +- `chartDataTransform.ts`: 데이터 변환 유틸리티 +- 데이터 페칭 및 자동 새로고침 diff --git a/frontend/components/admin/dashboard/ChartConfigPanel.tsx b/frontend/components/admin/dashboard/ChartConfigPanel.tsx index 67e69da8..08d7b17f 100644 --- a/frontend/components/admin/dashboard/ChartConfigPanel.tsx +++ b/frontend/components/admin/dashboard/ChartConfigPanel.tsx @@ -16,6 +16,7 @@ interface ChartConfigPanelProps { config?: ChartConfig; queryResult?: QueryResult; onConfigChange: (config: ChartConfig) => void; + chartType?: string; } /** @@ -24,9 +25,12 @@ interface ChartConfigPanelProps { * - 차트 스타일 설정 * - 실시간 미리보기 */ -export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartConfigPanelProps) { +export function ChartConfigPanel({ config, queryResult, onConfigChange, chartType }: ChartConfigPanelProps) { const [currentConfig, setCurrentConfig] = useState(config || {}); + // 원형/도넛 차트는 Y축이 필수가 아님 + const isPieChart = chartType === "pie" || chartType === "donut"; + // 설정 업데이트 const updateConfig = useCallback( (updates: Partial) => { @@ -78,7 +82,7 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC X축 (카테고리) * - updateConfig({ xAxis: value })}> @@ -96,7 +100,8 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC
@@ -154,13 +159,18 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange }: ChartC (데이터 처리 방식) updateConfig({ groupBy: value })}> + onDataSourceChange({ ...dataSource, diff --git a/frontend/components/admin/dashboard/charts/AreaChart.tsx b/frontend/components/admin/dashboard/charts/AreaChart.tsx new file mode 100644 index 00000000..9304d406 --- /dev/null +++ b/frontend/components/admin/dashboard/charts/AreaChart.tsx @@ -0,0 +1,254 @@ +"use client"; + +import React, { useEffect, useRef } from "react"; +import * as d3 from "d3"; +import { ChartConfig, ChartData } from "../types"; + +interface AreaChartProps { + data: ChartData; + config: ChartConfig; + width?: number; + height?: number; +} + +/** + * D3 영역 차트 컴포넌트 + */ +export function AreaChart({ data, config, width = 600, height = 400 }: AreaChartProps) { + 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.scalePoint().domain(data.labels).range([0, chartWidth]).padding(0.5); + + // 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"]; + + // 영역 생성기 + const areaGenerator = d3 + .area() + .x((_, i) => xScale(data.labels[i]) || 0) + .y0(chartHeight) + .y1((d) => yScale(d)); + + // 선 생성기 + const lineGenerator = d3 + .line() + .x((_, i) => xScale(data.labels[i]) || 0) + .y((d) => yScale(d)); + + // 부드러운 곡선 적용 + if (config.lineStyle === "smooth") { + areaGenerator.curve(d3.curveMonotoneX); + lineGenerator.curve(d3.curveMonotoneX); + } + + // 각 데이터셋에 대해 영역 그리기 + data.datasets.forEach((dataset, i) => { + const color = dataset.color || colors[i % colors.length]; + const opacity = config.areaOpacity !== undefined ? config.areaOpacity : 0.3; + + // 영역 그리기 + const area = g.append("path").datum(dataset.data).attr("fill", color).attr("opacity", 0).attr("d", areaGenerator); + + // 경계선 그리기 + const line = g + .append("path") + .datum(dataset.data) + .attr("fill", "none") + .attr("stroke", color) + .attr("stroke-width", 2.5) + .attr("d", lineGenerator); + + // 애니메이션 + if (config.enableAnimation !== false) { + area + .transition() + .duration(config.animationDuration || 750) + .attr("opacity", opacity); + + const totalLength = line.node()?.getTotalLength() || 0; + line + .attr("stroke-dasharray", `${totalLength} ${totalLength}`) + .attr("stroke-dashoffset", totalLength) + .transition() + .duration(config.animationDuration || 750) + .attr("stroke-dashoffset", 0); + } else { + area.attr("opacity", opacity); + } + + // 데이터 포인트 (점) 그리기 + const circles = g + .selectAll(`.circle-${i}`) + .data(dataset.data) + .enter() + .append("circle") + .attr("class", `circle-${i}`) + .attr("cx", (_, j) => xScale(data.labels[j]) || 0) + .attr("cy", (d) => yScale(d)) + .attr("r", 0) + .attr("fill", color) + .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 [x, y] = d3.pointer(event, g.node()); + const tooltip = g + .append("g") + .attr("class", "tooltip") + .attr("transform", `translate(${x},${y - 10})`); + + tooltip + .append("rect") + .attr("x", -40) + .attr("y", -30) + .attr("width", 80) + .attr("height", 25) + .attr("fill", "rgba(0,0,0,0.8)") + .attr("rx", 4); + + tooltip + .append("text") + .attr("text-anchor", "middle") + .attr("fill", "white") + .attr("font-size", "12px") + .text(`${dataset.label}: ${d}`); + }) + .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 > 1) { + const legend = svg + .append("g") + .attr("class", "legend") + .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})`); + + legendRow + .append("rect") + .attr("width", 15) + .attr("height", 15) + .attr("fill", dataset.color || colors[i % colors.length]) + .attr("opacity", config.areaOpacity !== undefined ? config.areaOpacity : 0.3) + .attr("rx", 3); + + legendRow + .append("text") + .attr("x", 20) + .attr("y", 12) + .style("font-size", "12px") + .style("fill", "#333") + .text(dataset.label); + }); + } + }, [data, config, width, height]); + + return ; +} diff --git a/frontend/components/admin/dashboard/charts/AreaChartComponent.tsx b/frontend/components/admin/dashboard/charts/AreaChartComponent.tsx deleted file mode 100644 index 56fa4787..00000000 --- a/frontend/components/admin/dashboard/charts/AreaChartComponent.tsx +++ /dev/null @@ -1,110 +0,0 @@ -'use client'; - -import React from 'react'; -import { - AreaChart, - Area, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - Legend, - ResponsiveContainer -} from 'recharts'; -import { ChartConfig } from '../types'; - -interface AreaChartComponentProps { - data: any[]; - config: ChartConfig; - width?: number; - height?: number; -} - -/** - * 영역 차트 컴포넌트 - * - Recharts AreaChart 사용 - * - 추세 파악에 적합 - * - 다중 시리즈 지원 - */ -export function AreaChartComponent({ data, config, width = 250, height = 200 }: AreaChartComponentProps) { - const { - xAxis = 'x', - yAxis = 'y', - colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B'], - title, - showLegend = true - } = config; - - // Y축 필드들 (단일 또는 다중) - const yFields = Array.isArray(yAxis) ? yAxis : [yAxis]; - const yKeys = yFields.filter(field => field && field !== 'y'); - - return ( -
- {title && ( -
- {title} -
- )} - - - - - {yKeys.map((key, index) => ( - - - - - ))} - - - - - [ - typeof value === 'number' ? value.toLocaleString() : value, - name - ]} - /> - {showLegend && yKeys.length > 1 && ( - - )} - - {yKeys.map((key, index) => ( - - ))} - - -
- ); -} diff --git a/frontend/components/admin/dashboard/charts/BarChart.tsx b/frontend/components/admin/dashboard/charts/BarChart.tsx new file mode 100644 index 00000000..fd401ec3 --- /dev/null +++ b/frontend/components/admin/dashboard/charts/BarChart.tsx @@ -0,0 +1,208 @@ +"use client"; + +import React, { useEffect, useRef } from "react"; +import * as d3 from "d3"; +import { ChartConfig, ChartData } from "../types"; + +interface BarChartProps { + data: ChartData; + config: ChartConfig; + width?: number; + height?: number; +} + +/** + * D3 막대 차트 컴포넌트 + */ +export function BarChart({ data, config, width = 600, height = 400 }: BarChartProps) { + 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"]; + + // 막대 그리기 + const barWidth = xScale.bandwidth() / data.datasets.length; + + data.datasets.forEach((dataset, i) => { + const bars = g + .selectAll(`.bar-${i}`) + .data(dataset.data) + .enter() + .append("rect") + .attr("class", `bar-${i}`) + .attr("x", (_, j) => (xScale(data.labels[j]) || 0) + barWidth * i) + .attr("y", chartHeight) + .attr("width", barWidth) + .attr("height", 0) + .attr("fill", dataset.color || colors[i % colors.length]) + .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 = `${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("opacity", 1); + 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 > 1) { + const legend = svg + .append("g") + .attr("class", "legend") + .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})`); + + legendRow + .append("rect") + .attr("width", 15) + .attr("height", 15) + .attr("fill", dataset.color || colors[i % colors.length]) + .attr("rx", 3); + + legendRow + .append("text") + .attr("x", 20) + .attr("y", 12) + .style("font-size", "12px") + .style("fill", "#333") + .text(dataset.label); + }); + } + }, [data, config, width, height]); + + return ; +} diff --git a/frontend/components/admin/dashboard/charts/BarChartComponent.tsx b/frontend/components/admin/dashboard/charts/BarChartComponent.tsx deleted file mode 100644 index d03777d6..00000000 --- a/frontend/components/admin/dashboard/charts/BarChartComponent.tsx +++ /dev/null @@ -1,87 +0,0 @@ -'use client'; - -import React from 'react'; -import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; - -interface BarChartComponentProps { - data: any[]; - config: any; - width?: number; - height?: number; -} - -/** - * 바 차트 컴포넌트 (Recharts SimpleBarChart 기반) - * - 실제 데이터를 받아서 단순하게 표시 - * - 복잡한 변환 로직 없음 - */ -export function BarChartComponent({ data, config, width = 600, height = 300 }: BarChartComponentProps) { - // console.log('🎨 BarChartComponent - 전체 데이터:', { - // dataLength: data?.length, - // fullData: data, - // dataType: typeof data, - // isArray: Array.isArray(data), - // config, - // xAxisField: config?.xAxis, - // yAxisFields: config?.yAxis - // }); - - // 데이터가 없으면 메시지 표시 - if (!data || data.length === 0) { - return ( -
-
-
📊
-
데이터가 없습니다
-
-
- ); - } - - // 데이터의 첫 번째 아이템에서 사용 가능한 키 확인 - const firstItem = data[0]; - const availableKeys = Object.keys(firstItem); - // console.log('📊 사용 가능한 데이터 키:', availableKeys); - // console.log('📊 첫 번째 데이터 아이템:', firstItem); - - // Y축 필드 추출 (배열이면 모두 사용, 아니면 단일 값) - const yFields = Array.isArray(config.yAxis) ? config.yAxis : [config.yAxis]; - - // 색상 배열 - const colors = ['#8884d8', '#82ca9d', '#ffc658', '#ff7c7c', '#8dd1e1']; - - // 한글 레이블 매핑 - const labelMapping: Record = { - 'total_users': '전체 사용자', - 'active_users': '활성 사용자', - 'name': '부서' - }; - - return ( - - - - - - - {config.showLegend !== false && } - - {/* Y축 필드마다 Bar 생성 */} - {yFields.map((field: string, index: number) => ( - - ))} - - - ); -} diff --git a/frontend/components/admin/dashboard/charts/Chart.tsx b/frontend/components/admin/dashboard/charts/Chart.tsx new file mode 100644 index 00000000..012f3c77 --- /dev/null +++ b/frontend/components/admin/dashboard/charts/Chart.tsx @@ -0,0 +1,78 @@ +"use client"; + +import React from "react"; +import { BarChart } from "./BarChart"; +import { LineChart } from "./LineChart"; +import { AreaChart } from "./AreaChart"; +import { PieChart } from "./PieChart"; +import { StackedBarChart } from "./StackedBarChart"; +import { ChartConfig, ChartData, ElementSubtype } from "../types"; + +interface ChartProps { + chartType: ElementSubtype; + data: ChartData; + config: ChartConfig; + width?: number; + height?: number; +} + +/** + * 통합 차트 컴포넌트 + * - 차트 타입에 따라 적절한 D3 차트 컴포넌트를 렌더링 + */ +export function Chart({ chartType, data, config, width, height }: ChartProps) { + // 데이터가 없으면 placeholder 표시 + if (!data || !data.labels.length || !data.datasets.length) { + return ( +
+
+
📊
+
데이터를 설정하세요
+
차트 설정에서 데이터 소스와 축을 설정하세요
+
+
+ ); + } + + // 차트 타입에 따라 렌더링 + switch (chartType) { + case "bar": + return ; + + case "line": + return ; + + case "area": + return ; + + case "pie": + return ; + + case "donut": + return ; + + case "stacked-bar": + return ; + + case "combo": + // Combo 차트는 일단 Bar + Line 조합으로 표시 (추후 구현 가능) + return ; + + default: + return ( +
+
+
+
지원하지 않는 차트 타입
+
{chartType}
+
+
+ ); + } +} diff --git a/frontend/components/admin/dashboard/charts/ChartRenderer.tsx b/frontend/components/admin/dashboard/charts/ChartRenderer.tsx index a69a6922..8ab0d9af 100644 --- a/frontend/components/admin/dashboard/charts/ChartRenderer.tsx +++ b/frontend/components/admin/dashboard/charts/ChartRenderer.tsx @@ -1,14 +1,11 @@ -'use client'; +"use client"; -import React from 'react'; -import { DashboardElement, QueryResult } from '../types'; -import { BarChartComponent } from './BarChartComponent'; -import { PieChartComponent } from './PieChartComponent'; -import { LineChartComponent } from './LineChartComponent'; -import { AreaChartComponent } from './AreaChartComponent'; -import { StackedBarChartComponent } from './StackedBarChartComponent'; -import { DonutChartComponent } from './DonutChartComponent'; -import { ComboChartComponent } from './ComboChartComponent'; +import React, { useEffect, useState } from "react"; +import { DashboardElement, QueryResult, ChartData } from "../types"; +import { Chart } from "./Chart"; +import { transformQueryResultToChartData } from "../utils/chartDataTransform"; +import { ExternalDbConnectionAPI } from "@/lib/api/externalDbConnection"; +import { dashboardApi } from "@/lib/api/dashboard"; interface ChartRendererProps { element: DashboardElement; @@ -18,85 +15,140 @@ interface ChartRendererProps { } /** - * 차트 렌더러 컴포넌트 (단순 버전) - * - 데이터를 받아서 차트에 그대로 전달 - * - 복잡한 변환 로직 제거 + * 차트 렌더러 컴포넌트 (D3 기반) + * - 데이터 소스에서 데이터 페칭 + * - QueryResult를 ChartData로 변환 + * - D3 Chart 컴포넌트에 전달 */ export function ChartRenderer({ element, data, width = 250, height = 200 }: ChartRendererProps) { - // console.log('🎬 ChartRenderer:', { - // elementId: element.id, - // hasData: !!data, - // dataRows: data?.rows?.length, - // xAxis: element.chartConfig?.xAxis, - // yAxis: element.chartConfig?.yAxis - // }); + const [chartData, setChartData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); - // 데이터나 설정이 없으면 메시지 표시 - if (!data || !element.chartConfig?.xAxis || !element.chartConfig?.yAxis) { + // 데이터 페칭 + useEffect(() => { + const fetchData = async () => { + // 이미 data가 전달된 경우 사용 + if (data) { + const transformed = transformQueryResultToChartData(data, element.chartConfig || {}); + setChartData(transformed); + return; + } + + // 데이터 소스가 설정되어 있으면 페칭 + if (element.dataSource && element.dataSource.query && element.chartConfig) { + setIsLoading(true); + setError(null); + + try { + let queryResult: QueryResult; + + // 현재 DB vs 외부 DB 분기 + if (element.dataSource.connectionType === "external" && element.dataSource.externalConnectionId) { + // 외부 DB + const result = await ExternalDbConnectionAPI.executeQuery( + parseInt(element.dataSource.externalConnectionId), + element.dataSource.query, + ); + + if (!result.success) { + throw new Error(result.message || "외부 DB 쿼리 실행 실패"); + } + + queryResult = { + columns: result.data?.[0] ? Object.keys(result.data[0]) : [], + rows: result.data || [], + totalRows: result.data?.length || 0, + executionTime: 0, + }; + } else { + // 현재 DB + const result = await dashboardApi.executeQuery(element.dataSource.query); + queryResult = { + columns: result.columns, + rows: result.rows, + totalRows: result.rowCount, + executionTime: 0, + }; + } + + // ChartData로 변환 + const transformed = transformQueryResultToChartData(queryResult, element.chartConfig); + setChartData(transformed); + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "데이터 로딩 실패"; + setError(errorMessage); + } finally { + setIsLoading(false); + } + } + }; + + fetchData(); + + // 자동 새로고침 설정 (0이면 수동이므로 interval 설정 안 함) + const refreshInterval = element.dataSource?.refreshInterval; + if (refreshInterval && refreshInterval > 0) { + const interval = setInterval(fetchData, refreshInterval); + return () => clearInterval(interval); + } + }, [ + element.dataSource?.query, + element.dataSource?.connectionType, + element.dataSource?.externalConnectionId, + element.dataSource?.refreshInterval, + element.chartConfig, + data, + ]); + + // 로딩 중 + if (isLoading) { return ( -
+
-
📊
-
데이터를 설정해주세요
-
⚙️ 버튼을 클릭하여 설정
+
+
데이터 로딩 중...
); } - // 데이터가 비어있으면 - if (!data.rows || data.rows.length === 0) { + // 에러 + if (error) { return ( -
+
-
⚠️
-
데이터가 없습니다
+
⚠️
+
오류 발생
+
{error}
); } - // 데이터를 그대로 전달 (변환 없음!) - const chartData = data.rows; - - // console.log('📊 Chart Data:', { - // dataLength: chartData.length, - // firstRow: chartData[0], - // columns: Object.keys(chartData[0] || {}) - // }); - - // 차트 공통 props - const chartProps = { - data: chartData, - config: element.chartConfig, - width: width - 20, - height: height - 60, - }; - - // 차트 타입에 따른 렌더링 - switch (element.subtype) { - case 'bar': - return ; - case 'pie': - return ; - case 'line': - return ; - case 'area': - return ; - case 'stacked-bar': - return ; - case 'donut': - return ; - case 'combo': - return ; - default: - return ( -
-
-
-
지원하지 않는 차트 타입
-
+ // 데이터나 설정이 없으면 + if (!chartData || !element.chartConfig?.xAxis || !element.chartConfig?.yAxis) { + return ( +
+
+
📊
+
데이터를 설정해주세요
+
⚙️ 버튼을 클릭하여 설정
- ); +
+ ); } + + // D3 차트 렌더링 + return ( +
+ +
+ ); } diff --git a/frontend/components/admin/dashboard/charts/DonutChartComponent.tsx b/frontend/components/admin/dashboard/charts/DonutChartComponent.tsx deleted file mode 100644 index 41849b0e..00000000 --- a/frontend/components/admin/dashboard/charts/DonutChartComponent.tsx +++ /dev/null @@ -1,109 +0,0 @@ -'use client'; - -import React from 'react'; -import { - PieChart, - Pie, - Cell, - Tooltip, - Legend, - ResponsiveContainer -} from 'recharts'; -import { ChartConfig } from '../types'; - -interface DonutChartComponentProps { - data: any[]; - config: ChartConfig; - width?: number; - height?: number; -} - -/** - * 도넛 차트 컴포넌트 - * - Recharts PieChart (innerRadius 사용) 사용 - * - 비율 표시에 적합 (중앙 공간 활용 가능) - */ -export function DonutChartComponent({ data, config, width = 250, height = 200 }: DonutChartComponentProps) { - const { - xAxis = 'x', - yAxis = 'y', - colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899'], - title, - showLegend = true - } = config; - - // 파이 차트용 데이터 변환 - const pieData = data.map(item => ({ - name: String(item[xAxis] || ''), - value: typeof item[yAxis as string] === 'number' ? item[yAxis as string] : 0 - })); - - // 총합 계산 - const total = pieData.reduce((sum, item) => sum + item.value, 0); - - // 커스텀 라벨 (퍼센트 표시) - const renderLabel = (entry: any) => { - const percent = ((entry.value / total) * 100).toFixed(1); - return `${percent}%`; - }; - - return ( -
- {title && ( -
- {title} -
- )} - - - - - {pieData.map((entry, index) => ( - - ))} - - [ - typeof value === 'number' ? value.toLocaleString() : value, - '값' - ]} - /> - {showLegend && ( - - )} - - - - {/* 중앙 총합 표시 */} -
-
-
Total
-
- {total.toLocaleString()} -
-
-
-
- ); -} diff --git a/frontend/components/admin/dashboard/charts/LineChart.tsx b/frontend/components/admin/dashboard/charts/LineChart.tsx new file mode 100644 index 00000000..db4292a8 --- /dev/null +++ b/frontend/components/admin/dashboard/charts/LineChart.tsx @@ -0,0 +1,251 @@ +"use client"; + +import React, { useEffect, useRef } from "react"; +import * as d3 from "d3"; +import { ChartConfig, ChartData } from "../types"; + +interface LineChartProps { + data: ChartData; + config: ChartConfig; + width?: number; + height?: number; +} + +/** + * D3 선 차트 컴포넌트 + */ +export function LineChart({ data, config, width = 600, height = 400 }: LineChartProps) { + 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.scalePoint().domain(data.labels).range([0, chartWidth]).padding(0.5); + + // 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"]; + + // 선 생성기 + const lineGenerator = d3 + .line() + .x((_, i) => xScale(data.labels[i]) || 0) + .y((d) => yScale(d)); + + // 부드러운 곡선 적용 + if (config.lineStyle === "smooth") { + lineGenerator.curve(d3.curveMonotoneX); + } + + // 각 데이터셋에 대해 선 그리기 + data.datasets.forEach((dataset, i) => { + const color = dataset.color || colors[i % colors.length]; + + // 선 그리기 + const path = g + .append("path") + .datum(dataset.data) + .attr("fill", "none") + .attr("stroke", color) + .attr("stroke-width", 2.5) + .attr("d", lineGenerator); + + // 애니메이션 + 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(`.circle-${i}`) + .data(dataset.data) + .enter() + .append("circle") + .attr("class", `circle-${i}`) + .attr("cx", (_, j) => xScale(data.labels[j]) || 0) + .attr("cy", (d) => yScale(d)) + .attr("r", 0) + .attr("fill", color) + .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 > 1) { + const legend = svg + .append("g") + .attr("class", "legend") + .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})`); + + 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", 3); + + legendRow + .append("circle") + .attr("cx", 7.5) + .attr("cy", 7) + .attr("r", 4) + .attr("fill", dataset.color || colors[i % colors.length]) + .attr("stroke", "white") + .attr("stroke-width", 2); + + legendRow + .append("text") + .attr("x", 20) + .attr("y", 12) + .style("font-size", "12px") + .style("fill", "#333") + .text(dataset.label); + }); + } + }, [data, config, width, height]); + + return ; +} diff --git a/frontend/components/admin/dashboard/charts/LineChartComponent.tsx b/frontend/components/admin/dashboard/charts/LineChartComponent.tsx deleted file mode 100644 index f4609802..00000000 --- a/frontend/components/admin/dashboard/charts/LineChartComponent.tsx +++ /dev/null @@ -1,104 +0,0 @@ -'use client'; - -import React from 'react'; -import { - LineChart, - Line, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - Legend, - ResponsiveContainer -} from 'recharts'; -import { ChartConfig } from '../types'; - -interface LineChartComponentProps { - data: any[]; - config: ChartConfig; - width?: number; - height?: number; -} - -/** - * 꺾은선 차트 컴포넌트 - * - Recharts LineChart 사용 - * - 다중 라인 지원 - */ -export function LineChartComponent({ data, config, width = 250, height = 200 }: LineChartComponentProps) { - const { - xAxis = 'x', - yAxis = 'y', - colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B'], - title, - showLegend = true - } = config; - - // Y축 필드들 (단일 또는 다중) - const yFields = Array.isArray(yAxis) ? yAxis : [yAxis]; - - // 사용할 Y축 키들 결정 - const yKeys = yFields.filter(field => field && field !== 'y'); - - return ( -
- {title && ( -
- {title} -
- )} - - - - - - - [ - typeof value === 'number' ? value.toLocaleString() : value, - name - ]} - /> - {showLegend && yKeys.length > 1 && ( - - )} - - {yKeys.map((key, index) => ( - - ))} - - -
- ); -} diff --git a/frontend/components/admin/dashboard/charts/PieChart.tsx b/frontend/components/admin/dashboard/charts/PieChart.tsx new file mode 100644 index 00000000..f9ab4810 --- /dev/null +++ b/frontend/components/admin/dashboard/charts/PieChart.tsx @@ -0,0 +1,187 @@ +"use client"; + +import React, { useEffect, useRef } from "react"; +import * as d3 from "d3"; +import { ChartConfig, ChartData } from "../types"; + +interface PieChartProps { + data: ChartData; + config: ChartConfig; + width?: number; + height?: number; + isDonut?: boolean; +} + +/** + * D3 원형 차트 / 도넛 차트 컴포넌트 + */ +export function PieChart({ data, config, width = 500, height = 500, isDonut = false }: PieChartProps) { + 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: 120, bottom: 40, left: 120 }; + const chartWidth = width - margin.left - margin.right; + const chartHeight = height - margin.top - margin.bottom; + const radius = Math.min(chartWidth, chartHeight) / 2; + + const g = svg.append("g").attr("transform", `translate(${width / 2},${height / 2})`); + + // 색상 팔레트 + const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B", "#8B5CF6", "#EC4899"]; + + // 첫 번째 데이터셋 사용 + const dataset = data.datasets[0]; + const pieData = data.labels.map((label, i) => ({ + label, + value: dataset.data[i], + })); + + // 파이 생성기 + const pie = d3 + .pie<{ label: string; value: number }>() + .value((d) => d.value) + .sort(null); + + // 아크 생성기 + const innerRadius = isDonut ? radius * (config.pieInnerRadius || 0.5) : 0; + const arc = d3.arc>().innerRadius(innerRadius).outerRadius(radius); + + // 툴팁용 확대 아크 + const arcHover = d3 + .arc>() + .innerRadius(innerRadius) + .outerRadius(radius + 10); + + // 파이 조각 그리기 + const arcs = g.selectAll(".arc").data(pie(pieData)).enter().append("g").attr("class", "arc"); + + const paths = arcs + .append("path") + .attr("fill", (d, i) => colors[i % colors.length]) + .attr("stroke", "white") + .attr("stroke-width", 2); + + // 애니메이션 + if (config.enableAnimation !== false) { + paths + .transition() + .duration(config.animationDuration || 750) + .attrTween("d", function (d) { + const interpolate = d3.interpolate({ startAngle: 0, endAngle: 0 }, d); + return function (t) { + return arc(interpolate(t)) || ""; + }; + }); + } else { + paths.attr("d", arc); + } + + // 툴팁 + if (config.showTooltip !== false) { + paths + .on("mouseover", function (event, d) { + d3.select(this).transition().duration(200).attr("d", arcHover); + + const tooltip = g + .append("g") + .attr("class", "tooltip") + .attr("transform", `translate(${arc.centroid(d)[0]},${arc.centroid(d)[1]})`); + + tooltip + .append("rect") + .attr("x", -50) + .attr("y", -40) + .attr("width", 100) + .attr("height", 35) + .attr("fill", "rgba(0,0,0,0.8)") + .attr("rx", 4); + + tooltip + .append("text") + .attr("text-anchor", "middle") + .attr("fill", "white") + .attr("font-size", "12px") + .attr("y", -25) + .text(d.data.label); + + tooltip + .append("text") + .attr("text-anchor", "middle") + .attr("fill", "white") + .attr("font-size", "14px") + .attr("font-weight", "bold") + .attr("y", -10) + .text(`${d.data.value} (${((d.data.value / d3.sum(dataset.data)) * 100).toFixed(1)}%)`); + }) + .on("mouseout", function (event, d) { + d3.select(this).transition().duration(200).attr("d", arc); + 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); + } + + // 범례 + if (config.showLegend !== false) { + const legend = svg + .append("g") + .attr("class", "legend") + .attr("transform", `translate(${width - margin.right + 10}, ${margin.top})`); + + pieData.forEach((d, i) => { + const legendRow = legend.append("g").attr("transform", `translate(0, ${i * 25})`); + + legendRow + .append("rect") + .attr("width", 15) + .attr("height", 15) + .attr("fill", colors[i % colors.length]) + .attr("rx", 3); + + legendRow + .append("text") + .attr("x", 20) + .attr("y", 12) + .style("font-size", "12px") + .style("fill", "#333") + .text(`${d.label} (${d.value})`); + }); + } + + // 도넛 차트 중앙 텍스트 + if (isDonut) { + const total = d3.sum(dataset.data); + g.append("text") + .attr("text-anchor", "middle") + .attr("y", -10) + .style("font-size", "24px") + .style("font-weight", "bold") + .style("fill", "#333") + .text(total.toLocaleString()); + + g.append("text") + .attr("text-anchor", "middle") + .attr("y", 15) + .style("font-size", "14px") + .style("fill", "#666") + .text("Total"); + } + }, [data, config, width, height, isDonut]); + + return ; +} diff --git a/frontend/components/admin/dashboard/charts/PieChartComponent.tsx b/frontend/components/admin/dashboard/charts/PieChartComponent.tsx deleted file mode 100644 index ef9e363b..00000000 --- a/frontend/components/admin/dashboard/charts/PieChartComponent.tsx +++ /dev/null @@ -1,96 +0,0 @@ -'use client'; - -import React from 'react'; -import { - PieChart, - Pie, - Cell, - Tooltip, - Legend, - ResponsiveContainer -} from 'recharts'; -import { ChartConfig } from '../types'; - -interface PieChartComponentProps { - data: any[]; - config: ChartConfig; - width?: number; - height?: number; -} - -/** - * 원형 차트 컴포넌트 - * - Recharts PieChart 사용 - * - 자동 색상 배치 및 레이블 - */ -export function PieChartComponent({ data, config, width = 250, height = 200 }: PieChartComponentProps) { - const { - xAxis = 'x', - yAxis = 'y', - colors = ['#3B82F6', '#EF4444', '#10B981', '#F59E0B', '#8B5CF6', '#EC4899', '#06B6D4', '#84CC16'], - title, - showLegend = true - } = config; - - // 파이 차트용 데이터 변환 - const pieData = data.map((item, index) => ({ - name: String(item[xAxis] || `항목 ${index + 1}`), - value: Number(item[yAxis]) || 0, - color: colors[index % colors.length] - })).filter(item => item.value > 0); // 0보다 큰 값만 표시 - - // 커스텀 레이블 함수 - const renderLabel = (entry: any) => { - const percent = ((entry.value / pieData.reduce((sum, item) => sum + item.value, 0)) * 100).toFixed(1); - return `${percent}%`; - }; - - return ( -
- {title && ( -
- {title} -
- )} - - - - - {pieData.map((entry, index) => ( - - ))} - - - [ - typeof value === 'number' ? value.toLocaleString() : value, - name - ]} - /> - - {showLegend && ( - - )} - - -
- ); -} diff --git a/frontend/components/admin/dashboard/charts/StackedBarChart.tsx b/frontend/components/admin/dashboard/charts/StackedBarChart.tsx new file mode 100644 index 00000000..bf1d8be7 --- /dev/null +++ b/frontend/components/admin/dashboard/charts/StackedBarChart.tsx @@ -0,0 +1,244 @@ +"use client"; + +import React, { useEffect, useRef } from "react"; +import * as d3 from "d3"; +import { ChartConfig, ChartData } from "../types"; + +interface StackedBarChartProps { + data: ChartData; + config: ChartConfig; + width?: number; + height?: number; +} + +/** + * D3 누적 막대 차트 컴포넌트 + */ +export function StackedBarChart({ data, config, width = 600, height = 400 }: StackedBarChartProps) { + 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})`); + + // 데이터 변환 (스택 데이터 생성) + const stackData = data.labels.map((label, i) => { + const obj: any = { label }; + data.datasets.forEach((dataset, j) => { + obj[`series${j}`] = dataset.data[i]; + }); + return obj; + }); + + const series = data.datasets.map((_, i) => `series${i}`); + + // 스택 레이아웃 + const stack = d3.stack().keys(series); + const stackedData = stack(stackData as any); + + // X축 스케일 + const xScale = d3.scaleBand().domain(data.labels).range([0, chartWidth]).padding(0.3); + + // Y축 스케일 + const maxValue = + config.stackMode === "percent" ? 100 : d3.max(stackedData[stackedData.length - 1], (d) => d[1] as number) || 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축 그리기 + const yAxis = config.stackMode === "percent" ? d3.axisLeft(yScale).tickFormat((d) => `${d}%`) : d3.axisLeft(yScale); + g.append("g").call(yAxis).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 (config.stackMode === "percent") { + stackData.forEach((label) => { + const total = d3.sum(series.map((s) => (label as any)[s])); + series.forEach((s) => { + (label as any)[s] = total > 0 ? ((label as any)[s] / total) * 100 : 0; + }); + }); + } + + // 누적 막대 그리기 + const layers = g + .selectAll(".layer") + .data(stackedData) + .enter() + .append("g") + .attr("class", "layer") + .attr("fill", (_, i) => data.datasets[i].color || colors[i % colors.length]); + + const bars = layers + .selectAll("rect") + .data((d) => d) + .enter() + .append("rect") + .attr("x", (d) => xScale((d.data as any).label) || 0) + .attr("y", chartHeight) + .attr("width", xScale.bandwidth()) + .attr("height", 0) + .attr("rx", 4); + + // 애니메이션 + if (config.enableAnimation !== false) { + bars + .transition() + .duration(config.animationDuration || 750) + .attr("y", (d) => yScale(d[1] as number)) + .attr("height", (d) => yScale(d[0] as number) - yScale(d[1] as number)); + } else { + bars + .attr("y", (d) => yScale(d[1] as number)) + .attr("height", (d) => yScale(d[0] as number) - yScale(d[1] as number)); + } + + // 툴팁 + if (config.showTooltip !== false) { + bars + .on("mouseover", function (event, d) { + d3.select(this).attr("opacity", 0.7); + + const seriesIndex = stackedData.findIndex((s) => s.includes(d as any)); + const value = (d[1] as number) - (d[0] as number); + const label = data.datasets[seriesIndex].label; + + const [mouseX, mouseY] = d3.pointer(event, g.node()); + const tooltipText = `${label}: ${value.toFixed(config.stackMode === "percent" ? 1 : 0)}${config.stackMode === "percent" ? "%" : ""}`; + + 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(); + }); + } + + // 차트 제목 + 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) { + const legend = svg + .append("g") + .attr("class", "legend") + .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})`); + + legendRow + .append("rect") + .attr("width", 15) + .attr("height", 15) + .attr("fill", dataset.color || colors[i % colors.length]) + .attr("rx", 3); + + legendRow + .append("text") + .attr("x", 20) + .attr("y", 12) + .style("font-size", "12px") + .style("fill", "#333") + .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 fb9af3ed..7eb547f9 100644 --- a/frontend/components/admin/dashboard/charts/index.ts +++ b/frontend/components/admin/dashboard/charts/index.ts @@ -1,12 +1,6 @@ -/** - * 차트 컴포넌트 인덱스 - */ - -export { ChartRenderer } from './ChartRenderer'; -export { BarChartComponent } from './BarChartComponent'; -export { PieChartComponent } from './PieChartComponent'; -export { LineChartComponent } from './LineChartComponent'; -export { AreaChartComponent } from './AreaChartComponent'; -export { StackedBarChartComponent } from './StackedBarChartComponent'; -export { DonutChartComponent } from './DonutChartComponent'; -export { ComboChartComponent } from './ComboChartComponent'; +export { Chart } from "./Chart"; +export { BarChart } from "./BarChart"; +export { LineChart } from "./LineChart"; +export { AreaChart } from "./AreaChart"; +export { PieChart } from "./PieChart"; +export { StackedBarChart } from "./StackedBarChart"; diff --git a/frontend/components/admin/dashboard/data-sources/DatabaseConfig.tsx b/frontend/components/admin/dashboard/data-sources/DatabaseConfig.tsx index 966af684..31e6ff7d 100644 --- a/frontend/components/admin/dashboard/data-sources/DatabaseConfig.tsx +++ b/frontend/components/admin/dashboard/data-sources/DatabaseConfig.tsx @@ -142,7 +142,7 @@ export function DatabaseConfig({ dataSource, onChange }: DatabaseConfigProps) { {!loading && !error && connections.length > 0 && ( <>