대시보드 기타 수정사항(3d 야드 위주) #151

Merged
hyeonsu merged 7 commits from feat/rest-api into main 2025-10-27 17:27:24 +09:00
3 changed files with 155 additions and 95 deletions
Showing only changes of commit d4579e4221 - Show all commits

View File

@ -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<HTMLDivElement>(null);
const [containerWidth, setContainerWidth] = useState(width || 250);
const [chartData, setChartData] = useState<ChartData | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="flex h-full w-full items-center justify-center bg-white p-2">
<Chart
chartType={element.subtype}
data={chartData}
config={element.chartConfig}
width={width - 20}
height={height - 20}
/>
<div ref={containerRef} className="flex h-full w-full items-center justify-center bg-white p-2">
<div className="flex items-center justify-center">
<Chart
chartType={element.subtype}
data={chartData}
config={element.chartConfig}
width={finalWidth}
height={finalHeight}
/>
</div>
</div>
);
}

View File

@ -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})`);

View File

@ -174,18 +174,6 @@ export function DashboardViewer({
}: DashboardViewerProps) {
const [elementData, setElementData] = useState<Record<string, QueryResult>>({});
const [loadingElements, setLoadingElements] = useState<Set<string>>(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 (
<DashboardProvider>
{isMobile ? (
// 모바일/태블릿: 세로 스택 레이아웃
<div className="min-h-screen bg-gray-100 p-4" style={{ backgroundColor }}>
<div className="mx-auto max-w-3xl space-y-4">
{/* 데스크톱: 디자이너에서 설정한 위치 그대로 렌더링 (화면에 맞춰 비율 유지) */}
<div className="hidden min-h-screen bg-gray-100 py-8 lg:block" style={{ backgroundColor }}>
<div className="mx-auto px-4" style={{ maxWidth: `${canvasConfig.width}px` }}>
<div
className="relative rounded-lg"
style={{
width: "100%",
minHeight: `${canvasConfig.height}px`,
height: `${canvasHeight}px`,
backgroundColor: backgroundColor,
}}
>
{sortedElements.map((element) => (
<ViewerElement
key={element.id}
@ -328,38 +322,29 @@ export function DashboardViewer({
data={elementData[element.id]}
isLoading={loadingElements.has(element.id)}
onRefresh={() => loadElementData(element)}
isMobile={true}
isMobile={false}
canvasWidth={canvasConfig.width}
/>
))}
</div>
</div>
) : (
// 데스크톱: 기존 고정 캔버스 레이아웃
<div className="min-h-screen bg-gray-100 py-8">
<div className="mx-auto" style={{ width: `${canvasConfig.width}px` }}>
<div
className="relative rounded-lg"
style={{
width: `${canvasConfig.width}px`,
minHeight: `${canvasConfig.height}px`,
height: `${canvasHeight}px`,
backgroundColor: backgroundColor,
}}
>
{sortedElements.map((element) => (
<ViewerElement
key={element.id}
element={element}
data={elementData[element.id]}
isLoading={loadingElements.has(element.id)}
onRefresh={() => loadElementData(element)}
isMobile={false}
/>
))}
</div>
</div>
</div>
{/* 태블릿 이하: 반응형 세로 정렬 */}
<div className="block min-h-screen bg-gray-100 p-4 lg:hidden" style={{ backgroundColor }}>
<div className="mx-auto max-w-3xl space-y-4">
{sortedElements.map((element) => (
<ViewerElement
key={element.id}
element={element}
data={elementData[element.id]}
isLoading={loadingElements.has(element.id)}
onRefresh={() => loadElementData(element)}
isMobile={true}
/>
))}
</div>
)}
</div>
</DashboardProvider>
);
}
@ -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 (
<div
className="relative overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"
style={{ minHeight: "300px" }}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{element.showHeader !== false && (
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
@ -393,14 +377,22 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: Viewer
<button
onClick={onRefresh}
disabled={isLoading}
className="text-gray-400 hover:text-gray-600 disabled:opacity-50"
className="text-gray-400 transition-colors hover:text-gray-600 disabled:opacity-50"
title="새로고침"
>
{isLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border border-gray-400 border-t-transparent" />
) : (
"🔄"
)}
<svg
className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
</div>
)}
@ -423,18 +415,19 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: Viewer
);
}
// 데스크톱: 기존 absolute positioning
// 데스크톱: 디자이너에서 설정한 위치 그대로 absolute positioning
// 단, 너비는 화면 크기에 따라 비율로 조정
const widthPercentage = (element.size.width / canvasWidth) * 100;
return (
<div
className="absolute overflow-hidden rounded-lg border border-gray-200 bg-white shadow-sm"
style={{
left: element.position.x,
left: `${(element.position.x / canvasWidth) * 100}%`,
top: element.position.y,
width: element.size.width,
width: `${widthPercentage}%`,
height: element.size.height,
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{element.showHeader !== false && (
<div className="flex items-center justify-between border-b border-gray-200 bg-gray-50 px-4 py-3">
@ -442,22 +435,36 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile }: Viewer
<button
onClick={onRefresh}
disabled={isLoading}
className={`text-gray-400 transition-opacity hover:text-gray-600 disabled:opacity-50 ${
isHovered ? "opacity-100" : "opacity-0"
}`}
className="text-gray-400 transition-colors hover:text-gray-600 disabled:opacity-50"
title="새로고침"
>
{isLoading ? (
<div className="h-4 w-4 animate-spin rounded-full border border-gray-400 border-t-transparent" />
) : (
"🔄"
)}
<svg
className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
</button>
</div>
)}
<div className={element.showHeader !== false ? "h-[calc(100%-57px)]" : "h-full"}>
<div
className={element.showHeader !== false ? "h-[calc(100%-57px)] w-full" : "h-full w-full"}
style={{ minHeight: "300px" }}
>
{element.type === "chart" ? (
<ChartRenderer element={element} data={data} width={element.size.width} height={element.size.height - 57} />
<ChartRenderer
element={element}
data={data}
width={undefined}
height={Math.max(element.size.height - 57, 300)}
/>
) : (
renderWidget(element)
)}