diff --git a/frontend/components/admin/dashboard/charts/ChartRenderer.tsx b/frontend/components/admin/dashboard/charts/ChartRenderer.tsx index 9a5a51a6..88e90cae 100644 --- a/frontend/components/admin/dashboard/charts/ChartRenderer.tsx +++ b/frontend/components/admin/dashboard/charts/ChartRenderer.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; import { DashboardElement, QueryResult, ChartData } from "../types"; import { Chart } from "./Chart"; import { transformQueryResultToChartData } from "../utils/chartDataTransform"; @@ -21,11 +21,39 @@ interface ChartRendererProps { * - QueryResult를 ChartData로 변환 * - D3 Chart 컴포넌트에 전달 */ -export function ChartRenderer({ element, data, width = 250, height = 200 }: ChartRendererProps) { +export function ChartRenderer({ element, data, width, height = 200 }: ChartRendererProps) { + const containerRef = useRef(null); + const [containerWidth, setContainerWidth] = useState(width || 250); const [chartData, setChartData] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); + // 컨테이너 너비 측정 (width가 undefined일 때) + useEffect(() => { + if (width !== undefined) { + setContainerWidth(width); + return; + } + + const updateWidth = () => { + if (containerRef.current) { + const measuredWidth = containerRef.current.offsetWidth; + console.log("📏 컨테이너 너비 측정:", measuredWidth); + setContainerWidth(measuredWidth || 500); // 기본값 500 + } + }; + + // 약간의 지연을 두고 측정 (DOM 렌더링 완료 후) + const timer = setTimeout(updateWidth, 100); + updateWidth(); + + window.addEventListener("resize", updateWidth); + return () => { + clearTimeout(timer); + window.removeEventListener("resize", updateWidth); + }; + }, [width]); + // 데이터 페칭 useEffect(() => { const fetchData = async () => { @@ -212,15 +240,38 @@ export function ChartRenderer({ element, data, width = 250, height = 200 }: Char } // D3 차트 렌더링 + const actualWidth = width !== undefined ? width : containerWidth; + + // 원형 차트는 더 큰 크기가 필요 (최소 400px) + const isCircularChart = element.subtype === "pie" || element.subtype === "donut"; + const minWidth = isCircularChart ? 400 : 200; + const finalWidth = Math.max(actualWidth - 20, minWidth); + const finalHeight = Math.max(height - 20, 300); + + console.log("🎨 ChartRenderer:", { + elementSubtype: element.subtype, + propWidth: width, + containerWidth, + actualWidth, + finalWidth, + finalHeight, + hasChartData: !!chartData, + chartDataLabels: chartData?.labels, + chartDataDatasets: chartData?.datasets?.length, + isCircularChart, + }); + return ( -
- +
+
+ +
); } diff --git a/frontend/components/admin/dashboard/charts/PieChart.tsx b/frontend/components/admin/dashboard/charts/PieChart.tsx index 8afcb4c0..affa2928 100644 --- a/frontend/components/admin/dashboard/charts/PieChart.tsx +++ b/frontend/components/admin/dashboard/charts/PieChart.tsx @@ -136,23 +136,24 @@ export function PieChart({ data, config, width = 500, height = 500, isDonut = fa .text(config.title); } - // 범례 (차트 오른쪽, 세로 배치) + // 범례 (차트 아래, 가로 배치, 중앙 정렬) if (config.showLegend !== false) { - const legendX = width / 2 + radius + 30; // 차트 오른쪽 - const legendY = (height - pieData.length * 25) / 2; // 세로 중앙 정렬 - - const legend = svg - .append("g") - .attr("class", "legend") - .attr("transform", `translate(${legendX}, ${legendY})`); + const itemSpacing = 140; // 각 범례 항목 사이 간격 + const totalWidth = pieData.length * itemSpacing; + const legendStartX = (width - totalWidth) / 2; // 시작 위치 + const legendY = height - 40; // 차트 아래 (여백 확보) + + const legend = svg.append("g").attr("class", "legend"); pieData.forEach((d, i) => { const legendItem = legend .append("g") - .attr("transform", `translate(0, ${i * 25})`); + .attr("transform", `translate(${legendStartX + i * itemSpacing + itemSpacing / 2}, ${legendY})`); legendItem .append("rect") + .attr("x", -7.5) // 사각형을 중앙 기준으로 + .attr("y", -7.5) .attr("width", 15) .attr("height", 15) .attr("fill", colors[i % colors.length]) @@ -160,8 +161,9 @@ export function PieChart({ data, config, width = 500, height = 500, isDonut = fa legendItem .append("text") - .attr("x", 20) - .attr("y", 12) + .attr("x", 0) + .attr("y", 20) + .attr("text-anchor", "middle") // 텍스트 중앙 정렬 .style("font-size", "11px") .style("fill", "#333") .text(`${d.label} (${d.value})`); diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index 4bbca728..176acfb0 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -174,18 +174,6 @@ export function DashboardViewer({ }: DashboardViewerProps) { const [elementData, setElementData] = useState>({}); const [loadingElements, setLoadingElements] = useState>(new Set()); - const [isMobile, setIsMobile] = useState(false); - - // 화면 크기 감지 - useEffect(() => { - const checkMobile = () => { - setIsMobile(window.innerWidth < 1024); // 1024px (lg) 미만은 모바일/태블릿 - }; - - checkMobile(); - window.addEventListener("resize", checkMobile); - return () => window.removeEventListener("resize", checkMobile); - }, []); // 캔버스 설정 계산 const canvasConfig = useMemo(() => { @@ -287,10 +275,8 @@ export function DashboardViewer({ return () => clearInterval(interval); }, [refreshInterval, loadAllData]); - // 모바일에서 요소를 자연스러운 읽기 순서로 정렬 (왼쪽→오른쪽, 위→아래) + // 요소를 자연스러운 읽기 순서로 정렬 (왼쪽→오른쪽, 위→아래) - 태블릿 이하에서 세로 정렬 시 사용 const sortedElements = useMemo(() => { - if (!isMobile) return elements; - return [...elements].sort((a, b) => { // Y 좌표 차이가 50px 이상이면 Y 우선 (같은 행으로 간주 안함) const yDiff = a.position.y - b.position.y; @@ -300,7 +286,7 @@ export function DashboardViewer({ // 같은 행이면 X 좌표로 정렬 return a.position.x - b.position.x; }); - }, [elements, isMobile]); + }, [elements]); // 요소가 없는 경우 if (elements.length === 0) { @@ -317,10 +303,18 @@ export function DashboardViewer({ return ( - {isMobile ? ( - // 모바일/태블릿: 세로 스택 레이아웃 -
-
+ {/* 데스크톱: 디자이너에서 설정한 위치 그대로 렌더링 (화면에 맞춰 비율 유지) */} +
+
+
{sortedElements.map((element) => ( loadElementData(element)} - isMobile={true} + isMobile={false} + canvasWidth={canvasConfig.width} /> ))}
- ) : ( - // 데스크톱: 기존 고정 캔버스 레이아웃 -
-
-
- {sortedElements.map((element) => ( - loadElementData(element)} - isMobile={false} - /> - ))} -
-
+
+ + {/* 태블릿 이하: 반응형 세로 정렬 */} +
+
+ {sortedElements.map((element) => ( + loadElementData(element)} + isMobile={true} + /> + ))}
- )} +
); } @@ -370,22 +355,21 @@ interface ViewerElementProps { isLoading: boolean; onRefresh: () => void; isMobile: boolean; + canvasWidth?: number; } /** * 개별 뷰어 요소 컴포넌트 + * - 데스크톱(lg 이상): absolute positioning으로 디자이너에서 설정한 위치 그대로 렌더링 (너비는 화면 비율에 따라 조정) + * - 태블릿 이하: 세로 스택 카드 레이아웃 */ -function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: ViewerElementProps) { - const [isHovered, setIsHovered] = useState(false); - +function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWidth = 1920 }: ViewerElementProps) { if (isMobile) { - // 모바일/태블릿: 세로 스택 카드 스타일 + // 태블릿 이하: 세로 스택 카드 스타일 return (
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} > {element.showHeader !== false && (
@@ -393,14 +377,22 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: Viewer
)} @@ -423,18 +415,19 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: Viewer ); } - // 데스크톱: 기존 absolute positioning + // 데스크톱: 디자이너에서 설정한 위치 그대로 absolute positioning + // 단, 너비는 화면 크기에 따라 비율로 조정 + const widthPercentage = (element.size.width / canvasWidth) * 100; + return (
setIsHovered(true)} - onMouseLeave={() => setIsHovered(false)} > {element.showHeader !== false && (
@@ -442,22 +435,36 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: Viewer
)} -
+
{element.type === "chart" ? ( - + ) : ( renderWidget(element) )}