From 9dca73f4c4f5d10a64b57a6ffa0f2b963c094c11 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 22 Oct 2025 16:37:14 +0900 Subject: [PATCH] =?UTF-8?q?12=EC=BB=AC=EB=9F=BC=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EB=93=9C=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/CanvasElement.tsx | 47 +++++--- .../admin/dashboard/DashboardCanvas.tsx | 108 ++++++++++-------- .../components/admin/dashboard/gridUtils.ts | 68 ++++++++++- 3 files changed, 157 insertions(+), 66 deletions(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 9350642e..6214c8cb 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -4,7 +4,7 @@ import React, { useState, useCallback, useRef, useEffect } from "react"; import dynamic from "next/dynamic"; import { DashboardElement, QueryResult, Position } from "./types"; import { ChartRenderer } from "./charts/ChartRenderer"; -import { GRID_CONFIG } from "./gridUtils"; +import { GRID_CONFIG, magneticSnap } from "./gridUtils"; // 위젯 동적 임포트 const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), { @@ -135,6 +135,8 @@ interface CanvasElementProps { cellSize: number; subGridSize: number; canvasWidth?: number; + verticalGuidelines: number[]; + horizontalGuidelines: number[]; onUpdate: (id: string, updates: Partial) => void; onUpdateMultiple?: (updates: { id: string; updates: Partial }[]) => void; // 🔥 다중 업데이트 onMultiDragStart?: (draggedId: string, otherOffsets: Record) => void; @@ -159,6 +161,8 @@ export function CanvasElement({ cellSize, subGridSize, canvasWidth = 1560, + verticalGuidelines, + horizontalGuidelines, onUpdate, onUpdateMultiple, onMultiDragStart, @@ -315,15 +319,18 @@ export function CanvasElement({ const maxX = canvasWidth - element.size.width; rawX = Math.min(rawX, maxX); - // 드래그 중 실시간 스냅 (서브그리드만 사용) - const snappedX = Math.round(rawX / subGridSize) * subGridSize; - const snappedY = Math.round(rawY / subGridSize) * subGridSize; + // 자석 스냅으로 변경 + const snappedX = magneticSnap(rawX, verticalGuidelines); + const snappedY = magneticSnap(rawY, horizontalGuidelines); - setTempPosition({ x: snappedX, y: snappedY }); + // 스냅 후 X 좌표 다시 체크 + const finalSnappedX = Math.min(snappedX, maxX); + + setTempPosition({ x: finalSnappedX, y: snappedY }); // 🔥 다중 드래그 중 - 다른 위젯들의 위치 업데이트 if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) { - onMultiDragMove(element, { x: snappedX, y: snappedY }); + onMultiDragMove(element, { x: finalSnappedX, y: snappedY }); } } else if (isResizing) { const deltaX = e.clientX - resizeStart.x; @@ -367,14 +374,20 @@ export function CanvasElement({ const maxWidth = canvasWidth - newX; newWidth = Math.min(newWidth, maxWidth); - // 리사이즈 중 실시간 스냅 (서브그리드만 사용) - const snappedX = Math.round(newX / subGridSize) * subGridSize; - const snappedY = Math.round(newY / subGridSize) * subGridSize; - const snappedWidth = Math.round(newWidth / subGridSize) * subGridSize; - const snappedHeight = Math.round(newHeight / subGridSize) * subGridSize; + // 자석 스냅으로 변경 + const snappedX = magneticSnap(newX, verticalGuidelines); + const snappedY = magneticSnap(newY, horizontalGuidelines); + + // 크기는 12px 단위로 스냅 + const snappedWidth = Math.round(newWidth / 12) * 12; + const snappedHeight = Math.round(newHeight / 12) * 12; + + // 스냅 후 경계 체크 + const finalSnappedX = Math.max(0, Math.min(snappedX, canvasWidth - snappedWidth)); + const finalSnappedY = Math.max(0, snappedY); // 임시 크기/위치 저장 (스냅됨) - setTempPosition({ x: Math.max(0, snappedX), y: Math.max(0, snappedY) }); + setTempPosition({ x: finalSnappedX, y: finalSnappedY }); setTempSize({ width: snappedWidth, height: snappedHeight }); } }, @@ -386,7 +399,8 @@ export function CanvasElement({ element, canvasWidth, cellSize, - subGridSize, + verticalGuidelines, + horizontalGuidelines, selectedElements, allElements, onUpdateMultiple, @@ -891,12 +905,7 @@ export function CanvasElement({ ) : element.type === "widget" && element.subtype === "list" ? ( // 리스트 위젯 렌더링
- { - onUpdate(element.id, { listConfig: newConfig as any }); - }} - /> +
) : element.type === "widget" && element.subtype === "yard-management-3d" ? ( // 야드 관리 3D 위젯 렌더링 diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx index 1c2414c1..51351475 100644 --- a/frontend/components/admin/dashboard/DashboardCanvas.tsx +++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx @@ -3,7 +3,15 @@ import React, { forwardRef, useState, useCallback, useMemo, useEffect } from "react"; import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types"; import { CanvasElement } from "./CanvasElement"; -import { GRID_CONFIG, snapToGrid, calculateGridConfig } from "./gridUtils"; +import { + GRID_CONFIG, + snapToGrid, + calculateGridConfig, + calculateVerticalGuidelines, + calculateHorizontalGuidelines, + calculateBoxSize, + magneticSnap, +} from "./gridUtils"; import { resolveAllCollisions } from "./collisionUtils"; interface DashboardCanvasProps { @@ -47,7 +55,7 @@ export const DashboardCanvas = forwardRef( ref, ) => { const [isDragOver, setIsDragOver] = useState(false); - + // 🔥 선택 박스 상태 const [selectionBox, setSelectionBox] = useState<{ startX: number; @@ -58,10 +66,10 @@ export const DashboardCanvas = forwardRef( const [isSelecting, setIsSelecting] = useState(false); const [justSelected, setJustSelected] = useState(false); // 🔥 방금 선택했는지 플래그 const [isDraggingAny, setIsDraggingAny] = useState(false); // 🔥 현재 드래그 중인지 플래그 - + // 🔥 다중 선택된 위젯들의 임시 위치 (드래그 중 시각적 피드백) const [multiDragOffsets, setMultiDragOffsets] = useState>({}); - + // 🔥 선택 박스 드래그 중 자동 스크롤 const lastMouseYForSelectionRef = React.useRef(window.innerHeight / 2); const selectionAutoScrollFrameRef = React.useRef(null); @@ -70,6 +78,14 @@ export const DashboardCanvas = forwardRef( const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]); const cellSize = gridConfig.CELL_SIZE; + // 🔥 그리드 박스 시스템 - 12개 박스가 캔버스 너비에 꽉 차게 + const verticalGuidelines = useMemo(() => calculateVerticalGuidelines(canvasWidth), [canvasWidth]); + const horizontalGuidelines = useMemo( + () => calculateHorizontalGuidelines(canvasHeight, canvasWidth), + [canvasHeight, canvasWidth], + ); + const boxSize = useMemo(() => calculateBoxSize(canvasWidth), [canvasWidth]); + // 충돌 방지 기능이 포함된 업데이트 핸들러 const handleUpdateWithCollisionDetection = useCallback( (id: string, updates: Partial) => { @@ -177,23 +193,13 @@ 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 gridSize = cellSize + GRID_CONFIG.GAP; // GAP 포함한 실제 그리드 크기 - const magneticThreshold = 15; + // 자석 스냅 적용 + let snappedX = magneticSnap(rawX, verticalGuidelines); + let snappedY = magneticSnap(rawY, horizontalGuidelines); - // X 좌표 스냅 - const nearestGridX = Math.round(rawX / gridSize) * gridSize; - const distToGridX = Math.abs(rawX - nearestGridX); - let snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(rawX / subGridSize) * subGridSize; - - // Y 좌표 스냅 - const nearestGridY = Math.round(rawY / gridSize) * gridSize; - const distToGridY = Math.abs(rawY - nearestGridY); - const snappedY = - distToGridY <= magneticThreshold ? nearestGridY : Math.round(rawY / subGridSize) * subGridSize; - - // X 좌표가 캔버스 너비를 벗어나지 않도록 제한 - const maxX = canvasWidth - cellSize * 2; // 최소 2칸 너비 보장 + // X 좌표가 캔버스 너비를 벗어나지 않도록 제한 (최소 2칸 너비 보장) + const minElementWidth = cellSize * 2 + GRID_CONFIG.GAP; + const maxX = canvasWidth - minElementWidth; snappedX = Math.max(0, Math.min(snappedX, maxX)); onCreateElement(dragData.type, dragData.subtype, snappedX, snappedY); @@ -201,7 +207,7 @@ export const DashboardCanvas = forwardRef( // 드롭 데이터 파싱 오류 무시 } }, - [ref, onCreateElement, canvasWidth, cellSize], + [ref, onCreateElement, canvasWidth, cellSize, verticalGuidelines, horizontalGuidelines], ); // 🔥 선택 박스 드래그 시작 @@ -210,14 +216,14 @@ export const DashboardCanvas = forwardRef( // 🔥 위젯 내부 클릭이 아닌 경우만 (data-element-id가 없는 경우) const target = e.target as HTMLElement; const isWidget = target.closest("[data-element-id]"); - + if (isWidget) { // console.log("🚫 위젯 내부 클릭 - 선택 박스 시작 안함"); return; } - + // console.log("✅ 빈 공간 클릭 - 선택 박스 시작"); - + if (!ref || typeof ref === "function") return; const rect = ref.current?.getBoundingClientRect(); if (!rect) return; @@ -274,20 +280,20 @@ export const DashboardCanvas = forwardRef( // 겹치는 영역의 넓이 const overlapArea = (overlapRight - overlapLeft) * (overlapBottom - overlapTop); - + // 요소의 전체 넓이 const elementArea = el.size.width * el.size.height; // 70% 이상 겹치면 선택 const overlapPercentage = overlapArea / elementArea; - + // console.log(`📦 요소 ${el.id}:`, { // position: el.position, // size: el.size, // overlapPercentage: (overlapPercentage * 100).toFixed(1) + "%", // selected: overlapPercentage >= 0.7, // }); - + return overlapPercentage >= 0.7; }) .map((el) => el.id); @@ -327,9 +333,9 @@ export const DashboardCanvas = forwardRef( if (!isSelecting) { const deltaX = Math.abs(x - selectionBox.startX); const deltaY = Math.abs(y - selectionBox.startY); - + // console.log("📏 이동 거리:", { deltaX, deltaY }); - + // 🔥 5px 이상 움직이면 선택 박스 활성화 (위젯 드래그와 구분) if (deltaX > 5 || deltaY > 5) { // console.log("🎯 선택 박스 활성화 (5px 이상 이동)"); @@ -374,10 +380,10 @@ export const DashboardCanvas = forwardRef( const autoScrollLoop = (currentTime: number) => { const viewportHeight = window.innerHeight; const lastMouseY = lastMouseYForSelectionRef.current; - + let shouldScroll = false; let scrollDirection = 0; - + if (lastMouseY < scrollThreshold) { shouldScroll = true; scrollDirection = -scrollSpeed; @@ -387,9 +393,9 @@ export const DashboardCanvas = forwardRef( scrollDirection = scrollSpeed; // console.log("⬇️ 아래로 스크롤 (선택 박스):", { lastMouseY, boundary: viewportHeight - scrollThreshold }); } - + const deltaTime = currentTime - lastTime; - + if (shouldScroll && deltaTime >= 10) { window.scrollBy(0, scrollDirection); // console.log("✅ 스크롤 실행 (선택 박스):", { scrollDirection, deltaTime }); @@ -418,7 +424,7 @@ export const DashboardCanvas = forwardRef( // console.log("🚫 방금 선택했거나 드래그 중이므로 클릭 이벤트 무시"); return; } - + if (e.target === e.currentTarget) { // console.log("✅ 빈 공간 클릭 - 선택 해제"); onSelectElement(null); @@ -433,7 +439,7 @@ export const DashboardCanvas = forwardRef( // 동적 그리드 크기 계산 const cellWithGap = cellSize + GRID_CONFIG.GAP; const gridSize = `${cellWithGap}px ${cellWithGap}px`; - + // 서브그리드 크기 계산 (gridConfig에서 정확하게 계산된 값 사용) const subGridSize = gridConfig.SUB_GRID_SIZE; @@ -443,7 +449,7 @@ export const DashboardCanvas = forwardRef( // 🔥 선택 박스 스타일 계산 const selectionBoxStyle = useMemo(() => { if (!selectionBox) return null; - + const minX = Math.min(selectionBox.startX, selectionBox.endX); const maxX = Math.max(selectionBox.startX, selectionBox.endX); const minY = Math.min(selectionBox.startY, selectionBox.endY); @@ -465,14 +471,6 @@ export const DashboardCanvas = forwardRef( backgroundColor, height: `${canvasHeight}px`, minHeight: `${canvasHeight}px`, - // 서브그리드 배경 (세밀한 점선) - backgroundImage: ` - linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px), - linear-gradient(90deg, rgba(59, 130, 246, 0.05) 1px, transparent 1px) - `, - backgroundSize: `${subGridSize}px ${subGridSize}px`, - backgroundPosition: "0 0", - backgroundRepeat: "repeat", cursor: isSelecting ? "crosshair" : "default", }} onDragOver={handleDragOver} @@ -494,6 +492,23 @@ export const DashboardCanvas = forwardRef( }} /> ))} */} + {/* 그리드 박스들 (12px 간격, 캔버스 너비에 꽉 차게) */} + {verticalGuidelines.map((x, xIdx) => + horizontalGuidelines.map((y, yIdx) => ( +
+ )), + )} {/* 배치된 요소들 렌더링 */} {elements.length === 0 && (
@@ -513,6 +528,8 @@ export const DashboardCanvas = forwardRef( cellSize={cellSize} subGridSize={subGridSize} canvasWidth={canvasWidth} + verticalGuidelines={verticalGuidelines} + horizontalGuidelines={horizontalGuidelines} onUpdate={handleUpdateWithCollisionDetection} onUpdateMultiple={(updates) => { // 🔥 여러 요소 동시 업데이트 (충돌 감지 건너뛰기) @@ -552,10 +569,9 @@ export const DashboardCanvas = forwardRef( }} onRemove={onRemoveElement} onSelect={onSelectElement} - onConfigure={onConfigureElement} /> ))} - + {/* 🔥 선택 박스 렌더링 */} {selectionBox && selectionBoxStyle && (
{ export const snapToGrid = (value: number, subGridSize?: number): number => { // 서브 그리드 크기가 지정되지 않으면 기본 그리드 크기의 1/3 사용 (3x3 서브그리드) const snapSize = subGridSize ?? Math.floor(GRID_CONFIG.CELL_SIZE / 3); - + // 서브 그리드 단위로 스냅 const gridIndex = Math.round(value / snapSize); return gridIndex * snapSize; @@ -198,3 +205,62 @@ export const getNearbyGridLines = (value: number): number[] => { export const isWithinSnapThreshold = (value: number, snapValue: number): boolean => { return Math.abs(value - snapValue) <= GRID_CONFIG.SNAP_THRESHOLD; }; + +// 박스 크기 계산 (캔버스 너비에 맞게) +export function calculateBoxSize(canvasWidth: number): number { + const totalGaps = 11 * GRID_CONFIG.GRID_BOX_GAP; // 12개 박스 사이 간격 11개 + const availableWidth = canvasWidth - totalGaps; + return availableWidth / 12; +} + +// 수직 그리드 박스 좌표 계산 (12개, 너비에 꽉 차게) +export function calculateVerticalGuidelines(canvasWidth: number): number[] { + const lines: number[] = []; + const boxSize = calculateBoxSize(canvasWidth); + + for (let i = 0; i < 12; i++) { + const x = i * (boxSize + GRID_CONFIG.GRID_BOX_GAP); + lines.push(x); + } + return lines; +} + +// 수평 그리드 박스 좌표 계산 (캔버스 너비 기준으로 정사각형 유지) +export function calculateHorizontalGuidelines(canvasHeight: number, canvasWidth: number): number[] { + const lines: number[] = []; + const boxSize = calculateBoxSize(canvasWidth); // 수직과 동일한 박스 크기 사용 + const cellSize = boxSize + GRID_CONFIG.GRID_BOX_GAP; + + for (let y = 0; y <= canvasHeight; y += cellSize) { + lines.push(y); + } + return lines; +} + +// 가장 가까운 가이드라인 찾기 +export function findNearestGuideline( + value: number, + guidelines: number[], +): { + nearest: number; + distance: number; +} { + let nearest = guidelines[0]; + let minDistance = Math.abs(value - guidelines[0]); + + for (const guideline of guidelines) { + const distance = Math.abs(value - guideline); + if (distance < minDistance) { + minDistance = distance; + nearest = guideline; + } + } + + return { nearest, distance: minDistance }; +} + +// 자석 스냅 (10px 이내면 스냅) +export function magneticSnap(value: number, guidelines: number[]): number { + const { nearest, distance } = findNearestGuideline(value, guidelines); + return distance <= GRID_CONFIG.SNAP_DISTANCE ? nearest : value; +}