From d4579e42214593c693eb4f37a8a156f724edb6ca Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 27 Oct 2025 14:38:43 +0900 Subject: [PATCH 1/6] =?UTF-8?q?=EB=B7=B0=EC=96=B4=20=EB=B6=80=EB=B6=84=20?= =?UTF-8?q?=EB=B0=98=EC=9D=91=ED=98=95=20=EC=A0=81=EC=9A=A9=20=EB=B0=8F=20?= =?UTF-8?q?=EC=9B=90=ED=98=95=20=EC=B0=A8=ED=8A=B8=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/charts/ChartRenderer.tsx | 71 ++++++-- .../admin/dashboard/charts/PieChart.tsx | 24 +-- .../components/dashboard/DashboardViewer.tsx | 155 +++++++++--------- 3 files changed, 155 insertions(+), 95 deletions(-) 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) )} -- 2.43.0 From 270c322dafa8e4e6328589645f0deb4b059e7bf7 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 27 Oct 2025 15:19:48 +0900 Subject: [PATCH 2/6] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EA=B8=B0=ED=83=80=20=EC=88=98=EC=A0=95=EC=82=AC=ED=95=AD=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../(main)/dashboard/[dashboardId]/page.tsx | 2 +- .../admin/dashboard/charts/ChartRenderer.tsx | 3 +- .../admin/dashboard/charts/PieChart.tsx | 29 ++++---- .../admin/dashboard/widgets/ClockWidget.tsx | 28 ++++---- .../admin/dashboard/widgets/ListWidget.tsx | 4 +- .../dashboard/widgets/CustomMetricWidget.tsx | 69 ++++++++++--------- 6 files changed, 72 insertions(+), 63 deletions(-) diff --git a/frontend/app/(main)/dashboard/[dashboardId]/page.tsx b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx index 7639abc6..54d61f77 100644 --- a/frontend/app/(main)/dashboard/[dashboardId]/page.tsx +++ b/frontend/app/(main)/dashboard/[dashboardId]/page.tsx @@ -105,7 +105,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) { } return ( -
+
{/* 대시보드 헤더 - 보기 모드에서는 숨김 */} {/*
diff --git a/frontend/components/admin/dashboard/charts/ChartRenderer.tsx b/frontend/components/admin/dashboard/charts/ChartRenderer.tsx index 88e90cae..94efd190 100644 --- a/frontend/components/admin/dashboard/charts/ChartRenderer.tsx +++ b/frontend/components/admin/dashboard/charts/ChartRenderer.tsx @@ -246,7 +246,8 @@ export function ChartRenderer({ element, data, width, height = 200 }: ChartRende 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); + // 원형 차트는 범례 공간을 위해 더 많은 여백 필요 + const finalHeight = Math.max(height - (isCircularChart ? 60 : 20), 300); console.log("🎨 ChartRenderer:", { elementSubtype: element.subtype, diff --git a/frontend/components/admin/dashboard/charts/PieChart.tsx b/frontend/components/admin/dashboard/charts/PieChart.tsx index affa2928..ab24219f 100644 --- a/frontend/components/admin/dashboard/charts/PieChart.tsx +++ b/frontend/components/admin/dashboard/charts/PieChart.tsx @@ -24,12 +24,17 @@ export function PieChart({ data, config, width = 500, height = 500, isDonut = fa const svg = d3.select(svgRef.current); svg.selectAll("*").remove(); - const margin = { top: 40, right: 150, bottom: 40, left: 120 }; + // 범례를 위한 여백 확보 (아래 80px) + const legendHeight = config.showLegend !== false ? 80 : 0; + const margin = { top: 20, right: 20, bottom: 20 + legendHeight, left: 20 }; const chartWidth = width - margin.left - margin.right; - const chartHeight = height - margin.top - margin.bottom; + const chartHeight = height - margin.top - margin.bottom - legendHeight; const radius = Math.min(chartWidth, chartHeight) / 2; - const g = svg.append("g").attr("transform", `translate(${width / 2},${height / 2})`); + // 차트를 위쪽에 배치 (범례 공간 확보) + const centerX = width / 2; + const centerY = margin.top + radius + 20; + const g = svg.append("g").attr("transform", `translate(${centerX},${centerY})`); // 색상 팔레트 const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B", "#8B5CF6", "#EC4899"]; @@ -138,10 +143,10 @@ export function PieChart({ data, config, width = 500, height = 500, isDonut = fa // 범례 (차트 아래, 가로 배치, 중앙 정렬) if (config.showLegend !== false) { - const itemSpacing = 140; // 각 범례 항목 사이 간격 + const itemSpacing = 100; // 각 범례 항목 사이 간격 (줄임) const totalWidth = pieData.length * itemSpacing; const legendStartX = (width - totalWidth) / 2; // 시작 위치 - const legendY = height - 40; // 차트 아래 (여백 확보) + const legendY = centerY + radius + 40; // 차트 아래 40px const legend = svg.append("g").attr("class", "legend"); @@ -152,19 +157,19 @@ export function PieChart({ data, config, width = 500, height = 500, isDonut = fa legendItem .append("rect") - .attr("x", -7.5) // 사각형을 중앙 기준으로 - .attr("y", -7.5) - .attr("width", 15) - .attr("height", 15) + .attr("x", -6) // 사각형을 중앙 기준으로 + .attr("y", -6) + .attr("width", 12) + .attr("height", 12) .attr("fill", colors[i % colors.length]) - .attr("rx", 3); + .attr("rx", 2); legendItem .append("text") .attr("x", 0) - .attr("y", 20) + .attr("y", 18) .attr("text-anchor", "middle") // 텍스트 중앙 정렬 - .style("font-size", "11px") + .style("font-size", "10px") .style("fill", "#333") .text(`${d.label} (${d.value})`); }); diff --git a/frontend/components/admin/dashboard/widgets/ClockWidget.tsx b/frontend/components/admin/dashboard/widgets/ClockWidget.tsx index e85623f8..fff65bc4 100644 --- a/frontend/components/admin/dashboard/widgets/ClockWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/ClockWidget.tsx @@ -111,19 +111,21 @@ export function ClockWidget({ element, onConfigUpdate }: ClockWidgetProps) { {/* 시계 콘텐츠 */} {renderClockContent()} - {/* 설정 버튼 - 우측 상단 */} -
- - - - - - setSettingsOpen(false)} /> - - -
+ {/* 설정 버튼 - 우측 상단 (디자이너 모드에서만 표시) */} + {onConfigUpdate && ( +
+ + + + + + setSettingsOpen(false)} /> + + +
+ )}
); } diff --git a/frontend/components/admin/dashboard/widgets/ListWidget.tsx b/frontend/components/admin/dashboard/widgets/ListWidget.tsx index 252831c5..6d3e6929 100644 --- a/frontend/components/admin/dashboard/widgets/ListWidget.tsx +++ b/frontend/components/admin/dashboard/widgets/ListWidget.tsx @@ -216,7 +216,7 @@ export function ListWidget({ element }: ListWidgetProps) { const paginatedRows = config.enablePagination ? data.rows.slice(startIdx, endIdx) : data.rows; return ( -
+
{/* 테이블 뷰 */} {config.viewMode === "table" && (
@@ -306,7 +306,7 @@ export function ListWidget({ element }: ListWidgetProps) { {/* 페이지네이션 */} {config.enablePagination && totalPages > 1 && ( -
+
{startIdx + 1}-{Math.min(endIdx, data.rows.length)} / {data.rows.length}개
diff --git a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx index 893ab6b0..0dba9473 100644 --- a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx +++ b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx @@ -374,45 +374,46 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) } return ( -
- {/* 스크롤 가능한 콘텐츠 영역 */} -
-
- {/* 그룹별 카드 (활성화 시) */} - {isGroupByMode && - groupedCards.map((card, index) => { - // 색상 순환 (6가지 색상) - const colorKeys = Object.keys(colorMap) as Array; - const colorKey = colorKeys[index % colorKeys.length]; - const colors = colorMap[colorKey]; - - return ( -
-
{card.label}
-
{card.value.toLocaleString()}
-
- ); - })} - - {/* 일반 지표 카드 (항상 표시) */} - {metrics.map((metric) => { - const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray; - const formattedValue = metric.calculatedValue.toFixed(metric.decimals); +
+ {/* 콘텐츠 영역 - 스크롤 없이 자동으로 크기 조정 */} +
+ {/* 그룹별 카드 (활성화 시) */} + {isGroupByMode && + groupedCards.map((card, index) => { + // 색상 순환 (6가지 색상) + const colorKeys = Object.keys(colorMap) as Array; + const colorKey = colorKeys[index % colorKeys.length]; + const colors = colorMap[colorKey]; return ( -
-
{metric.label}
-
- {formattedValue} - {metric.unit} -
+
+
{card.label}
+
{card.value.toLocaleString()}
); })} -
+ + {/* 일반 지표 카드 (항상 표시) */} + {metrics.map((metric) => { + const colors = colorMap[metric.color as keyof typeof colorMap] || colorMap.gray; + const formattedValue = metric.calculatedValue.toFixed(metric.decimals); + + return ( +
+
{metric.label}
+
+ {formattedValue} + {metric.unit} +
+
+ ); + })}
); -- 2.43.0 From 8788b47663aa0774b179a2a75f8ef0cb14096b38 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 27 Oct 2025 15:46:13 +0900 Subject: [PATCH 3/6] =?UTF-8?q?=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/dashboard/DashboardViewer.tsx | 26 ++++++++++++++----- .../dashboard/widgets/CustomMetricWidget.tsx | 18 ++++++------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index 176acfb0..4ad23fa8 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -364,6 +364,13 @@ interface ViewerElementProps { * - 태블릿 이하: 세로 스택 카드 레이아웃 */ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWidth = 1920 }: ViewerElementProps) { + const [isMounted, setIsMounted] = useState(false); + + // 마운트 확인 (Leaflet 지도 초기화 문제 해결) + useEffect(() => { + setIsMounted(true); + }, []); + if (isMobile) { // 태블릿 이하: 세로 스택 카드 스타일 return ( @@ -397,7 +404,11 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
)}
- {element.type === "chart" ? ( + {!isMounted ? ( +
+
+
+ ) : element.type === "chart" ? ( ) : ( renderWidget(element) @@ -454,16 +465,17 @@ function ViewerElement({ element, data, isLoading, onRefresh, isMobile, canvasWi
)} -
- {element.type === "chart" ? ( +
+ {!isMounted ? ( +
+
+
+ ) : element.type === "chart" ? ( ) : ( renderWidget(element) diff --git a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx index 0dba9473..52c8411c 100644 --- a/frontend/components/dashboard/widgets/CustomMetricWidget.tsx +++ b/frontend/components/dashboard/widgets/CustomMetricWidget.tsx @@ -374,9 +374,9 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) } return ( -
+
{/* 콘텐츠 영역 - 스크롤 없이 자동으로 크기 조정 */} -
+
{/* 그룹별 카드 (활성화 시) */} {isGroupByMode && groupedCards.map((card, index) => { @@ -388,10 +388,10 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) return (
-
{card.label}
-
{card.value.toLocaleString()}
+
{card.label}
+
{card.value.toLocaleString()}
); })} @@ -404,12 +404,12 @@ export default function CustomMetricWidget({ element }: CustomMetricWidgetProps) return (
-
{metric.label}
-
+
{metric.label}
+
{formattedValue} - {metric.unit} + {metric.unit}
); -- 2.43.0 From 640a9a741caf97f8d8dcda9cd5afe1017628cf5b Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 27 Oct 2025 16:06:51 +0900 Subject: [PATCH 4/6] =?UTF-8?q?=EC=95=BC=EB=93=9C=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=95=88=EB=90=98=EB=8A=94=20=ED=98=84?= =?UTF-8?q?=EC=83=81=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/widgets/yard-3d/YardEditor.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx index 87319916..a9fea2f3 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/YardEditor.tsx @@ -65,7 +65,17 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) { setIsLoading(true); const response = await yardLayoutApi.getPlacementsByLayoutId(layout.id); if (response.success) { - const loadedData = response.data as YardPlacement[]; + const loadedData = (response.data as YardPlacement[]).map((p) => ({ + ...p, + // 문자열로 저장된 숫자 필드를 숫자로 변환 + position_x: Number(p.position_x), + position_y: Number(p.position_y), + position_z: Number(p.position_z), + size_x: Number(p.size_x), + size_y: Number(p.size_y), + size_z: Number(p.size_z), + quantity: p.quantity !== null && p.quantity !== undefined ? Number(p.quantity) : null, + })); setPlacements(loadedData); setOriginalPlacements(JSON.parse(JSON.stringify(loadedData))); // 깊은 복사 } -- 2.43.0 From 8a318ea741b1ac68530035e3b70fc674ea156210 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 27 Oct 2025 16:09:06 +0900 Subject: [PATCH 5/6] =?UTF-8?q?=EC=95=BC=EB=93=9C=20=EC=BA=94=EB=B2=84?= =?UTF-8?q?=EC=8A=A4=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index bcbded34..4dd93136 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -393,7 +393,6 @@ function Scene({ maxDistance={200} maxPolarAngle={Math.PI / 2} enabled={!isDraggingAny} - reverseOrbit={true} // 드래그 방향 반전 (자연스러운 이동) screenSpacePanning={true} // 화면 공간 패닝 panSpeed={0.8} // 패닝 속도 (기본값 1.0, 낮을수록 느림) rotateSpeed={0.5} // 회전 속도 -- 2.43.0 From 9b337496b8f56306f7dc93068b298d91bd666f77 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 27 Oct 2025 17:05:33 +0900 Subject: [PATCH 6/6] =?UTF-8?q?3d=EC=9A=94=EC=86=8C=20=EB=94=94=EC=9E=90?= =?UTF-8?q?=EC=9D=B8=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/yard-3d/Yard3DCanvas.tsx | 209 +++++++++++++++--- 1 file changed, 176 insertions(+), 33 deletions(-) diff --git a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx index 4dd93136..b5af2630 100644 --- a/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx +++ b/frontend/components/admin/dashboard/widgets/yard-3d/Yard3DCanvas.tsx @@ -1,7 +1,7 @@ "use client"; import { Canvas, useThree } from "@react-three/fiber"; -import { OrbitControls, Grid, Box } from "@react-three/drei"; +import { OrbitControls, Grid, Box, Text } from "@react-three/drei"; import { Suspense, useRef, useState, useEffect } from "react"; import * as THREE from "three"; @@ -68,16 +68,19 @@ function MaterialBox({ }) { const meshRef = useRef(null); const [isDragging, setIsDragging] = useState(false); - const [isValidPosition, setIsValidPosition] = useState(true); // 배치 가능 여부 (시각 피드백용) const dragStartPos = useRef<{ x: number; y: number; z: number }>({ x: 0, y: 0, z: 0 }); const mouseStartPos = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); const { camera, gl } = useThree(); // 특정 좌표에 요소를 배치할 수 있는지 확인하고, 필요하면 Y 위치를 조정 const checkCollisionAndAdjustY = (x: number, y: number, z: number): { hasCollision: boolean; adjustedY: number } => { + const palletHeight = 0.3; // 팔레트 높이 + const palletGap = 0.05; // 팔레트와 박스 사이 간격 + const mySize = placement.size_x || gridSize; // 내 크기 (5) const myHalfSize = mySize / 2; // 2.5 - const mySizeY = placement.size_y || gridSize; // 내 높이 (5) + const mySizeY = placement.size_y || gridSize; // 박스 높이 (5) + const myTotalHeight = mySizeY + palletHeight + palletGap; // 팔레트 포함한 전체 높이 let maxYBelow = gridSize / 2; // 기본 바닥 높이 (2.5) @@ -89,24 +92,33 @@ function MaterialBox({ const pSize = p.size_x || gridSize; // 상대방 크기 (5) const pHalfSize = pSize / 2; // 2.5 - const pSizeY = p.size_y || gridSize; // 상대방 높이 (5) + const pSizeY = p.size_y || gridSize; // 상대방 박스 높이 (5) + const pTotalHeight = pSizeY + palletHeight + palletGap; // 상대방 팔레트 포함 전체 높이 - // XZ 평면에서 겹치는지 확인 - const isXZOverlapping = - Math.abs(x - p.position_x) < myHalfSize + pHalfSize && // X축 겹침 - Math.abs(z - p.position_z) < myHalfSize + pHalfSize; // Z축 겹침 + // 1단계: 넓은 범위로 겹침 감지 (살짝만 가까이 가도 감지) + const detectionMargin = 0.5; // 감지 범위 확장 (0.5 유닛) + const isNearby = + Math.abs(x - p.position_x) < myHalfSize + pHalfSize + detectionMargin && // X축 근접 + Math.abs(z - p.position_z) < myHalfSize + pHalfSize + detectionMargin; // Z축 근접 - if (isXZOverlapping) { - // 같은 XZ 위치에 요소가 있음 - // 그 요소의 윗면 높이 계산 (중심 + 높이/2) - const topOfOtherElement = p.position_y + pSizeY / 2; - // 내가 올라갈 Y 위치는 윗면 + 내 높이/2 - const myYOnTop = topOfOtherElement + mySizeY / 2; + if (isNearby) { + // 2단계: 실제로 겹치는지 정확히 판단 (바닥에 둘지, 위에 둘지 결정) + const isActuallyOverlapping = + Math.abs(x - p.position_x) < myHalfSize + pHalfSize && // X축 실제 겹침 + Math.abs(z - p.position_z) < myHalfSize + pHalfSize; // Z축 실제 겹침 - // 가장 높은 위치 기록 - if (myYOnTop > maxYBelow) { - maxYBelow = myYOnTop; + if (isActuallyOverlapping) { + // 실제로 겹침: 위에 배치 + // 상대방 전체 높이 (박스 + 팔레트)의 윗면 계산 + const topOfOtherElement = p.position_y + pTotalHeight / 2; + // 내 전체 높이의 절반을 더해서 내가 올라갈 Y 위치 계산 + const myYOnTop = topOfOtherElement + myTotalHeight / 2; + + if (myYOnTop > maxYBelow) { + maxYBelow = myYOnTop; + } } + // 근처에만 있고 실제로 안 겹침: 바닥에 배치 (maxYBelow 유지) } } @@ -182,12 +194,9 @@ function MaterialBox({ const snappedX = snapToGrid(finalX, gridSize); const snappedZ = snapToGrid(finalZ, gridSize); - // 충돌 체크 및 Y 위치 조정 (시각 피드백용) + // 충돌 체크 및 Y 위치 조정 const { adjustedY } = checkCollisionAndAdjustY(snappedX, dragStartPos.current.y, snappedZ); - // 시각 피드백: 항상 유효한 위치 (위로 올라가기 때문) - setIsValidPosition(true); - // 즉시 mesh 위치 업데이트 (조정된 Y 위치로) meshRef.current.position.set(finalX, adjustedY, finalZ); @@ -285,11 +294,19 @@ function MaterialBox({ // 요소가 설정되었는지 확인 const isConfigured = !!(placement.material_name && placement.quantity && placement.unit); + const boxHeight = placement.size_y || gridSize; + const boxWidth = placement.size_x || gridSize; + const boxDepth = placement.size_z || gridSize; + const palletHeight = 0.3; // 팔레트 높이 + const palletGap = 0.05; // 팔레트와 박스 사이 간격 (매우 작게) + + // 팔레트 위치 계산: 박스 하단부터 시작 + const palletYOffset = -(boxHeight / 2) - palletHeight / 2 - palletGap; + return ( - { e.stopPropagation(); e.nativeEvent?.stopPropagation(); @@ -298,7 +315,6 @@ function MaterialBox({ }} onPointerDown={handlePointerDown} onPointerOver={() => { - // 뷰어 모드(onDrag 없음)에서는 기본 커서, 편집 모드에서는 grab 커서 if (onDrag) { gl.domElement.style.cursor = isSelected ? "grab" : "pointer"; } else { @@ -311,15 +327,142 @@ function MaterialBox({ } }} > - - + {/* 팔레트 그룹 - 박스 하단에 붙어있도록 */} + + {/* 상단 가로 판자들 (5개) */} + {[-boxDepth * 0.4, -boxDepth * 0.2, 0, boxDepth * 0.2, boxDepth * 0.4].map((zOffset, idx) => ( + + + + + + + + ))} + + {/* 중간 세로 받침대 (3개) */} + {[-boxWidth * 0.35, 0, boxWidth * 0.35].map((xOffset, idx) => ( + + + + + + + + ))} + + {/* 하단 가로 판자들 (3개) */} + {[-boxDepth * 0.3, 0, boxDepth * 0.3].map((zOffset, idx) => ( + + + + + + + + ))} + + + {/* 메인 박스 */} + + {/* 메인 재질 - 골판지 느낌 */} + + + {/* 외곽선 - 더 진하게 */} + + + + + + + {/* 포장 테이프 (가로) - 윗면 */} + {isConfigured && ( + <> + {/* 테이프 세로 */} + + + + + )} + + {/* 자재명 라벨 스티커 (앞면) - 흰색 배경 */} + {isConfigured && placement.material_name && ( + + {/* 라벨 배경 (흰색 스티커) */} + + + + + + + + {/* 라벨 텍스트 */} + + {placement.material_name} + + + )} + + {/* 수량 라벨 (윗면) - 큰 글씨 */} + {isConfigured && placement.quantity && ( + + {placement.quantity} {placement.unit || ""} + + )} + + {/* 디테일 표시 */} + {isConfigured && ( + <> + {/* 화살표 표시 (이 쪽이 위) */} + + + ▲ + + + UP + + + + )} + ); } -- 2.43.0