뷰어 부분 반응형 적용 및 원형 차트 설정 변경
This commit is contained in:
parent
cc4dd5ffdc
commit
d4579e4221
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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})`);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue