From cf909cded68c16512336f385ad8e337446327c42 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 13 Oct 2025 17:05:14 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C?= =?UTF-8?q?=EC=97=90=2012=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EC=8A=A4=EB=83=85?= =?UTF-8?q?=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/CanvasElement.tsx | 423 ++++++++++-------- .../admin/dashboard/DashboardCanvas.tsx | 133 ++++-- .../admin/dashboard/DashboardDesigner.tsx | 248 +++++----- .../components/admin/dashboard/GRID_SYSTEM.md | 228 ++++++++++ .../components/admin/dashboard/gridUtils.ts | 165 +++++++ 5 files changed, 870 insertions(+), 327 deletions(-) create mode 100644 frontend/components/admin/dashboard/GRID_SYSTEM.md create mode 100644 frontend/components/admin/dashboard/gridUtils.ts diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index f5180379..87c69062 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -1,12 +1,14 @@ -'use client'; +"use client"; -import React, { useState, useCallback, useRef, useEffect } from 'react'; -import { DashboardElement, QueryResult } from './types'; -import { ChartRenderer } from './charts/ChartRenderer'; +import React, { useState, useCallback, useRef, useEffect } from "react"; +import { DashboardElement, QueryResult } from "./types"; +import { ChartRenderer } from "./charts/ChartRenderer"; +import { snapToGrid, snapSizeToGrid, GRID_CONFIG } from "./gridUtils"; interface CanvasElementProps { element: DashboardElement; isSelected: boolean; + cellSize: number; onUpdate: (id: string, updates: Partial) => void; onRemove: (id: string) => void; onSelect: (id: string | null) => void; @@ -15,145 +17,200 @@ interface CanvasElementProps { /** * 캔버스에 배치된 개별 요소 컴포넌트 - * - 드래그로 이동 가능 - * - 크기 조절 핸들 + * - 드래그로 이동 가능 (그리드 스냅) + * - 크기 조절 핸들 (그리드 스냅) * - 삭제 버튼 */ -export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelect, onConfigure }: CanvasElementProps) { +export function CanvasElement({ + element, + isSelected, + cellSize, + onUpdate, + onRemove, + onSelect, + onConfigure, +}: CanvasElementProps) { const [isDragging, setIsDragging] = useState(false); const [isResizing, setIsResizing] = useState(false); const [dragStart, setDragStart] = useState({ x: 0, y: 0, elementX: 0, elementY: 0 }); - const [resizeStart, setResizeStart] = useState({ - x: 0, y: 0, width: 0, height: 0, elementX: 0, elementY: 0, handle: '' + const [resizeStart, setResizeStart] = useState({ + x: 0, + y: 0, + width: 0, + height: 0, + elementX: 0, + elementY: 0, + handle: "", }); const [chartData, setChartData] = useState(null); const [isLoadingData, setIsLoadingData] = useState(false); const elementRef = useRef(null); + // 드래그/리사이즈 중 임시 위치/크기 (스냅 전) + const [tempPosition, setTempPosition] = useState<{ x: number; y: number } | null>(null); + const [tempSize, setTempSize] = useState<{ width: number; height: number } | null>(null); + // 요소 선택 처리 - const handleMouseDown = useCallback((e: React.MouseEvent) => { - // 닫기 버튼이나 리사이즈 핸들 클릭 시 무시 - if ((e.target as HTMLElement).closest('.element-close, .resize-handle')) { - return; - } - - onSelect(element.id); - setIsDragging(true); - setDragStart({ - x: e.clientX, - y: e.clientY, - elementX: element.position.x, - elementY: element.position.y - }); - e.preventDefault(); - }, [element.id, element.position.x, element.position.y, onSelect]); - - // 리사이즈 핸들 마우스다운 - const handleResizeMouseDown = useCallback((e: React.MouseEvent, handle: string) => { - e.stopPropagation(); - setIsResizing(true); - setResizeStart({ - x: e.clientX, - y: e.clientY, - width: element.size.width, - height: element.size.height, - elementX: element.position.x, - elementY: element.position.y, - handle - }); - }, [element.size.width, element.size.height, element.position.x, element.position.y]); - - // 마우스 이동 처리 - const handleMouseMove = useCallback((e: MouseEvent) => { - if (isDragging) { - const deltaX = e.clientX - dragStart.x; - const deltaY = e.clientY - dragStart.y; - - onUpdate(element.id, { - position: { - x: Math.max(0, dragStart.elementX + deltaX), - y: Math.max(0, dragStart.elementY + deltaY) - } - }); - } else if (isResizing) { - const deltaX = e.clientX - resizeStart.x; - const deltaY = e.clientY - resizeStart.y; - - let newWidth = resizeStart.width; - let newHeight = resizeStart.height; - let newX = resizeStart.elementX; - let newY = resizeStart.elementY; - - switch (resizeStart.handle) { - case 'se': // 오른쪽 아래 - newWidth = Math.max(150, resizeStart.width + deltaX); - newHeight = Math.max(150, resizeStart.height + deltaY); - break; - case 'sw': // 왼쪽 아래 - newWidth = Math.max(150, resizeStart.width - deltaX); - newHeight = Math.max(150, resizeStart.height + deltaY); - newX = resizeStart.elementX + deltaX; - break; - case 'ne': // 오른쪽 위 - newWidth = Math.max(150, resizeStart.width + deltaX); - newHeight = Math.max(150, resizeStart.height - deltaY); - newY = resizeStart.elementY + deltaY; - break; - case 'nw': // 왼쪽 위 - newWidth = Math.max(150, resizeStart.width - deltaX); - newHeight = Math.max(150, resizeStart.height - deltaY); - newX = resizeStart.elementX + deltaX; - newY = resizeStart.elementY + deltaY; - break; + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + // 닫기 버튼이나 리사이즈 핸들 클릭 시 무시 + if ((e.target as HTMLElement).closest(".element-close, .resize-handle")) { + return; } - onUpdate(element.id, { - position: { x: Math.max(0, newX), y: Math.max(0, newY) }, - size: { width: newWidth, height: newHeight } + onSelect(element.id); + setIsDragging(true); + setDragStart({ + x: e.clientX, + y: e.clientY, + elementX: element.position.x, + elementY: element.position.y, }); - } - }, [isDragging, isResizing, dragStart, resizeStart, element.id, onUpdate]); + e.preventDefault(); + }, + [element.id, element.position.x, element.position.y, onSelect], + ); - // 마우스 업 처리 + // 리사이즈 핸들 마우스다운 + const handleResizeMouseDown = useCallback( + (e: React.MouseEvent, handle: string) => { + e.stopPropagation(); + setIsResizing(true); + setResizeStart({ + x: e.clientX, + y: e.clientY, + width: element.size.width, + height: element.size.height, + elementX: element.position.x, + elementY: element.position.y, + handle, + }); + }, + [element.size.width, element.size.height, element.position.x, element.position.y], + ); + + // 마우스 이동 처리 (그리드 스냅 적용) + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (isDragging) { + const deltaX = e.clientX - dragStart.x; + const deltaY = e.clientY - dragStart.y; + + // 임시 위치 계산 (스냅 안 됨) + const rawX = Math.max(0, dragStart.elementX + deltaX); + const rawY = Math.max(0, dragStart.elementY + deltaY); + + setTempPosition({ x: rawX, y: rawY }); + } else if (isResizing) { + const deltaX = e.clientX - resizeStart.x; + const deltaY = e.clientY - resizeStart.y; + + let newWidth = resizeStart.width; + let newHeight = resizeStart.height; + let newX = resizeStart.elementX; + let newY = resizeStart.elementY; + + const minSize = GRID_CONFIG.CELL_SIZE * 2; // 최소 2셀 + + switch (resizeStart.handle) { + case "se": // 오른쪽 아래 + newWidth = Math.max(minSize, resizeStart.width + deltaX); + newHeight = Math.max(minSize, resizeStart.height + deltaY); + break; + case "sw": // 왼쪽 아래 + newWidth = Math.max(minSize, resizeStart.width - deltaX); + newHeight = Math.max(minSize, resizeStart.height + deltaY); + newX = resizeStart.elementX + deltaX; + break; + case "ne": // 오른쪽 위 + newWidth = Math.max(minSize, resizeStart.width + deltaX); + newHeight = Math.max(minSize, resizeStart.height - deltaY); + newY = resizeStart.elementY + deltaY; + break; + case "nw": // 왼쪽 위 + newWidth = Math.max(minSize, resizeStart.width - deltaX); + newHeight = Math.max(minSize, resizeStart.height - deltaY); + newX = resizeStart.elementX + deltaX; + newY = resizeStart.elementY + deltaY; + break; + } + + // 임시 크기/위치 저장 (스냅 안 됨) + setTempPosition({ x: Math.max(0, newX), y: Math.max(0, newY) }); + setTempSize({ width: newWidth, height: newHeight }); + } + }, + [isDragging, isResizing, dragStart, resizeStart], + ); + + // 마우스 업 처리 (그리드 스냅 적용) const handleMouseUp = useCallback(() => { + if (isDragging && tempPosition) { + // 드래그 종료 시 그리드에 스냅 (동적 셀 크기 사용) + const snappedX = snapToGrid(tempPosition.x, cellSize); + const snappedY = snapToGrid(tempPosition.y, cellSize); + + onUpdate(element.id, { + position: { x: snappedX, y: snappedY }, + }); + + setTempPosition(null); + } + + if (isResizing && tempPosition && tempSize) { + // 리사이즈 종료 시 그리드에 스냅 (동적 셀 크기 사용) + const snappedX = snapToGrid(tempPosition.x, cellSize); + const snappedY = snapToGrid(tempPosition.y, cellSize); + const snappedWidth = snapSizeToGrid(tempSize.width, 2, cellSize); + const snappedHeight = snapSizeToGrid(tempSize.height, 2, cellSize); + + onUpdate(element.id, { + position: { x: snappedX, y: snappedY }, + size: { width: snappedWidth, height: snappedHeight }, + }); + + setTempPosition(null); + setTempSize(null); + } + setIsDragging(false); setIsResizing(false); - }, []); + }, [isDragging, isResizing, tempPosition, tempSize, element.id, onUpdate, cellSize]); // 전역 마우스 이벤트 등록 React.useEffect(() => { if (isDragging || isResizing) { - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); }; } }, [isDragging, isResizing, handleMouseMove, handleMouseUp]); // 데이터 로딩 const loadChartData = useCallback(async () => { - if (!element.dataSource?.query || element.type !== 'chart') { + if (!element.dataSource?.query || element.type !== "chart") { return; } setIsLoadingData(true); try { // console.log('🔄 쿼리 실행 시작:', element.dataSource.query); - + // 실제 API 호출 - const { dashboardApi } = await import('@/lib/api/dashboard'); + const { dashboardApi } = await import("@/lib/api/dashboard"); const result = await dashboardApi.executeQuery(element.dataSource.query); - + // console.log('✅ 쿼리 실행 결과:', result); - + setChartData({ columns: result.columns || [], rows: result.rows || [], totalRows: result.rowCount || 0, - executionTime: 0 + executionTime: 0, }); } catch (error) { // console.error('❌ 데이터 로딩 오류:', error); @@ -185,51 +242,54 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec // 스타일 클래스 생성 const getContentClass = () => { - if (element.type === 'chart') { + if (element.type === "chart") { switch (element.subtype) { - case 'bar': return 'bg-gradient-to-br from-indigo-400 to-purple-600'; - case 'pie': return 'bg-gradient-to-br from-pink-400 to-red-500'; - case 'line': return 'bg-gradient-to-br from-blue-400 to-cyan-400'; - default: return 'bg-gray-200'; + case "bar": + return "bg-gradient-to-br from-indigo-400 to-purple-600"; + case "pie": + return "bg-gradient-to-br from-pink-400 to-red-500"; + case "line": + return "bg-gradient-to-br from-blue-400 to-cyan-400"; + default: + return "bg-gray-200"; } - } else if (element.type === 'widget') { + } else if (element.type === "widget") { switch (element.subtype) { - case 'exchange': return 'bg-gradient-to-br from-pink-400 to-yellow-400'; - case 'weather': return 'bg-gradient-to-br from-cyan-400 to-indigo-800'; - default: return 'bg-gray-200'; + case "exchange": + return "bg-gradient-to-br from-pink-400 to-yellow-400"; + case "weather": + return "bg-gradient-to-br from-cyan-400 to-indigo-800"; + default: + return "bg-gray-200"; } } - return 'bg-gray-200'; + return "bg-gray-200"; }; + // 드래그/리사이즈 중일 때는 임시 위치/크기 사용, 아니면 실제 값 사용 + const displayPosition = tempPosition || element.position; + const displaySize = tempSize || element.size; + return (
{/* 헤더 */} -
- {element.title} +
+ {element.title}
{/* 설정 버튼 */} {onConfigure && (
{/* 내용 */} -
- {element.type === 'chart' ? ( +
+ {element.type === "chart" ? ( // 차트 렌더링 -
+
{isLoadingData ? ( -
+
-
+
데이터 로딩 중...
@@ -274,15 +330,13 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
) : ( // 위젯 렌더링 (기존 방식) -
+
-
- {element.type === 'widget' && element.subtype === 'exchange' && '💱'} - {element.type === 'widget' && element.subtype === 'weather' && '☁️'} +
+ {element.type === "widget" && element.subtype === "exchange" && "💱"} + {element.type === "widget" && element.subtype === "weather" && "☁️"}
{element.content}
@@ -304,7 +358,7 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec } interface ResizeHandleProps { - position: 'nw' | 'ne' | 'sw' | 'se'; + position: "nw" | "ne" | "sw" | "se"; onMouseDown: (e: React.MouseEvent, handle: string) => void; } @@ -314,19 +368,20 @@ interface ResizeHandleProps { function ResizeHandle({ position, onMouseDown }: ResizeHandleProps) { const getPositionClass = () => { switch (position) { - case 'nw': return 'top-[-5px] left-[-5px] cursor-nw-resize'; - case 'ne': return 'top-[-5px] right-[-5px] cursor-ne-resize'; - case 'sw': return 'bottom-[-5px] left-[-5px] cursor-sw-resize'; - case 'se': return 'bottom-[-5px] right-[-5px] cursor-se-resize'; + case "nw": + return "top-[-5px] left-[-5px] cursor-nw-resize"; + case "ne": + return "top-[-5px] right-[-5px] cursor-ne-resize"; + case "sw": + return "bottom-[-5px] left-[-5px] cursor-sw-resize"; + case "se": + return "bottom-[-5px] right-[-5px] cursor-se-resize"; } }; return (
onMouseDown(e, position)} /> ); @@ -337,55 +392,55 @@ function ResizeHandle({ position, onMouseDown }: ResizeHandleProps) { */ function generateSampleData(query: string, chartType: string): QueryResult { // 쿼리에서 키워드 추출하여 적절한 샘플 데이터 생성 - const isMonthly = query.toLowerCase().includes('month'); - const isSales = query.toLowerCase().includes('sales') || query.toLowerCase().includes('매출'); - const isUsers = query.toLowerCase().includes('users') || query.toLowerCase().includes('사용자'); - const isProducts = query.toLowerCase().includes('product') || query.toLowerCase().includes('상품'); + const isMonthly = query.toLowerCase().includes("month"); + const isSales = query.toLowerCase().includes("sales") || query.toLowerCase().includes("매출"); + const isUsers = query.toLowerCase().includes("users") || query.toLowerCase().includes("사용자"); + const isProducts = query.toLowerCase().includes("product") || query.toLowerCase().includes("상품"); let columns: string[]; let rows: Record[]; if (isMonthly && isSales) { // 월별 매출 데이터 - columns = ['month', 'sales', 'order_count']; + columns = ["month", "sales", "order_count"]; rows = [ - { month: '2024-01', sales: 1200000, order_count: 45 }, - { month: '2024-02', sales: 1350000, order_count: 52 }, - { month: '2024-03', sales: 1180000, order_count: 41 }, - { month: '2024-04', sales: 1420000, order_count: 58 }, - { month: '2024-05', sales: 1680000, order_count: 67 }, - { month: '2024-06', sales: 1540000, order_count: 61 }, + { month: "2024-01", sales: 1200000, order_count: 45 }, + { month: "2024-02", sales: 1350000, order_count: 52 }, + { month: "2024-03", sales: 1180000, order_count: 41 }, + { month: "2024-04", sales: 1420000, order_count: 58 }, + { month: "2024-05", sales: 1680000, order_count: 67 }, + { month: "2024-06", sales: 1540000, order_count: 61 }, ]; } else if (isUsers) { // 사용자 가입 추이 - columns = ['week', 'new_users']; + columns = ["week", "new_users"]; rows = [ - { week: '2024-W10', new_users: 23 }, - { week: '2024-W11', new_users: 31 }, - { week: '2024-W12', new_users: 28 }, - { week: '2024-W13', new_users: 35 }, - { week: '2024-W14', new_users: 42 }, - { week: '2024-W15', new_users: 38 }, + { week: "2024-W10", new_users: 23 }, + { week: "2024-W11", new_users: 31 }, + { week: "2024-W12", new_users: 28 }, + { week: "2024-W13", new_users: 35 }, + { week: "2024-W14", new_users: 42 }, + { week: "2024-W15", new_users: 38 }, ]; } else if (isProducts) { // 상품별 판매량 - columns = ['product_name', 'total_sold', 'revenue']; + columns = ["product_name", "total_sold", "revenue"]; rows = [ - { product_name: '스마트폰', total_sold: 156, revenue: 234000000 }, - { product_name: '노트북', total_sold: 89, revenue: 178000000 }, - { product_name: '태블릿', total_sold: 134, revenue: 67000000 }, - { product_name: '이어폰', total_sold: 267, revenue: 26700000 }, - { product_name: '스마트워치', total_sold: 98, revenue: 49000000 }, + { product_name: "스마트폰", total_sold: 156, revenue: 234000000 }, + { product_name: "노트북", total_sold: 89, revenue: 178000000 }, + { product_name: "태블릿", total_sold: 134, revenue: 67000000 }, + { product_name: "이어폰", total_sold: 267, revenue: 26700000 }, + { product_name: "스마트워치", total_sold: 98, revenue: 49000000 }, ]; } else { // 기본 샘플 데이터 - columns = ['category', 'value', 'count']; + columns = ["category", "value", "count"]; rows = [ - { category: 'A', value: 100, count: 10 }, - { category: 'B', value: 150, count: 15 }, - { category: 'C', value: 120, count: 12 }, - { category: 'D', value: 180, count: 18 }, - { category: 'E', value: 90, count: 9 }, + { category: "A", value: 100, count: 10 }, + { category: "B", value: 150, count: 15 }, + { category: "C", value: 120, count: 12 }, + { category: "D", value: 180, count: 18 }, + { category: "E", value: 90, count: 9 }, ]; } diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx index 0b15e59f..7f73cc1e 100644 --- a/frontend/components/admin/dashboard/DashboardCanvas.tsx +++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx @@ -1,8 +1,9 @@ -'use client'; +"use client"; -import React, { forwardRef, useState, useCallback } from 'react'; -import { DashboardElement, ElementType, ElementSubtype, DragData } from './types'; -import { CanvasElement } from './CanvasElement'; +import React, { forwardRef, useState, useCallback, useEffect } from "react"; +import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types"; +import { CanvasElement } from "./CanvasElement"; +import { GRID_CONFIG, snapToGrid } from "./gridUtils"; interface DashboardCanvasProps { elements: DashboardElement[]; @@ -17,17 +18,53 @@ interface DashboardCanvasProps { /** * 대시보드 캔버스 컴포넌트 * - 드래그 앤 드롭 영역 - * - 그리드 배경 + * - 12 컬럼 그리드 배경 + * - 스냅 기능 * - 요소 배치 및 관리 */ export const DashboardCanvas = forwardRef( - ({ elements, selectedElement, onCreateElement, onUpdateElement, onRemoveElement, onSelectElement, onConfigureElement }, ref) => { + ( + { + elements, + selectedElement, + onCreateElement, + onUpdateElement, + onRemoveElement, + onSelectElement, + onConfigureElement, + }, + ref, + ) => { const [isDragOver, setIsDragOver] = useState(false); + const [cellSize, setCellSize] = useState(60); + + // 화면 크기에 따라 셀 크기 동적 계산 + useEffect(() => { + const updateCellSize = () => { + if (!ref || typeof ref === "function" || !ref.current) return; + + const container = ref.current.parentElement; + if (!container) return; + + // 컨테이너 너비에서 여백 제외 + const availableWidth = container.clientWidth - 32; // 좌우 패딩 + + // 12 컬럼 + 11개 gap을 고려한 셀 크기 계산 + const calculatedCellSize = Math.floor((availableWidth - 11 * GRID_CONFIG.GAP) / GRID_CONFIG.COLUMNS); + + setCellSize(calculatedCellSize); + }; + + updateCellSize(); + window.addEventListener("resize", updateCellSize); + + return () => window.removeEventListener("resize", updateCellSize); + }, [ref]); // 드래그 오버 처리 const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); - e.dataTransfer.dropEffect = 'copy'; + e.dataTransfer.dropEffect = "copy"; setIsDragOver(true); }, []); @@ -38,51 +75,62 @@ export const DashboardCanvas = forwardRef( } }, []); - // 드롭 처리 - const handleDrop = useCallback((e: React.DragEvent) => { - e.preventDefault(); - setIsDragOver(false); + // 드롭 처리 (그리드 스냅 적용) + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragOver(false); - try { - const dragData: DragData = JSON.parse(e.dataTransfer.getData('application/json')); - - if (!ref || typeof ref === 'function') return; - - const rect = ref.current?.getBoundingClientRect(); - if (!rect) return; + try { + const dragData: DragData = JSON.parse(e.dataTransfer.getData("application/json")); - // 캔버스 스크롤을 고려한 정확한 위치 계산 - const x = e.clientX - rect.left + (ref.current?.scrollLeft || 0); - const y = e.clientY - rect.top + (ref.current?.scrollTop || 0); + if (!ref || typeof ref === "function") return; - onCreateElement(dragData.type, dragData.subtype, x, y); - } catch (error) { - // console.error('드롭 데이터 파싱 오류:', error); - } - }, [ref, onCreateElement]); + const rect = ref.current?.getBoundingClientRect(); + if (!rect) return; + + // 캔버스 스크롤을 고려한 정확한 위치 계산 + const rawX = e.clientX - rect.left + (ref.current?.scrollLeft || 0); + const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0); + + // 그리드에 스냅 (동적 셀 크기 사용) + const snappedX = snapToGrid(rawX, cellSize); + const snappedY = snapToGrid(rawY, cellSize); + + onCreateElement(dragData.type, dragData.subtype, snappedX, snappedY); + } catch (error) { + // console.error('드롭 데이터 파싱 오류:', error); + } + }, + [ref, onCreateElement, cellSize], + ); // 캔버스 클릭 시 선택 해제 - const handleCanvasClick = useCallback((e: React.MouseEvent) => { - if (e.target === e.currentTarget) { - onSelectElement(null); - } - }, [onSelectElement]); + const handleCanvasClick = useCallback( + (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onSelectElement(null); + } + }, + [onSelectElement], + ); + + // 그리드 크기 계산 (동적) + const cellWithGap = cellSize + GRID_CONFIG.GAP; + const gridSize = `${cellWithGap}px ${cellWithGap}px`; return (
( key={element.id} element={element} isSelected={selectedElement === element.id} + cellSize={cellSize} onUpdate={onUpdateElement} onRemove={onRemoveElement} onSelect={onSelectElement} @@ -103,7 +152,7 @@ export const DashboardCanvas = forwardRef( ))}
); - } + }, ); -DashboardCanvas.displayName = 'DashboardCanvas'; +DashboardCanvas.displayName = "DashboardCanvas"; diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 60d03747..495dd17c 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -1,15 +1,17 @@ -'use client'; +"use client"; -import React, { useState, useRef, useCallback } from 'react'; -import { DashboardCanvas } from './DashboardCanvas'; -import { DashboardSidebar } from './DashboardSidebar'; -import { DashboardToolbar } from './DashboardToolbar'; -import { ElementConfigModal } from './ElementConfigModal'; -import { DashboardElement, ElementType, ElementSubtype } from './types'; +import React, { useState, useRef, useCallback, useEffect } from "react"; +import { DashboardCanvas } from "./DashboardCanvas"; +import { DashboardSidebar } from "./DashboardSidebar"; +import { DashboardToolbar } from "./DashboardToolbar"; +import { ElementConfigModal } from "./ElementConfigModal"; +import { DashboardElement, ElementType, ElementSubtype } from "./types"; +import { GRID_CONFIG } from "./gridUtils"; /** * 대시보드 설계 도구 메인 컴포넌트 * - 드래그 앤 드롭으로 차트/위젯 배치 + * - 그리드 기반 레이아웃 (12 컬럼) * - 요소 이동, 크기 조절, 삭제 기능 * - 레이아웃 저장/불러오기 기능 */ @@ -19,15 +21,39 @@ export default function DashboardDesigner() { const [elementCounter, setElementCounter] = useState(0); const [configModalElement, setConfigModalElement] = useState(null); const [dashboardId, setDashboardId] = useState(null); - const [dashboardTitle, setDashboardTitle] = useState(''); + const [dashboardTitle, setDashboardTitle] = useState(""); const [isLoading, setIsLoading] = useState(false); + const [cellSize, setCellSize] = useState(60); const canvasRef = useRef(null); + // 화면 크기에 따라 셀 크기 동적 계산 + useEffect(() => { + const updateCellSize = () => { + if (!canvasRef.current) return; + + const container = canvasRef.current.parentElement; + if (!container) return; + + // 컨테이너 너비에서 여백 제외 + const availableWidth = container.clientWidth - 32; + + // 12 컬럼 + 11개 gap을 고려한 셀 크기 계산 + const calculatedCellSize = Math.floor((availableWidth - 11 * GRID_CONFIG.GAP) / GRID_CONFIG.COLUMNS); + + setCellSize(calculatedCellSize); + }; + + updateCellSize(); + window.addEventListener("resize", updateCellSize); + + return () => window.removeEventListener("resize", updateCellSize); + }, []); + // URL 파라미터에서 대시보드 ID 읽기 및 데이터 로드 React.useEffect(() => { const params = new URLSearchParams(window.location.search); - const loadId = params.get('load'); - + const loadId = params.get("load"); + if (loadId) { loadDashboard(loadId); } @@ -38,20 +64,20 @@ export default function DashboardDesigner() { setIsLoading(true); try { // console.log('🔄 대시보드 로딩:', id); - - const { dashboardApi } = await import('@/lib/api/dashboard'); + + const { dashboardApi } = await import("@/lib/api/dashboard"); const dashboard = await dashboardApi.getDashboard(id); - + // console.log('✅ 대시보드 로딩 완료:', dashboard); - + // 대시보드 정보 설정 setDashboardId(dashboard.id); setDashboardTitle(dashboard.title); - + // 요소들 설정 if (dashboard.elements && dashboard.elements.length > 0) { setElements(dashboard.elements); - + // elementCounter를 가장 큰 ID 번호로 설정 const maxId = dashboard.elements.reduce((max, el) => { const match = el.id.match(/element-(\d+)/); @@ -63,55 +89,63 @@ export default function DashboardDesigner() { }, 0); setElementCounter(maxId); } - } catch (error) { // console.error('❌ 대시보드 로딩 오류:', error); - alert('대시보드를 불러오는 중 오류가 발생했습니다.\n\n' + (error instanceof Error ? error.message : '알 수 없는 오류')); + alert( + "대시보드를 불러오는 중 오류가 발생했습니다.\n\n" + + (error instanceof Error ? error.message : "알 수 없는 오류"), + ); } finally { setIsLoading(false); } }; - // 새로운 요소 생성 - const createElement = useCallback(( - type: ElementType, - subtype: ElementSubtype, - x: number, - y: number - ) => { - const newElement: DashboardElement = { - id: `element-${elementCounter + 1}`, - type, - subtype, - position: { x, y }, - size: { width: 250, height: 200 }, - title: getElementTitle(type, subtype), - content: getElementContent(type, subtype) - }; + // 새로운 요소 생성 (그리드 기반 기본 크기) + const createElement = useCallback( + (type: ElementType, subtype: ElementSubtype, x: number, y: number) => { + // 기본 크기: 차트는 4x3 셀, 위젯은 2x2 셀 + const defaultCells = type === "chart" ? { width: 4, height: 3 } : { width: 2, height: 2 }; + const cellWithGap = cellSize + GRID_CONFIG.GAP; - setElements(prev => [...prev, newElement]); - setElementCounter(prev => prev + 1); - setSelectedElement(newElement.id); - }, [elementCounter]); + const defaultWidth = defaultCells.width * cellWithGap - GRID_CONFIG.GAP; + const defaultHeight = defaultCells.height * cellWithGap - GRID_CONFIG.GAP; + + const newElement: DashboardElement = { + id: `element-${elementCounter + 1}`, + type, + subtype, + position: { x, y }, + size: { width: defaultWidth, height: defaultHeight }, + title: getElementTitle(type, subtype), + content: getElementContent(type, subtype), + }; + + setElements((prev) => [...prev, newElement]); + setElementCounter((prev) => prev + 1); + setSelectedElement(newElement.id); + }, + [elementCounter, cellSize], + ); // 요소 업데이트 const updateElement = useCallback((id: string, updates: Partial) => { - setElements(prev => prev.map(el => - el.id === id ? { ...el, ...updates } : el - )); + setElements((prev) => prev.map((el) => (el.id === id ? { ...el, ...updates } : el))); }, []); // 요소 삭제 - const removeElement = useCallback((id: string) => { - setElements(prev => prev.filter(el => el.id !== id)); - if (selectedElement === id) { - setSelectedElement(null); - } - }, [selectedElement]); + const removeElement = useCallback( + (id: string) => { + setElements((prev) => prev.filter((el) => el.id !== id)); + if (selectedElement === id) { + setSelectedElement(null); + } + }, + [selectedElement], + ); // 전체 삭제 const clearCanvas = useCallback(() => { - if (window.confirm('모든 요소를 삭제하시겠습니까?')) { + if (window.confirm("모든 요소를 삭제하시겠습니까?")) { setElements([]); setSelectedElement(null); setElementCounter(0); @@ -129,22 +163,25 @@ export default function DashboardDesigner() { }, []); // 요소 설정 저장 - const saveElementConfig = useCallback((updatedElement: DashboardElement) => { - updateElement(updatedElement.id, updatedElement); - }, [updateElement]); + const saveElementConfig = useCallback( + (updatedElement: DashboardElement) => { + updateElement(updatedElement.id, updatedElement); + }, + [updateElement], + ); // 레이아웃 저장 const saveLayout = useCallback(async () => { if (elements.length === 0) { - alert('저장할 요소가 없습니다. 차트나 위젯을 추가해주세요.'); + alert("저장할 요소가 없습니다. 차트나 위젯을 추가해주세요."); return; } try { // 실제 API 호출 - const { dashboardApi } = await import('@/lib/api/dashboard'); - - const elementsData = elements.map(el => ({ + const { dashboardApi } = await import("@/lib/api/dashboard"); + + const elementsData = elements.map((el) => ({ id: el.id, type: el.type, subtype: el.subtype, @@ -153,51 +190,49 @@ export default function DashboardDesigner() { title: el.title, content: el.content, dataSource: el.dataSource, - chartConfig: el.chartConfig + chartConfig: el.chartConfig, })); - + let savedDashboard; - + if (dashboardId) { // 기존 대시보드 업데이트 // console.log('🔄 대시보드 업데이트:', dashboardId); savedDashboard = await dashboardApi.updateDashboard(dashboardId, { - elements: elementsData + elements: elementsData, }); - + alert(`대시보드 "${savedDashboard.title}"이 업데이트되었습니다!`); - + // 뷰어 페이지로 이동 window.location.href = `/dashboard/${savedDashboard.id}`; - } else { // 새 대시보드 생성 - const title = prompt('대시보드 제목을 입력하세요:', '새 대시보드'); + const title = prompt("대시보드 제목을 입력하세요:", "새 대시보드"); if (!title) return; - const description = prompt('대시보드 설명을 입력하세요 (선택사항):', ''); - + const description = prompt("대시보드 설명을 입력하세요 (선택사항):", ""); + const dashboardData = { title, description: description || undefined, isPublic: false, - elements: elementsData + elements: elementsData, }; - + savedDashboard = await dashboardApi.createDashboard(dashboardData); - + // console.log('✅ 대시보드 생성 완료:', savedDashboard); - + const viewDashboard = confirm(`대시보드 "${title}"이 저장되었습니다!\n\n지금 확인해보시겠습니까?`); if (viewDashboard) { window.location.href = `/dashboard/${savedDashboard.id}`; } } - } catch (error) { // console.error('❌ 저장 오류:', error); - - const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류'; + + const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류"; alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}\n\n관리자에게 문의하세요.`); } }, [elements, dashboardId]); @@ -207,9 +242,9 @@ export default function DashboardDesigner() { return (
-
+
대시보드 로딩 중...
-
잠시만 기다려주세요
+
잠시만 기다려주세요
); @@ -218,18 +253,15 @@ export default function DashboardDesigner() { return (
{/* 캔버스 영역 */} -
+
{/* 편집 중인 대시보드 표시 */} {dashboardTitle && ( -
+
📝 편집 중: {dashboardTitle}
)} - - + + { + return GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP; +}; + +/** + * 전체 캔버스 너비 계산 + */ +export const getCanvasWidth = () => { + const cellWithGap = getCellWithGap(); + return GRID_CONFIG.COLUMNS * cellWithGap - GRID_CONFIG.GAP; +}; + +/** + * 좌표를 가장 가까운 그리드 포인트로 스냅 + * @param value - 스냅할 좌표값 + * @param cellSize - 셀 크기 (선택사항, 기본값은 GRID_CONFIG.CELL_SIZE) + * @returns 스냅된 좌표값 + */ +export const snapToGrid = (value: number, cellSize: number = GRID_CONFIG.CELL_SIZE): number => { + const cellWithGap = cellSize + GRID_CONFIG.GAP; + const gridIndex = Math.round(value / cellWithGap); + return gridIndex * cellWithGap; +}; + +/** + * 좌표를 그리드에 스냅 (임계값 적용) + * @param value - 현재 좌표값 + * @param cellSize - 셀 크기 (선택사항) + * @returns 스냅된 좌표값 (임계값 내에 있으면 스냅, 아니면 원래 값) + */ +export const snapToGridWithThreshold = (value: number, cellSize: number = GRID_CONFIG.CELL_SIZE): number => { + const snapped = snapToGrid(value, cellSize); + const distance = Math.abs(value - snapped); + + return distance <= GRID_CONFIG.SNAP_THRESHOLD ? snapped : value; +}; + +/** + * 크기를 그리드 단위로 스냅 + * @param size - 스냅할 크기 + * @param minCells - 최소 셀 개수 (기본값: 2) + * @param cellSize - 셀 크기 (선택사항) + * @returns 스냅된 크기 + */ +export const snapSizeToGrid = ( + size: number, + minCells: number = 2, + cellSize: number = GRID_CONFIG.CELL_SIZE, +): number => { + const cellWithGap = cellSize + GRID_CONFIG.GAP; + const cells = Math.max(minCells, Math.round(size / cellWithGap)); + return cells * cellWithGap - GRID_CONFIG.GAP; +}; + +/** + * 위치와 크기를 모두 그리드에 스냅 + */ +export interface GridPosition { + x: number; + y: number; +} + +export interface GridSize { + width: number; + height: number; +} + +export interface GridBounds { + position: GridPosition; + size: GridSize; +} + +/** + * 요소의 위치와 크기를 그리드에 맞춰 조정 + * @param bounds - 현재 위치와 크기 + * @param canvasWidth - 캔버스 너비 (경계 체크용) + * @param canvasHeight - 캔버스 높이 (경계 체크용) + * @returns 그리드에 스냅된 위치와 크기 + */ +export const snapBoundsToGrid = (bounds: GridBounds, canvasWidth?: number, canvasHeight?: number): GridBounds => { + // 위치 스냅 + let snappedX = snapToGrid(bounds.position.x); + let snappedY = snapToGrid(bounds.position.y); + + // 크기 스냅 + const snappedWidth = snapSizeToGrid(bounds.size.width); + const snappedHeight = snapSizeToGrid(bounds.size.height); + + // 캔버스 경계 체크 + if (canvasWidth) { + snappedX = Math.min(snappedX, canvasWidth - snappedWidth); + } + if (canvasHeight) { + snappedY = Math.min(snappedY, canvasHeight - snappedHeight); + } + + // 음수 방지 + snappedX = Math.max(0, snappedX); + snappedY = Math.max(0, snappedY); + + return { + position: { x: snappedX, y: snappedY }, + size: { width: snappedWidth, height: snappedHeight }, + }; +}; + +/** + * 좌표가 어느 그리드 셀에 속하는지 계산 + * @param value - 좌표값 + * @returns 그리드 인덱스 (0부터 시작) + */ +export const getGridIndex = (value: number): number => { + const cellWithGap = getCellWithGap(); + return Math.floor(value / cellWithGap); +}; + +/** + * 그리드 인덱스를 좌표로 변환 + * @param index - 그리드 인덱스 + * @returns 좌표값 + */ +export const gridIndexToCoordinate = (index: number): number => { + const cellWithGap = getCellWithGap(); + return index * cellWithGap; +}; + +/** + * 스냅 가이드라인 표시용 좌표 계산 + * @param value - 현재 좌표 + * @returns 가장 가까운 그리드 라인들의 좌표 배열 + */ +export const getNearbyGridLines = (value: number): number[] => { + const snapped = snapToGrid(value); + const cellWithGap = getCellWithGap(); + + return [snapped - cellWithGap, snapped, snapped + cellWithGap].filter((line) => line >= 0); +}; + +/** + * 위치가 스냅 임계값 내에 있는지 확인 + * @param value - 현재 값 + * @param snapValue - 스냅할 값 + * @returns 임계값 내에 있으면 true + */ +export const isWithinSnapThreshold = (value: number, snapValue: number): boolean => { + return Math.abs(value - snapValue) <= GRID_CONFIG.SNAP_THRESHOLD; +}; From f73229eeebcb6381832a8c247a73afd2506f0b61 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 13 Oct 2025 17:09:45 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=EC=BA=94=EB=B2=84=EC=8A=A4=20=ED=95=98?= =?UTF-8?q?=EB=8B=A8=EC=97=90=EB=8F=84=20=EA=B7=B8=EB=A6=AC=EB=93=9C=20?= =?UTF-8?q?=EB=B3=B4=EC=9D=B4=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/admin/dashboard/DashboardCanvas.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx index 7f73cc1e..dcdcaa8f 100644 --- a/frontend/components/admin/dashboard/DashboardCanvas.tsx +++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx @@ -122,7 +122,7 @@ export const DashboardCanvas = forwardRef( return (
( `, backgroundSize: gridSize, backgroundPosition: "0 0", + backgroundRepeat: "repeat", }} onDragOver={handleDragOver} onDragLeave={handleDragLeave} From 3d48c0f7cbca6b272b5f283e8190240aef137dbc Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 13 Oct 2025 17:16:44 +0900 Subject: [PATCH 3/7] =?UTF-8?q?12=20=EA=B7=B8=EB=A6=AC=EB=93=9C=20?= =?UTF-8?q?=EC=8A=A4=EB=83=85=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/admin/dashboard/CanvasElement.tsx | 2 ++ frontend/components/admin/dashboard/gridUtils.ts | 7 ++++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 87c69062..303dab38 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -279,6 +279,8 @@ export function CanvasElement({ top: displayPosition.y, width: displaySize.width, height: displaySize.height, + padding: `${GRID_CONFIG.ELEMENT_PADDING}px`, + boxSizing: "border-box", }} onMouseDown={handleMouseDown} > diff --git a/frontend/components/admin/dashboard/gridUtils.ts b/frontend/components/admin/dashboard/gridUtils.ts index 598851d6..f17496da 100644 --- a/frontend/components/admin/dashboard/gridUtils.ts +++ b/frontend/components/admin/dashboard/gridUtils.ts @@ -11,6 +11,7 @@ export const GRID_CONFIG = { CELL_SIZE: 60, // 60px 정사각형 셀 GAP: 8, // 셀 간격 SNAP_THRESHOLD: 15, // 스냅 임계값 (px) + ELEMENT_PADDING: 4, // 요소 주위 여백 (px) } as const; /** @@ -29,15 +30,15 @@ export const getCanvasWidth = () => { }; /** - * 좌표를 가장 가까운 그리드 포인트로 스냅 + * 좌표를 가장 가까운 그리드 포인트로 스냅 (여백 포함) * @param value - 스냅할 좌표값 * @param cellSize - 셀 크기 (선택사항, 기본값은 GRID_CONFIG.CELL_SIZE) - * @returns 스냅된 좌표값 + * @returns 스냅된 좌표값 (여백 포함) */ export const snapToGrid = (value: number, cellSize: number = GRID_CONFIG.CELL_SIZE): number => { const cellWithGap = cellSize + GRID_CONFIG.GAP; const gridIndex = Math.round(value / cellWithGap); - return gridIndex * cellWithGap; + return gridIndex * cellWithGap + GRID_CONFIG.ELEMENT_PADDING; }; /** From 39e3aa14cba8b5e7ec3627e220fcbafe19eae896 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 13 Oct 2025 17:21:24 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=EC=9A=B0=EC=B8=A1=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EB=93=9C=EB=B0=94=20=EB=84=88=EB=B9=84=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardSidebar.tsx | 38 +++++++------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/frontend/components/admin/dashboard/DashboardSidebar.tsx b/frontend/components/admin/dashboard/DashboardSidebar.tsx index 619f0dba..a9eb2659 100644 --- a/frontend/components/admin/dashboard/DashboardSidebar.tsx +++ b/frontend/components/admin/dashboard/DashboardSidebar.tsx @@ -1,7 +1,7 @@ -'use client'; +"use client"; -import React from 'react'; -import { DragData, ElementType, ElementSubtype } from './types'; +import React from "react"; +import { DragData, ElementType, ElementSubtype } from "./types"; /** * 대시보드 사이드바 컴포넌트 @@ -12,18 +12,16 @@ export function DashboardSidebar() { // 드래그 시작 처리 const handleDragStart = (e: React.DragEvent, type: ElementType, subtype: ElementSubtype) => { const dragData: DragData = { type, subtype }; - e.dataTransfer.setData('application/json', JSON.stringify(dragData)); - e.dataTransfer.effectAllowed = 'copy'; + e.dataTransfer.setData("application/json", JSON.stringify(dragData)); + e.dataTransfer.effectAllowed = "copy"; }; return ( -
+
{/* 차트 섹션 */}
-

- 📊 차트 종류 -

- +

📊 차트 종류

+
-

- 🔧 위젯 종류 -

- +

🔧 위젯 종류

+
onDragStart(e, type, subtype)} > - {icon} + {icon} {title}
); From 3672bbd9971710a50429c71ac63f419d9855c27d Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 13 Oct 2025 18:09:20 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=EC=BA=94=EB=B2=84=EC=8A=A4=20=ED=81=AC?= =?UTF-8?q?=EA=B8=B0=20=EC=A1=B0=EC=A0=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardCanvas.tsx | 47 ++++----------- .../admin/dashboard/DashboardDesigner.tsx | 58 ++++++------------- .../admin/dashboard/DashboardSidebar.tsx | 2 +- .../components/admin/dashboard/gridUtils.ts | 7 ++- 4 files changed, 37 insertions(+), 77 deletions(-) diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx index dcdcaa8f..04975d0f 100644 --- a/frontend/components/admin/dashboard/DashboardCanvas.tsx +++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx @@ -36,30 +36,6 @@ export const DashboardCanvas = forwardRef( ref, ) => { const [isDragOver, setIsDragOver] = useState(false); - const [cellSize, setCellSize] = useState(60); - - // 화면 크기에 따라 셀 크기 동적 계산 - useEffect(() => { - const updateCellSize = () => { - if (!ref || typeof ref === "function" || !ref.current) return; - - const container = ref.current.parentElement; - if (!container) return; - - // 컨테이너 너비에서 여백 제외 - const availableWidth = container.clientWidth - 32; // 좌우 패딩 - - // 12 컬럼 + 11개 gap을 고려한 셀 크기 계산 - const calculatedCellSize = Math.floor((availableWidth - 11 * GRID_CONFIG.GAP) / GRID_CONFIG.COLUMNS); - - setCellSize(calculatedCellSize); - }; - - updateCellSize(); - window.addEventListener("resize", updateCellSize); - - return () => window.removeEventListener("resize", updateCellSize); - }, [ref]); // 드래그 오버 처리 const handleDragOver = useCallback((e: React.DragEvent) => { @@ -93,16 +69,16 @@ export const DashboardCanvas = forwardRef( const rawX = e.clientX - rect.left + (ref.current?.scrollLeft || 0); const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0); - // 그리드에 스냅 (동적 셀 크기 사용) - const snappedX = snapToGrid(rawX, cellSize); - const snappedY = snapToGrid(rawY, cellSize); + // 그리드에 스냅 (고정 셀 크기 사용) + const snappedX = snapToGrid(rawX, GRID_CONFIG.CELL_SIZE); + const snappedY = snapToGrid(rawY, GRID_CONFIG.CELL_SIZE); onCreateElement(dragData.type, dragData.subtype, snappedX, snappedY); } catch (error) { // console.error('드롭 데이터 파싱 오류:', error); } }, - [ref, onCreateElement, cellSize], + [ref, onCreateElement], ); // 캔버스 클릭 시 선택 해제 @@ -115,19 +91,20 @@ export const DashboardCanvas = forwardRef( [onSelectElement], ); - // 그리드 크기 계산 (동적) - const cellWithGap = cellSize + GRID_CONFIG.GAP; + // 고정 그리드 크기 + const cellWithGap = GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP; const gridSize = `${cellWithGap}px ${cellWithGap}px`; return (
( key={element.id} element={element} isSelected={selectedElement === element.id} - cellSize={cellSize} + cellSize={GRID_CONFIG.CELL_SIZE} onUpdate={onUpdateElement} onRemove={onRemoveElement} onSelect={onSelectElement} diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 495dd17c..f23a12a2 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -23,32 +23,8 @@ export default function DashboardDesigner() { const [dashboardId, setDashboardId] = useState(null); const [dashboardTitle, setDashboardTitle] = useState(""); const [isLoading, setIsLoading] = useState(false); - const [cellSize, setCellSize] = useState(60); const canvasRef = useRef(null); - // 화면 크기에 따라 셀 크기 동적 계산 - useEffect(() => { - const updateCellSize = () => { - if (!canvasRef.current) return; - - const container = canvasRef.current.parentElement; - if (!container) return; - - // 컨테이너 너비에서 여백 제외 - const availableWidth = container.clientWidth - 32; - - // 12 컬럼 + 11개 gap을 고려한 셀 크기 계산 - const calculatedCellSize = Math.floor((availableWidth - 11 * GRID_CONFIG.GAP) / GRID_CONFIG.COLUMNS); - - setCellSize(calculatedCellSize); - }; - - updateCellSize(); - window.addEventListener("resize", updateCellSize); - - return () => window.removeEventListener("resize", updateCellSize); - }, []); - // URL 파라미터에서 대시보드 ID 읽기 및 데이터 로드 React.useEffect(() => { const params = new URLSearchParams(window.location.search); @@ -100,12 +76,12 @@ export default function DashboardDesigner() { } }; - // 새로운 요소 생성 (그리드 기반 기본 크기) + // 새로운 요소 생성 (고정 그리드 기반 기본 크기) const createElement = useCallback( (type: ElementType, subtype: ElementSubtype, x: number, y: number) => { // 기본 크기: 차트는 4x3 셀, 위젯은 2x2 셀 const defaultCells = type === "chart" ? { width: 4, height: 3 } : { width: 2, height: 2 }; - const cellWithGap = cellSize + GRID_CONFIG.GAP; + const cellWithGap = GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP; const defaultWidth = defaultCells.width * cellWithGap - GRID_CONFIG.GAP; const defaultHeight = defaultCells.height * cellWithGap - GRID_CONFIG.GAP; @@ -124,7 +100,7 @@ export default function DashboardDesigner() { setElementCounter((prev) => prev + 1); setSelectedElement(newElement.id); }, - [elementCounter, cellSize], + [elementCounter], ); // 요소 업데이트 @@ -253,25 +229,29 @@ export default function DashboardDesigner() { return (
{/* 캔버스 영역 */} -
+
{/* 편집 중인 대시보드 표시 */} {dashboardTitle && ( -
+
📝 편집 중: {dashboardTitle}
)} - + + {/* 캔버스 중앙 정렬 컨테이너 */} +
+ +
{/* 사이드바 */} diff --git a/frontend/components/admin/dashboard/DashboardSidebar.tsx b/frontend/components/admin/dashboard/DashboardSidebar.tsx index a9eb2659..6ff1502c 100644 --- a/frontend/components/admin/dashboard/DashboardSidebar.tsx +++ b/frontend/components/admin/dashboard/DashboardSidebar.tsx @@ -17,7 +17,7 @@ export function DashboardSidebar() { }; return ( -
+
{/* 차트 섹션 */}

📊 차트 종류

diff --git a/frontend/components/admin/dashboard/gridUtils.ts b/frontend/components/admin/dashboard/gridUtils.ts index f17496da..f5ec9d7c 100644 --- a/frontend/components/admin/dashboard/gridUtils.ts +++ b/frontend/components/admin/dashboard/gridUtils.ts @@ -5,13 +5,16 @@ * - 스냅 기능 */ -// 그리드 설정 +// 그리드 설정 (고정 크기) export const GRID_CONFIG = { COLUMNS: 12, - CELL_SIZE: 60, // 60px 정사각형 셀 + CELL_SIZE: 132, // 고정 셀 크기 GAP: 8, // 셀 간격 SNAP_THRESHOLD: 15, // 스냅 임계값 (px) ELEMENT_PADDING: 4, // 요소 주위 여백 (px) + CANVAS_WIDTH: 1682, // 고정 캔버스 너비 (실제 측정값) + // 계산식: (132 + 8) × 12 - 8 = 1672px (그리드) + // 추가 여백 10px 포함 = 1682px } as const; /** From faf201dfc6af1252003763a31068e85957593731 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 13 Oct 2025 18:10:58 +0900 Subject: [PATCH 6/7] =?UTF-8?q?=EC=9C=84=EC=A0=AF=EC=9D=84=20=EC=95=84?= =?UTF-8?q?=EB=9E=98=EB=A1=9C=20=EB=B0=B0=EC=B9=98=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/admin/dashboard/DashboardCanvas.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx index 04975d0f..a1e4d65e 100644 --- a/frontend/components/admin/dashboard/DashboardCanvas.tsx +++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx @@ -95,12 +95,19 @@ export const DashboardCanvas = forwardRef( const cellWithGap = GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP; const gridSize = `${cellWithGap}px ${cellWithGap}px`; + // 캔버스 높이를 요소들의 최대 y + height 기준으로 계산 (최소 화면 높이 보장) + const minCanvasHeight = Math.max( + typeof window !== "undefined" ? window.innerHeight : 800, + ...elements.map((el) => el.position.y + el.size.height + 100), // 하단 여백 100px + ); + return (
Date: Mon, 13 Oct 2025 18:37:18 +0900 Subject: [PATCH 7/7] =?UTF-8?q?gridUtils=20=EB=90=98=EB=8F=8C=EB=A6=AC?= =?UTF-8?q?=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/lib/utils/gridUtils.ts | 472 ++++++++++++++++++++++++-------- 1 file changed, 353 insertions(+), 119 deletions(-) diff --git a/frontend/lib/utils/gridUtils.ts b/frontend/lib/utils/gridUtils.ts index 7c0ecace..b23c0ec0 100644 --- a/frontend/lib/utils/gridUtils.ts +++ b/frontend/lib/utils/gridUtils.ts @@ -1,155 +1,389 @@ -import type { ComponentConfig, GridConfig } from "@/types/report"; +import { Position, Size } from "@/types/screen"; +import { GridSettings } from "@/types/screen-management"; -/** - * 픽셀 좌표를 그리드 좌표로 변환 - */ -export function pixelToGrid(pixel: number, cellSize: number): number { - return Math.round(pixel / cellSize); +export interface GridInfo { + columnWidth: number; + totalWidth: number; + totalHeight: number; } /** - * 그리드 좌표를 픽셀 좌표로 변환 + * 격자 정보 계산 */ -export function gridToPixel(grid: number, cellSize: number): number { - return grid * cellSize; -} +export function calculateGridInfo( + containerWidth: number, + containerHeight: number, + gridSettings: GridSettings, +): GridInfo { + const { columns, gap, padding } = gridSettings; -/** - * 컴포넌트 위치/크기를 그리드에 스냅 - */ -export function snapComponentToGrid(component: ComponentConfig, gridConfig: GridConfig): ComponentConfig { - if (!gridConfig.snapToGrid) { - return component; - } + // 사용 가능한 너비 계산 (패딩 제외) + const availableWidth = containerWidth - padding * 2; - // 픽셀 좌표를 그리드 좌표로 변환 - const gridX = pixelToGrid(component.x, gridConfig.cellWidth); - const gridY = pixelToGrid(component.y, gridConfig.cellHeight); - const gridWidth = Math.max(1, pixelToGrid(component.width, gridConfig.cellWidth)); - const gridHeight = Math.max(1, pixelToGrid(component.height, gridConfig.cellHeight)); + // 격자 간격을 고려한 컬럼 너비 계산 + const totalGaps = (columns - 1) * gap; + const columnWidth = (availableWidth - totalGaps) / columns; - // 그리드 좌표를 다시 픽셀로 변환 return { - ...component, - gridX, - gridY, - gridWidth, - gridHeight, - x: gridToPixel(gridX, gridConfig.cellWidth), - y: gridToPixel(gridY, gridConfig.cellHeight), - width: gridToPixel(gridWidth, gridConfig.cellWidth), - height: gridToPixel(gridHeight, gridConfig.cellHeight), + columnWidth: Math.max(columnWidth, 20), // 최소 20px로 줄여서 더 많은 컬럼 표시 + totalWidth: containerWidth, + totalHeight: containerHeight, }; } /** - * 그리드 충돌 감지 - * 두 컴포넌트가 겹치는지 확인 + * 위치를 격자에 맞춤 */ -export function detectGridCollision( - component: ComponentConfig, - otherComponents: ComponentConfig[], - gridConfig: GridConfig, -): boolean { - const comp1GridX = component.gridX ?? pixelToGrid(component.x, gridConfig.cellWidth); - const comp1GridY = component.gridY ?? pixelToGrid(component.y, gridConfig.cellHeight); - const comp1GridWidth = component.gridWidth ?? pixelToGrid(component.width, gridConfig.cellWidth); - const comp1GridHeight = component.gridHeight ?? pixelToGrid(component.height, gridConfig.cellHeight); +export function snapToGrid(position: Position, gridInfo: GridInfo, gridSettings: GridSettings): Position { + if (!gridSettings.snapToGrid) { + return position; + } - for (const other of otherComponents) { - if (other.id === component.id) continue; + const { columnWidth } = gridInfo; + const { gap, padding } = gridSettings; - const comp2GridX = other.gridX ?? pixelToGrid(other.x, gridConfig.cellWidth); - const comp2GridY = other.gridY ?? pixelToGrid(other.y, gridConfig.cellHeight); - const comp2GridWidth = other.gridWidth ?? pixelToGrid(other.width, gridConfig.cellWidth); - const comp2GridHeight = other.gridHeight ?? pixelToGrid(other.height, gridConfig.cellHeight); + // 격자 셀 크기 (컬럼 너비 + 간격을 하나의 격자 단위로 계산) + const cellWidth = columnWidth + gap; + const cellHeight = Math.max(40, gap * 2); // 행 높이를 더 크게 설정 - // AABB (Axis-Aligned Bounding Box) 충돌 감지 - const xOverlap = comp1GridX < comp2GridX + comp2GridWidth && comp1GridX + comp1GridWidth > comp2GridX; - const yOverlap = comp1GridY < comp2GridY + comp2GridHeight && comp1GridY + comp1GridHeight > comp2GridY; + // 패딩을 제외한 상대 위치 + const relativeX = position.x - padding; + const relativeY = position.y - padding; - if (xOverlap && yOverlap) { - return true; + // 격자 기준으로 위치 계산 (가장 가까운 격자점으로 스냅) + const gridX = Math.round(relativeX / cellWidth); + const gridY = Math.round(relativeY / cellHeight); + + // 실제 픽셀 위치로 변환 + const snappedX = Math.max(padding, padding + gridX * cellWidth); + const snappedY = Math.max(padding, padding + gridY * cellHeight); + + return { + x: snappedX, + y: snappedY, + z: position.z, + }; +} + +/** + * 크기를 격자에 맞춤 + */ +export function snapSizeToGrid(size: Size, gridInfo: GridInfo, gridSettings: GridSettings): Size { + if (!gridSettings.snapToGrid) { + return size; + } + + const { columnWidth } = gridInfo; + const { gap } = gridSettings; + + // 격자 단위로 너비 계산 + // 컴포넌트가 차지하는 컬럼 수를 올바르게 계산 + let gridColumns = 1; + + // 현재 너비에서 가장 가까운 격자 컬럼 수 찾기 + for (let cols = 1; cols <= gridSettings.columns; cols++) { + const targetWidth = cols * columnWidth + (cols - 1) * gap; + if (size.width <= targetWidth + (columnWidth + gap) / 2) { + gridColumns = cols; + break; + } + gridColumns = cols; + } + + const snappedWidth = gridColumns * columnWidth + (gridColumns - 1) * gap; + + // 높이는 동적 행 높이 단위로 스냅 + const rowHeight = Math.max(20, gap); + const snappedHeight = Math.max(40, Math.round(size.height / rowHeight) * rowHeight); + + console.log( + `📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`, + ); + + return { + width: Math.max(columnWidth, snappedWidth), + height: snappedHeight, + }; +} + +/** + * 격자 컬럼 수로 너비 계산 + */ +export function calculateWidthFromColumns(columns: number, gridInfo: GridInfo, gridSettings: GridSettings): number { + const { columnWidth } = gridInfo; + const { gap } = gridSettings; + + return columns * columnWidth + (columns - 1) * gap; +} + +/** + * gridColumns 속성을 기반으로 컴포넌트 크기 업데이트 + */ +export function updateSizeFromGridColumns( + component: { gridColumns?: number; size: Size }, + gridInfo: GridInfo, + gridSettings: GridSettings, +): Size { + if (!component.gridColumns || component.gridColumns < 1) { + return component.size; + } + + const newWidth = calculateWidthFromColumns(component.gridColumns, gridInfo, gridSettings); + + return { + width: newWidth, + height: component.size.height, // 높이는 유지 + }; +} + +/** + * 컴포넌트의 gridColumns를 자동으로 크기에 맞게 조정 + */ +export function adjustGridColumnsFromSize( + component: { size: Size }, + gridInfo: GridInfo, + gridSettings: GridSettings, +): number { + const columns = calculateColumnsFromWidth(component.size.width, gridInfo, gridSettings); + return Math.min(Math.max(1, columns), gridSettings.columns); // 1-12 범위로 제한 +} + +/** + * 너비에서 격자 컬럼 수 계산 + */ +export function calculateColumnsFromWidth(width: number, gridInfo: GridInfo, gridSettings: GridSettings): number { + const { columnWidth } = gridInfo; + const { gap } = gridSettings; + + return Math.max(1, Math.round((width + gap) / (columnWidth + gap))); +} + +/** + * 격자 가이드라인 생성 + */ +export function generateGridLines( + containerWidth: number, + containerHeight: number, + gridSettings: GridSettings, +): { + verticalLines: number[]; + horizontalLines: number[]; +} { + const { columns, gap, padding } = gridSettings; + const gridInfo = calculateGridInfo(containerWidth, containerHeight, gridSettings); + const { columnWidth } = gridInfo; + + // 격자 셀 크기 (스냅 로직과 동일하게) + const cellWidth = columnWidth + gap; + const cellHeight = Math.max(40, gap * 2); + + // 세로 격자선 + const verticalLines: number[] = []; + for (let i = 0; i <= columns; i++) { + const x = padding + i * cellWidth; + if (x <= containerWidth) { + verticalLines.push(x); } } - return false; -} + // 가로 격자선 + const horizontalLines: number[] = []; + for (let y = padding; y < containerHeight; y += cellHeight) { + horizontalLines.push(y); + } -/** - * 페이지 크기 기반 그리드 행/열 계산 - */ -export function calculateGridDimensions( - pageWidth: number, - pageHeight: number, - cellWidth: number, - cellHeight: number, -): { rows: number; columns: number } { return { - columns: Math.floor(pageWidth / cellWidth), - rows: Math.floor(pageHeight / cellHeight), + verticalLines, + horizontalLines, }; } /** - * 기본 그리드 설정 생성 + * 컴포넌트가 격자 경계에 있는지 확인 */ -export function createDefaultGridConfig(pageWidth: number, pageHeight: number): GridConfig { - const cellWidth = 20; - const cellHeight = 20; - const { rows, columns } = calculateGridDimensions(pageWidth, pageHeight, cellWidth, cellHeight); - - return { - cellWidth, - cellHeight, - rows, - columns, - visible: true, - snapToGrid: true, - gridColor: "#e5e7eb", - gridOpacity: 0.5, - }; -} - -/** - * 위치가 페이지 경계 내에 있는지 확인 - */ -export function isWithinPageBounds( - component: ComponentConfig, - pageWidth: number, - pageHeight: number, - margins: { top: number; bottom: number; left: number; right: number }, +export function isOnGridBoundary( + position: Position, + size: Size, + gridInfo: GridInfo, + gridSettings: GridSettings, + tolerance: number = 5, ): boolean { - const minX = margins.left; - const minY = margins.top; - const maxX = pageWidth - margins.right; - const maxY = pageHeight - margins.bottom; + const snappedPos = snapToGrid(position, gridInfo, gridSettings); + const snappedSize = snapSizeToGrid(size, gridInfo, gridSettings); - return ( - component.x >= minX && - component.y >= minY && - component.x + component.width <= maxX && - component.y + component.height <= maxY - ); + const positionMatch = + Math.abs(position.x - snappedPos.x) <= tolerance && Math.abs(position.y - snappedPos.y) <= tolerance; + + const sizeMatch = + Math.abs(size.width - snappedSize.width) <= tolerance && Math.abs(size.height - snappedSize.height) <= tolerance; + + return positionMatch && sizeMatch; } /** - * 컴포넌트를 페이지 경계 내로 제한 + * 그룹 내부 컴포넌트들을 격자에 맞게 정렬 */ -export function constrainToPageBounds( - component: ComponentConfig, - pageWidth: number, - pageHeight: number, - margins: { top: number; bottom: number; left: number; right: number }, -): ComponentConfig { - const minX = margins.left; - const minY = margins.top; - const maxX = pageWidth - margins.right - component.width; - const maxY = pageHeight - margins.bottom - component.height; +export function alignGroupChildrenToGrid( + children: any[], + groupPosition: Position, + gridInfo: GridInfo, + gridSettings: GridSettings, +): any[] { + if (!gridSettings.snapToGrid || children.length === 0) return children; - return { - ...component, - x: Math.max(minX, Math.min(maxX, component.x)), - y: Math.max(minY, Math.min(maxY, component.y)), - }; + console.log("🔧 alignGroupChildrenToGrid 시작:", { + childrenCount: children.length, + groupPosition, + gridInfo, + gridSettings, + }); + + return children.map((child, index) => { + console.log(`📐 자식 ${index + 1} 처리 중:`, { + childId: child.id, + originalPosition: child.position, + originalSize: child.size, + }); + + const { columnWidth } = gridInfo; + const { gap } = gridSettings; + + // 그룹 내부 패딩 고려한 격자 정렬 + const padding = 16; + const effectiveX = child.position.x - padding; + const columnIndex = Math.round(effectiveX / (columnWidth + gap)); + const snappedX = padding + columnIndex * (columnWidth + gap); + + // Y 좌표는 동적 행 높이 단위로 스냅 + const rowHeight = Math.max(20, gap); + const effectiveY = child.position.y - padding; + const rowIndex = Math.round(effectiveY / rowHeight); + const snappedY = padding + rowIndex * rowHeight; + + // 크기는 외부 격자와 동일하게 스냅 (columnWidth + gap 사용) + const fullColumnWidth = columnWidth + gap; // 외부 격자와 동일한 크기 + const widthInColumns = Math.max(1, Math.round(child.size.width / fullColumnWidth)); + const snappedWidth = widthInColumns * fullColumnWidth - gap; // gap 제거하여 실제 컴포넌트 크기 + const snappedHeight = Math.max(40, Math.round(child.size.height / rowHeight) * rowHeight); + + const snappedChild = { + ...child, + position: { + x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보 + y: Math.max(padding, snappedY), + z: child.position.z || 1, + }, + size: { + width: snappedWidth, + height: snappedHeight, + }, + }; + + console.log(`✅ 자식 ${index + 1} 격자 정렬 완료:`, { + childId: child.id, + calculation: { + effectiveX, + effectiveY, + columnIndex, + rowIndex, + widthInColumns, + originalX: child.position.x, + snappedX: snappedChild.position.x, + padding, + }, + snappedPosition: snappedChild.position, + snappedSize: snappedChild.size, + deltaX: snappedChild.position.x - child.position.x, + deltaY: snappedChild.position.y - child.position.y, + }); + + return snappedChild; + }); +} + +/** + * 그룹 생성 시 최적화된 그룹 크기 계산 + */ +export function calculateOptimalGroupSize( + children: Array<{ position: Position; size: Size }>, + gridInfo: GridInfo, + gridSettings: GridSettings, +): Size { + if (children.length === 0) { + return { width: gridInfo.columnWidth * 2, height: 40 * 2 }; + } + + console.log("📏 calculateOptimalGroupSize 시작:", { + childrenCount: children.length, + children: children.map((c) => ({ pos: c.position, size: c.size })), + }); + + // 모든 자식 컴포넌트를 포함하는 최소 경계 계산 + const bounds = children.reduce( + (acc, child) => ({ + minX: Math.min(acc.minX, child.position.x), + minY: Math.min(acc.minY, child.position.y), + maxX: Math.max(acc.maxX, child.position.x + child.size.width), + maxY: Math.max(acc.maxY, child.position.y + child.size.height), + }), + { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity }, + ); + + console.log("📐 경계 계산:", bounds); + + const contentWidth = bounds.maxX - bounds.minX; + const contentHeight = bounds.maxY - bounds.minY; + + // 그룹은 격자 스냅 없이 컨텐츠에 맞는 자연스러운 크기 + const padding = 16; // 그룹 내부 여백 + const groupSize = { + width: contentWidth + padding * 2, + height: contentHeight + padding * 2, + }; + + console.log("✅ 자연스러운 그룹 크기:", { + contentSize: { width: contentWidth, height: contentHeight }, + withPadding: groupSize, + strategy: "그룹은 격자 스냅 없이, 내부 컴포넌트만 격자에 맞춤", + }); + + return groupSize; +} + +/** + * 그룹 내 상대 좌표를 격자 기준으로 정규화 + */ +export function normalizeGroupChildPositions(children: any[], gridSettings: GridSettings): any[] { + if (!gridSettings.snapToGrid || children.length === 0) return children; + + console.log("🔄 normalizeGroupChildPositions 시작:", { + childrenCount: children.length, + originalPositions: children.map((c) => ({ id: c.id, pos: c.position })), + }); + + // 모든 자식의 최소 위치 찾기 + const minX = Math.min(...children.map((child) => child.position.x)); + const minY = Math.min(...children.map((child) => child.position.y)); + + console.log("📍 최소 위치:", { minX, minY }); + + // 그룹 내에서 시작점을 패딩만큼 떨어뜨림 (자연스러운 여백) + const padding = 16; + const startX = padding; + const startY = padding; + + const normalizedChildren = children.map((child) => ({ + ...child, + position: { + x: child.position.x - minX + startX, + y: child.position.y - minY + startY, + z: child.position.z || 1, + }, + })); + + console.log("✅ 정규화 완료:", { + normalizedPositions: normalizedChildren.map((c) => ({ id: c.id, pos: c.position })), + }); + + return normalizedChildren; }