From c896b2182c5154ebe8cbc4f1a94e18f0cb4927bd Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 14 Oct 2025 16:49:57 +0900 Subject: [PATCH] =?UTF-8?q?=EC=88=98=ED=8F=89=20=EB=B0=94=20=EC=B0=A8?= =?UTF-8?q?=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/ChartConfigPanel.tsx | 13 -- .../admin/dashboard/DashboardDesigner.tsx | 4 + .../admin/dashboard/DashboardSidebar.tsx | 8 + .../admin/dashboard/QueryEditor.tsx | 14 -- .../admin/dashboard/charts/Chart.tsx | 4 + .../dashboard/charts/HorizontalBarChart.tsx | 201 ++++++++++++++++++ .../admin/dashboard/charts/index.ts | 1 + frontend/components/admin/dashboard/types.ts | 1 + 8 files changed, 219 insertions(+), 27 deletions(-) create mode 100644 frontend/components/admin/dashboard/charts/HorizontalBarChart.tsx diff --git a/frontend/components/admin/dashboard/ChartConfigPanel.tsx b/frontend/components/admin/dashboard/ChartConfigPanel.tsx index 08d7b17f..135a4855 100644 --- a/frontend/components/admin/dashboard/ChartConfigPanel.tsx +++ b/frontend/components/admin/dashboard/ChartConfigPanel.tsx @@ -47,19 +47,6 @@ export function ChartConfigPanel({ config, queryResult, onConfigChange, chartTyp return (
-
- -

차트 설정

-
- - {/* 쿼리 결과가 없을 때 */} - {!queryResult && ( - - - 먼저 SQL 쿼리를 실행하여 데이터를 가져온 후 차트를 설정할 수 있습니다. - - )} - {/* 데이터 필드 매핑 */} {queryResult && ( <> diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 6033fc7c..17c1d6b0 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -290,6 +290,8 @@ function getElementTitle(type: ElementType, subtype: ElementSubtype): string { switch (subtype) { case "bar": return "📊 바 차트"; + case "horizontal-bar": + return "📊 수평 바 차트"; case "pie": return "🥧 원형 차트"; case "line": @@ -326,6 +328,8 @@ function getElementContent(type: ElementType, subtype: ElementSubtype): string { switch (subtype) { case "bar": return "바 차트가 여기에 표시됩니다"; + case "horizontal-bar": + return "수평 바 차트가 여기에 표시됩니다"; case "pie": return "원형 차트가 여기에 표시됩니다"; case "line": diff --git a/frontend/components/admin/dashboard/DashboardSidebar.tsx b/frontend/components/admin/dashboard/DashboardSidebar.tsx index 1ed5ae53..de1f4f52 100644 --- a/frontend/components/admin/dashboard/DashboardSidebar.tsx +++ b/frontend/components/admin/dashboard/DashboardSidebar.tsx @@ -31,6 +31,14 @@ export function DashboardSidebar() { onDragStart={handleDragStart} className="border-primary border-l-4" /> + -
Ctrl+Enter로 실행
@@ -318,19 +317,6 @@ ORDER BY Q4 DESC;`, ); - - // Ctrl+Enter로 쿼리 실행 - React.useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.ctrlKey && e.key === "Enter") { - e.preventDefault(); - executeQuery(); - } - }; - - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [executeQuery]); } /** diff --git a/frontend/components/admin/dashboard/charts/Chart.tsx b/frontend/components/admin/dashboard/charts/Chart.tsx index 2ff4de59..8bd810fd 100644 --- a/frontend/components/admin/dashboard/charts/Chart.tsx +++ b/frontend/components/admin/dashboard/charts/Chart.tsx @@ -2,6 +2,7 @@ import React from "react"; import { BarChart } from "./BarChart"; +import { HorizontalBarChart } from "./HorizontalBarChart"; import { LineChart } from "./LineChart"; import { AreaChart } from "./AreaChart"; import { PieChart } from "./PieChart"; @@ -43,6 +44,9 @@ export function Chart({ chartType, data, config, width, height }: ChartProps) { case "bar": return ; + case "horizontal-bar": + return ; + case "line": return ; diff --git a/frontend/components/admin/dashboard/charts/HorizontalBarChart.tsx b/frontend/components/admin/dashboard/charts/HorizontalBarChart.tsx new file mode 100644 index 00000000..d5785bf8 --- /dev/null +++ b/frontend/components/admin/dashboard/charts/HorizontalBarChart.tsx @@ -0,0 +1,201 @@ +"use client"; + +import React, { useEffect, useRef } from "react"; +import * as d3 from "d3"; +import { ChartConfig, ChartData } from "../types"; + +interface HorizontalBarChartProps { + data: ChartData; + config: ChartConfig; + width?: number; + height?: number; +} + +/** + * D3 수평 막대 차트 컴포넌트 + */ +export function HorizontalBarChart({ data, config, width = 600, height = 400 }: HorizontalBarChartProps) { + 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: 120 }; + 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})`); + + // Y축 스케일 (카테고리) - 수평이므로 Y축이 카테고리 + const yScale = d3.scaleBand().domain(data.labels).range([0, chartHeight]).padding(0.2); + + // X축 스케일 (값) - 수평이므로 X축이 값 + const maxValue = d3.max(data.datasets.flatMap((ds) => ds.data)) || 0; + const xScale = d3 + .scaleLinear() + .domain([0, maxValue * 1.1]) + .range([0, chartWidth]) + .nice(); + + // Y축 그리기 (카테고리) + g.append("g").call(d3.axisLeft(yScale)).style("font-size", "12px").selectAll("text").style("text-anchor", "end"); + + // X축 그리기 (값) + g.append("g") + .attr("transform", `translate(0,${chartHeight})`) + .call(d3.axisBottom(xScale)) + .style("font-size", "12px"); + + // 그리드 라인 + if (config.showGrid !== false) { + g.append("g") + .attr("class", "grid") + .call( + d3 + .axisBottom(xScale) + .tickSize(chartHeight) + .tickFormat(() => ""), + ) + .style("stroke-dasharray", "3,3") + .style("stroke", "#e0e0e0") + .style("opacity", 0.5); + } + + // 색상 팔레트 + const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"]; + + // 막대 그리기 + const barHeight = yScale.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", 0) + .attr("y", (_, j) => (yScale(data.labels[j]) || 0) + barHeight * i) + .attr("width", 0) + .attr("height", barHeight) + .attr("fill", dataset.color || colors[i % colors.length]) + .attr("ry", 4); + + // 애니메이션 + if (config.enableAnimation !== false) { + bars + .transition() + .duration(config.animationDuration || 750) + .attr("width", (d) => xScale(d)); + } else { + bars.attr("width", (d) => xScale(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("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", "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 f6b98ce1..c194b7cd 100644 --- a/frontend/components/admin/dashboard/charts/index.ts +++ b/frontend/components/admin/dashboard/charts/index.ts @@ -1,5 +1,6 @@ export { Chart } from "./Chart"; export { BarChart } from "./BarChart"; +export { HorizontalBarChart } from "./HorizontalBarChart"; export { LineChart } from "./LineChart"; export { AreaChart } from "./AreaChart"; export { PieChart } from "./PieChart"; diff --git a/frontend/components/admin/dashboard/types.ts b/frontend/components/admin/dashboard/types.ts index d9ca7029..2a7719dd 100644 --- a/frontend/components/admin/dashboard/types.ts +++ b/frontend/components/admin/dashboard/types.ts @@ -6,6 +6,7 @@ export type ElementType = "chart" | "widget"; export type ElementSubtype = | "bar" + | "horizontal-bar" | "pie" | "line" | "area"