diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 9350642e..3db56497 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, snapSizeToGrid } 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, @@ -307,7 +311,7 @@ export function CanvasElement({ const deltaX = e.clientX - dragStartRef.current.x; const deltaY = e.clientY - dragStartRef.current.y + scrollDelta; // 🔥 스크롤 변화량 반영 - // 임시 위치 계산 + // 임시 위치 계산 (드래그 중에는 부드럽게 이동) let rawX = Math.max(0, dragStart.elementX + deltaX); const rawY = Math.max(0, dragStart.elementY + deltaY); @@ -315,15 +319,12 @@ 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; - - setTempPosition({ x: snappedX, y: snappedY }); + // 드래그 중에는 스냅 없이 부드럽게 이동 + setTempPosition({ x: rawX, y: rawY }); // 🔥 다중 드래그 중 - 다른 위젯들의 위치 업데이트 if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) { - onMultiDragMove(element, { x: snappedX, y: snappedY }); + onMultiDragMove(element, { x: rawX, y: rawY }); } } else if (isResizing) { const deltaX = e.clientX - resizeStart.x; @@ -367,15 +368,13 @@ 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 boundedX = Math.max(0, Math.min(newX, canvasWidth - newWidth)); + const boundedY = Math.max(0, newY); - // 임시 크기/위치 저장 (스냅됨) - setTempPosition({ x: Math.max(0, snappedX), y: Math.max(0, snappedY) }); - setTempSize({ width: snappedWidth, height: snappedHeight }); + // 임시 크기/위치 저장 (부드러운 이동) + setTempPosition({ x: boundedX, y: boundedY }); + setTempSize({ width: newWidth, height: newHeight }); } }, [ @@ -386,7 +385,8 @@ export function CanvasElement({ element, canvasWidth, cellSize, - subGridSize, + verticalGuidelines, + horizontalGuidelines, selectedElements, allElements, onUpdateMultiple, @@ -398,10 +398,9 @@ export function CanvasElement({ // 마우스 업 처리 (이미 스냅된 위치 사용) const handleMouseUp = useCallback(() => { if (isDragging && tempPosition) { - // tempPosition은 이미 드래그 중에 마그네틱 스냅 적용됨 - // 다시 스냅하지 않고 그대로 사용! - let finalX = tempPosition.x; - const finalY = tempPosition.y; + // 마우스를 놓을 때 그리드에 스냅 + let finalX = magneticSnap(tempPosition.x, verticalGuidelines); + const finalY = magneticSnap(tempPosition.y, horizontalGuidelines); // X 좌표가 캔버스 너비를 벗어나지 않도록 최종 제한 const maxX = canvasWidth - element.size.width; @@ -459,20 +458,19 @@ export function CanvasElement({ } if (isResizing && tempPosition && tempSize) { - // tempPosition과 tempSize는 이미 리사이즈 중에 마그네틱 스냅 적용됨 - // 다시 스냅하지 않고 그대로 사용! - const finalX = tempPosition.x; - const finalY = tempPosition.y; - let finalWidth = tempSize.width; - const finalHeight = tempSize.height; + // 마우스를 놓을 때 그리드에 스냅 + const finalX = magneticSnap(tempPosition.x, verticalGuidelines); + const finalY = magneticSnap(tempPosition.y, horizontalGuidelines); + const finalWidth = snapSizeToGrid(tempSize.width, canvasWidth || 1560); + const finalHeight = snapSizeToGrid(tempSize.height, canvasWidth || 1560); // 가로 너비가 캔버스를 벗어나지 않도록 최종 제한 const maxWidth = canvasWidth - finalX; - finalWidth = Math.min(finalWidth, maxWidth); + const boundedWidth = Math.min(finalWidth, maxWidth); onUpdate(element.id, { position: { x: finalX, y: finalY }, - size: { width: finalWidth, height: finalHeight }, + size: { width: boundedWidth, height: finalHeight }, }); setTempPosition(null); @@ -504,6 +502,8 @@ export function CanvasElement({ allElements, dragStart.elementX, dragStart.elementY, + verticalGuidelines, + horizontalGuidelines, ]); // 🔥 자동 스크롤 루프 (requestAnimationFrame 사용) @@ -891,12 +891,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..c77cf541 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 { @@ -40,14 +48,14 @@ export const DashboardCanvas = forwardRef( onSelectElement, onSelectMultiple, onConfigureElement, - backgroundColor = "#f9fafb", + backgroundColor = "transparent", canvasWidth = 1560, canvasHeight = 768, }, 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); @@ -460,19 +466,11 @@ export const DashboardCanvas = forwardRef( return (
( }} /> ))} */} + {/* 그리드 박스들 (12px 간격, 캔버스 너비에 꽉 차게, 마지막 행 제외) */} + {verticalGuidelines.map((x, xIdx) => + horizontalGuidelines.slice(0, -1).map((y, yIdx) => ( +
+ )), + )} {/* 배치된 요소들 렌더링 */} {elements.length === 0 && (
@@ -513,6 +529,8 @@ export const DashboardCanvas = forwardRef( cellSize={cellSize} subGridSize={subGridSize} canvasWidth={canvasWidth} + verticalGuidelines={verticalGuidelines} + horizontalGuidelines={horizontalGuidelines} onUpdate={handleUpdateWithCollisionDetection} onUpdateMultiple={(updates) => { // 🔥 여러 요소 동시 업데이트 (충돌 감지 건너뛰기) @@ -552,10 +570,9 @@ export const DashboardCanvas = forwardRef( }} onRemove={onRemoveElement} onSelect={onSelectElement} - onConfigure={onConfigureElement} /> ))} - + {/* 🔥 선택 박스 렌더링 */} {selectionBox && selectionBoxStyle && (
(initialDashboardId || null); const [dashboardTitle, setDashboardTitle] = useState(""); const [isLoading, setIsLoading] = useState(false); - const [canvasBackgroundColor, setCanvasBackgroundColor] = useState("#f9fafb"); + const [canvasBackgroundColor, setCanvasBackgroundColor] = useState("transparent"); const canvasRef = useRef(null); // 저장 모달 상태 @@ -65,7 +72,11 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 화면 해상도 자동 감지 const [screenResolution] = useState(() => detectScreenResolution()); - const [resolution, setResolution] = useState(screenResolution); + const [resolution, setResolution] = useState(() => { + // 새 대시보드인 경우 (dashboardId 없음) 화면 해상도 감지값 사용 + // 기존 대시보드 편집인 경우 FHD로 시작 (로드 시 덮어씀) + return initialDashboardId ? "fhd" : detectScreenResolution(); + }); // resolution 변경 감지 및 요소 자동 조정 const handleResolutionChange = useCallback( @@ -89,8 +100,8 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 그리드에 스냅 (X, Y, 너비, 높이 모두) const snappedX = snapToGrid(scaledX, newCellSize); const snappedY = snapToGrid(el.position.y, newCellSize); - const snappedWidth = snapSizeToGrid(scaledWidth, 2, newCellSize); - const snappedHeight = snapSizeToGrid(el.size.height, 2, newCellSize); + const snappedWidth = snapSizeToGrid(scaledWidth, newConfig.width); + const snappedHeight = snapSizeToGrid(el.size.height, newConfig.width); return { ...el, @@ -136,8 +147,12 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 대시보드 ID가 props로 전달되면 로드 React.useEffect(() => { if (initialDashboardId) { + console.log("📝 기존 대시보드 편집 모드"); loadDashboard(initialDashboardId); + } else { + console.log("✨ 새 대시보드 생성 모드 - 감지된 해상도:", resolution); } + // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialDashboardId]); // 대시보드 데이터 로드 @@ -164,23 +179,19 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D settings, resolution: settings?.resolution, backgroundColor: settings?.backgroundColor, - currentResolution: resolution, }); - if (settings?.resolution) { - setResolution(settings.resolution); - console.log("✅ Resolution 설정됨:", settings.resolution); - } else { - console.log("⚠️ Resolution 없음, 기본값 유지:", resolution); - } - + // 배경색 설정 if (settings?.backgroundColor) { setCanvasBackgroundColor(settings.backgroundColor); console.log("✅ BackgroundColor 설정됨:", settings.backgroundColor); - } else { - console.log("⚠️ BackgroundColor 없음, 기본값 유지:", canvasBackgroundColor); } + // 해상도와 요소를 함께 설정 (해상도가 먼저 반영되어야 함) + const loadedResolution = settings?.resolution || "fhd"; + setResolution(loadedResolution); + console.log("✅ Resolution 설정됨:", loadedResolution); + // 요소들 설정 if (dashboard.elements && dashboard.elements.length > 0) { setElements(dashboard.elements); @@ -215,22 +226,25 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D return; } - // 기본 크기 설정 (서브그리드 기준) - const gridConfig = calculateGridConfig(canvasConfig.width); - const subGridSize = gridConfig.SUB_GRID_SIZE; - - // 서브그리드 기준 기본 크기 (픽셀) - let defaultWidth = subGridSize * 10; // 기본 위젯: 서브그리드 10칸 - let defaultHeight = subGridSize * 10; // 기본 위젯: 서브그리드 10칸 + // 기본 크기 설정 (그리드 박스 단위) + const boxSize = calculateBoxSize(canvasConfig.width); + + // 그리드 박스 단위 기본 크기 + let boxesWidth = 3; // 기본 위젯: 박스 3개 + let boxesHeight = 3; // 기본 위젯: 박스 3개 if (type === "chart") { - defaultWidth = subGridSize * 20; // 차트: 서브그리드 20칸 - defaultHeight = subGridSize * 15; // 차트: 서브그리드 15칸 + boxesWidth = 4; // 차트: 박스 4개 + boxesHeight = 3; // 차트: 박스 3개 } else if (type === "widget" && subtype === "calendar") { - defaultWidth = subGridSize * 10; // 달력: 서브그리드 10칸 - defaultHeight = subGridSize * 15; // 달력: 서브그리드 15칸 + boxesWidth = 3; // 달력: 박스 3개 + boxesHeight = 4; // 달력: 박스 4개 } + // 박스 개수를 픽셀로 변환 (마지막 간격 제거) + const defaultWidth = boxesWidth * boxSize + (boxesWidth - 1) * GRID_CONFIG.GRID_BOX_GAP; + const defaultHeight = boxesHeight * boxSize + (boxesHeight - 1) * GRID_CONFIG.GRID_BOX_GAP; + // 크기 유효성 검사 if (isNaN(defaultWidth) || isNaN(defaultHeight) || defaultWidth <= 0 || defaultHeight <= 0) { // console.error("Invalid size calculated:", { @@ -422,8 +436,15 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D id: el.id, type: el.type, subtype: el.subtype, - position: el.position, - size: el.size, + // 위치와 크기는 정수로 반올림 (DB integer 타입) + position: { + x: Math.round(el.position.x), + y: Math.round(el.position.y), + }, + size: { + width: Math.round(el.size.width), + height: Math.round(el.size.height), + }, title: el.title, customTitle: el.customTitle, showHeader: el.showHeader, @@ -449,6 +470,12 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D }, }; + console.log("💾 대시보드 업데이트 요청:", { + dashboardId, + updateData, + elementsCount: elementsData.length, + }); + savedDashboard = await dashboardApi.updateDashboard(dashboardId, updateData); } else { // 새 대시보드 생성 @@ -509,7 +536,18 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 성공 모달 표시 setSuccessModalOpen(true); } catch (error) { + console.error("❌ 대시보드 저장 실패:", error); const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류"; + + // 상세한 에러 정보 로깅 + if (error instanceof Error) { + console.error("Error details:", { + message: error.message, + stack: error.stack, + name: error.name, + }); + } + alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}`); throw error; } @@ -550,7 +588,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D {/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */}
= 3840 || height >= 2160) return "uhd"; - if (width >= 2560 || height >= 1440) return "qhd"; - if (width >= 1920 || height >= 1080) return "fhd"; - return "hd"; + // 2. 화면 해상도 + devicePixelRatio (Retina 디스플레이 대응) + const pixelRatio = window.devicePixelRatio || 1; + const physicalWidth = window.screen.width; + const physicalHeight = window.screen.height; + const logicalWidth = physicalWidth / pixelRatio; + const logicalHeight = physicalHeight / pixelRatio; + + let detectedResolution: Resolution; + + // 뷰포트와 논리적 해상도 중 더 큰 값을 기준으로 결정 + // (크램쉘 모드나 특수한 경우에도 대응) + const effectiveWidth = Math.max(viewportWidth, logicalWidth); + const effectiveHeight = Math.max(viewportHeight, logicalHeight); + + // 캔버스가 여유있게 들어갈 수 있는 크기로 결정 + // 여유 공간: 좌우 패딩, 사이드바 등을 고려하여 약 400-500px 여유 + if (effectiveWidth >= 3400) { + // UHD 캔버스 2940px + 여유 460px + detectedResolution = "uhd"; + } else if (effectiveWidth >= 2400) { + // QHD 캔버스 1960px + 여유 440px + detectedResolution = "qhd"; + } else if (effectiveWidth >= 1900) { + // FHD 캔버스 1560px + 여유 340px + detectedResolution = "fhd"; + } else { + // HD 캔버스 1160px 이하 + detectedResolution = "hd"; + } + + console.log("🖥️ 화면 해상도 자동 감지:", { + viewportWidth, + viewportHeight, + physicalWidth, + physicalHeight, + pixelRatio, + logicalWidth: Math.round(logicalWidth), + logicalHeight: Math.round(logicalHeight), + effectiveWidth: Math.round(effectiveWidth), + effectiveHeight: Math.round(effectiveHeight), + detectedResolution, + canvasSize: RESOLUTIONS[detectedResolution], + }); + + return detectedResolution; } /** diff --git a/frontend/components/admin/dashboard/gridUtils.ts b/frontend/components/admin/dashboard/gridUtils.ts index 3864e861..6094aa0e 100644 --- a/frontend/components/admin/dashboard/gridUtils.ts +++ b/frontend/components/admin/dashboard/gridUtils.ts @@ -12,6 +12,13 @@ export const GRID_CONFIG = { SNAP_THRESHOLD: 10, // 스냅 임계값 (px) ELEMENT_PADDING: 4, // 요소 주위 여백 (px) SUB_GRID_DIVISIONS: 5, // 각 그리드 칸을 5x5로 세분화 (세밀한 조정용) + // 가이드라인 시스템 + GUIDELINE_SPACING: 12, // 가이드라인 간격 (px) + SNAP_DISTANCE: 10, // 자석 스냅 거리 (px) + GUIDELINE_COLOR: "rgba(59, 130, 246, 0.3)", // 가이드라인 색상 + ROW_HEIGHT: 96, // 각 행의 높이 (12px * 8 = 96px) + GRID_BOX_SIZE: 40, // 그리드 박스 크기 (px) - [ ] 한 칸의 크기 + GRID_BOX_GAP: 12, // 그리드 박스 간 간격 (px) // CELL_SIZE와 CANVAS_WIDTH는 해상도에 따라 동적 계산 } as const; @@ -47,9 +54,11 @@ export function calculateGridConfig(canvasWidth: number) { /** * 실제 그리드 셀 크기 계산 (gap 포함) + * @param canvasWidth - 캔버스 너비 */ -export const getCellWithGap = () => { - return GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP; +export const getCellWithGap = (canvasWidth: number = 1560) => { + const boxSize = calculateBoxSize(canvasWidth); + return boxSize + GRID_CONFIG.GRID_BOX_GAP; }; /** @@ -63,14 +72,14 @@ export const getCanvasWidth = () => { /** * 좌표를 서브 그리드에 스냅 (세밀한 조정 가능) * @param value - 스냅할 좌표값 - * @param subGridSize - 서브 그리드 크기 (선택사항, 기본값: cellSize/3 ≈ 43px) + * @param subGridSize - 서브 그리드 크기 (선택사항) * @returns 스냅된 좌표값 */ export const snapToGrid = (value: number, subGridSize?: number): number => { - // 서브 그리드 크기가 지정되지 않으면 기본 그리드 크기의 1/3 사용 (3x3 서브그리드) - const snapSize = subGridSize ?? Math.floor(GRID_CONFIG.CELL_SIZE / 3); - - // 서브 그리드 단위로 스냅 + // 서브 그리드 크기가 지정되지 않으면 기본 박스 크기 사용 + const snapSize = subGridSize ?? calculateBoxSize(1560); + + // 그리드 단위로 스냅 const gridIndex = Math.round(value / snapSize); return gridIndex * snapSize; }; @@ -81,8 +90,9 @@ export const snapToGrid = (value: number, subGridSize?: number): number => { * @param cellSize - 셀 크기 (선택사항) * @returns 스냅된 좌표값 (임계값 내에 있으면 스냅, 아니면 원래 값) */ -export const snapToGridWithThreshold = (value: number, cellSize: number = GRID_CONFIG.CELL_SIZE): number => { - const snapped = snapToGrid(value, cellSize); +export const snapToGridWithThreshold = (value: number, cellSize?: number): number => { + const snapSize = cellSize ?? calculateBoxSize(1560); + const snapped = snapToGrid(value, snapSize); const distance = Math.abs(value - snapped); return distance <= GRID_CONFIG.SNAP_THRESHOLD ? snapped : value; @@ -95,15 +105,7 @@ export const snapToGridWithThreshold = (value: number, cellSize: number = GRID_C * @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; -}; +// 기존 snapSizeToGrid 제거 - 새 버전은 269번 줄에 있음 /** * 위치와 크기를 모두 그리드에 스냅 @@ -135,9 +137,10 @@ export const snapBoundsToGrid = (bounds: GridBounds, canvasWidth?: number, canva let snappedX = snapToGrid(bounds.position.x); let snappedY = snapToGrid(bounds.position.y); - // 크기 스냅 - const snappedWidth = snapSizeToGrid(bounds.size.width); - const snappedHeight = snapSizeToGrid(bounds.size.height); + // 크기 스냅 (canvasWidth 기본값 1560) + const width = canvasWidth || 1560; + const snappedWidth = snapSizeToGrid(bounds.size.width, width); + const snappedHeight = snapSizeToGrid(bounds.size.height, width); // 캔버스 경계 체크 if (canvasWidth) { @@ -198,3 +201,75 @@ 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 }; +} + +// 강제 스냅 (항상 가장 가까운 가이드라인에 스냅) +export function magneticSnap(value: number, guidelines: number[]): number { + const { nearest } = findNearestGuideline(value, guidelines); + return nearest; // 거리 체크 없이 무조건 스냅 +} + +// 크기를 그리드 박스 단위로 스냅 (박스 크기의 배수로만 가능) +export function snapSizeToGrid(size: number, canvasWidth: number): number { + const boxSize = calculateBoxSize(canvasWidth); + const cellSize = boxSize + GRID_CONFIG.GRID_BOX_GAP; // 박스 + 간격 + + // 최소 1개 박스 크기 + const minBoxes = 1; + const boxes = Math.max(minBoxes, Math.round(size / cellSize)); + + // 박스 개수에서 마지막 간격 제거 + return boxes * boxSize + (boxes - 1) * GRID_CONFIG.GRID_BOX_GAP; +} diff --git a/frontend/components/dashboard/DashboardViewer.tsx b/frontend/components/dashboard/DashboardViewer.tsx index 5438b0c0..f784c76f 100644 --- a/frontend/components/dashboard/DashboardViewer.tsx +++ b/frontend/components/dashboard/DashboardViewer.tsx @@ -328,26 +328,28 @@ export function DashboardViewer({
) : ( // 데스크톱: 기존 고정 캔버스 레이아웃 -
-
- {sortedElements.map((element) => ( - loadElementData(element)} - isMobile={false} - /> - ))} +
+
+
+ {sortedElements.map((element) => ( + loadElementData(element)} + isMobile={false} + /> + ))} +
)}