From 9dca73f4c4f5d10a64b57a6ffa0f2b963c094c11 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 22 Oct 2025 16:37:14 +0900 Subject: [PATCH 1/8] =?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; +} From 0a28445abe2f2337a7e5923ccd8eb3fb2624af87 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 22 Oct 2025 16:45:15 +0900 Subject: [PATCH 2/8] =?UTF-8?q?12=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EC=BB=AC?= =?UTF-8?q?=EB=9F=BC=20=EB=94=94=EC=9E=90=EC=9D=B8=20=EB=B0=8F=20=EC=BA=94?= =?UTF-8?q?=EB=B2=84=EC=8A=A4=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/admin/dashboard/DashboardCanvas.tsx | 9 +++++---- .../components/admin/dashboard/DashboardDesigner.tsx | 6 +++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx index 51351475..ac0d69a4 100644 --- a/frontend/components/admin/dashboard/DashboardCanvas.tsx +++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx @@ -48,7 +48,7 @@ export const DashboardCanvas = forwardRef( onSelectElement, onSelectMultiple, onConfigureElement, - backgroundColor = "#f9fafb", + backgroundColor = "transparent", canvasWidth = 1560, canvasHeight = 768, }, @@ -466,7 +466,7 @@ export const DashboardCanvas = forwardRef( return (
( horizontalGuidelines.map((y, yIdx) => (
diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 315179f6..36601623 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -47,7 +47,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D const [dashboardId, setDashboardId] = useState(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); // 저장 모달 상태 @@ -218,7 +218,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 기본 크기 설정 (서브그리드 기준) const gridConfig = calculateGridConfig(canvasConfig.width); const subGridSize = gridConfig.SUB_GRID_SIZE; - + // 서브그리드 기준 기본 크기 (픽셀) let defaultWidth = subGridSize * 10; // 기본 위젯: 서브그리드 10칸 let defaultHeight = subGridSize * 10; // 기본 위젯: 서브그리드 10칸 @@ -550,7 +550,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D {/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */}
Date: Wed, 22 Oct 2025 16:49:57 +0900 Subject: [PATCH 3/8] =?UTF-8?q?=EC=8A=A4=EB=83=85=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/admin/dashboard/gridUtils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/components/admin/dashboard/gridUtils.ts b/frontend/components/admin/dashboard/gridUtils.ts index a9aaeec8..083f819b 100644 --- a/frontend/components/admin/dashboard/gridUtils.ts +++ b/frontend/components/admin/dashboard/gridUtils.ts @@ -259,8 +259,8 @@ export function findNearestGuideline( 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; + const { nearest } = findNearestGuideline(value, guidelines); + return nearest; // 거리 체크 없이 무조건 스냅 } From 41c763c0196be01563b12101f848d4d359ecd2b1 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Wed, 22 Oct 2025 16:58:07 +0900 Subject: [PATCH 4/8] =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EB=B0=95?= =?UTF-8?q?=EC=8A=A4=20=EA=B8=B0=EB=B0=98=20=EC=8A=A4=EB=83=85=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/CanvasElement.tsx | 8 +-- .../admin/dashboard/DashboardDesigner.tsx | 36 +++++++++----- .../components/admin/dashboard/gridUtils.ts | 49 +++++++++++-------- 3 files changed, 56 insertions(+), 37 deletions(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 6214c8cb..2ecf8245 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, magneticSnap } from "./gridUtils"; +import { GRID_CONFIG, magneticSnap, snapSizeToGrid } from "./gridUtils"; // 위젯 동적 임포트 const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), { @@ -378,9 +378,9 @@ export function CanvasElement({ 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 snappedWidth = snapSizeToGrid(newWidth, canvasWidth || 1560); + const snappedHeight = snapSizeToGrid(newHeight, canvasWidth || 1560); // 스냅 후 경계 체크 const finalSnappedX = Math.max(0, Math.min(snappedX, canvasWidth - snappedWidth)); diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 36601623..83097678 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -7,7 +7,14 @@ import { DashboardTopMenu } from "./DashboardTopMenu"; import { ElementConfigSidebar } from "./ElementConfigSidebar"; import { DashboardSaveModal } from "./DashboardSaveModal"; import { DashboardElement, ElementType, ElementSubtype } from "./types"; -import { GRID_CONFIG, snapToGrid, snapSizeToGrid, calculateCellSize, calculateGridConfig } from "./gridUtils"; +import { + GRID_CONFIG, + snapToGrid, + snapSizeToGrid, + calculateCellSize, + calculateGridConfig, + calculateBoxSize, +} from "./gridUtils"; import { Resolution, RESOLUTIONS, detectScreenResolution } from "./ResolutionSelector"; import { DashboardProvider } from "@/contexts/DashboardContext"; import { useMenu } from "@/contexts/MenuContext"; @@ -89,8 +96,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, @@ -215,22 +222,25 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D return; } - // 기본 크기 설정 (서브그리드 기준) - const gridConfig = calculateGridConfig(canvasConfig.width); - const subGridSize = gridConfig.SUB_GRID_SIZE; + // 기본 크기 설정 (그리드 박스 단위) + const boxSize = calculateBoxSize(canvasConfig.width); - // 서브그리드 기준 기본 크기 (픽셀) - let defaultWidth = subGridSize * 10; // 기본 위젯: 서브그리드 10칸 - let defaultHeight = subGridSize * 10; // 기본 위젯: 서브그리드 10칸 + // 그리드 박스 단위 기본 크기 + 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:", { diff --git a/frontend/components/admin/dashboard/gridUtils.ts b/frontend/components/admin/dashboard/gridUtils.ts index 083f819b..6094aa0e 100644 --- a/frontend/components/admin/dashboard/gridUtils.ts +++ b/frontend/components/admin/dashboard/gridUtils.ts @@ -54,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; }; /** @@ -70,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; }; @@ -88,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; @@ -102,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번 줄에 있음 /** * 위치와 크기를 모두 그리드에 스냅 @@ -142,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) { @@ -264,3 +260,16 @@ 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; +} From 9bd84f898a96f2225c8b03331c3ada217c5d17af Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 23 Oct 2025 09:36:30 +0900 Subject: [PATCH 5/8] =?UTF-8?q?=EA=B7=B8=EB=A6=AC=EB=93=9C=20=EC=8B=9C?= =?UTF-8?q?=EC=8A=A4=ED=85=9C=EC=97=90=20=EB=93=9C=EB=9E=98=EA=B7=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/CanvasElement.tsx | 58 +++++++------------ 1 file changed, 22 insertions(+), 36 deletions(-) diff --git a/frontend/components/admin/dashboard/CanvasElement.tsx b/frontend/components/admin/dashboard/CanvasElement.tsx index 2ecf8245..3db56497 100644 --- a/frontend/components/admin/dashboard/CanvasElement.tsx +++ b/frontend/components/admin/dashboard/CanvasElement.tsx @@ -311,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); @@ -319,18 +319,12 @@ export function CanvasElement({ const maxX = canvasWidth - element.size.width; rawX = Math.min(rawX, maxX); - // 자석 스냅으로 변경 - const snappedX = magneticSnap(rawX, verticalGuidelines); - const snappedY = magneticSnap(rawY, horizontalGuidelines); - - // 스냅 후 X 좌표 다시 체크 - const finalSnappedX = Math.min(snappedX, maxX); - - setTempPosition({ x: finalSnappedX, y: snappedY }); + // 드래그 중에는 스냅 없이 부드럽게 이동 + setTempPosition({ x: rawX, y: rawY }); // 🔥 다중 드래그 중 - 다른 위젯들의 위치 업데이트 if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) { - onMultiDragMove(element, { x: finalSnappedX, y: snappedY }); + onMultiDragMove(element, { x: rawX, y: rawY }); } } else if (isResizing) { const deltaX = e.clientX - resizeStart.x; @@ -374,21 +368,13 @@ export function CanvasElement({ const maxWidth = canvasWidth - newX; newWidth = Math.min(newWidth, maxWidth); - // 자석 스냅으로 변경 - const snappedX = magneticSnap(newX, verticalGuidelines); - const snappedY = magneticSnap(newY, horizontalGuidelines); + // 리사이즈 중에는 스냅 없이 부드럽게 조절 + const boundedX = Math.max(0, Math.min(newX, canvasWidth - newWidth)); + const boundedY = Math.max(0, newY); - // 크기는 그리드 박스 단위로 스냅 - const snappedWidth = snapSizeToGrid(newWidth, canvasWidth || 1560); - const snappedHeight = snapSizeToGrid(newHeight, canvasWidth || 1560); - - // 스냅 후 경계 체크 - const finalSnappedX = Math.max(0, Math.min(snappedX, canvasWidth - snappedWidth)); - const finalSnappedY = Math.max(0, snappedY); - - // 임시 크기/위치 저장 (스냅됨) - setTempPosition({ x: finalSnappedX, y: finalSnappedY }); - setTempSize({ width: snappedWidth, height: snappedHeight }); + // 임시 크기/위치 저장 (부드러운 이동) + setTempPosition({ x: boundedX, y: boundedY }); + setTempSize({ width: newWidth, height: newHeight }); } }, [ @@ -412,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; @@ -473,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); @@ -518,6 +502,8 @@ export function CanvasElement({ allElements, dragStart.elementX, dragStart.elementY, + verticalGuidelines, + horizontalGuidelines, ]); // 🔥 자동 스크롤 루프 (requestAnimationFrame 사용) From 298fd11169c61ced8cc5b8e8c18cba847c9c59c5 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 23 Oct 2025 09:52:14 +0900 Subject: [PATCH 6/8] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardDesigner.tsx | 46 +++++++++++++------ .../components/dashboard/DashboardViewer.tsx | 42 +++++++++-------- 2 files changed, 55 insertions(+), 33 deletions(-) diff --git a/frontend/components/admin/dashboard/DashboardDesigner.tsx b/frontend/components/admin/dashboard/DashboardDesigner.tsx index 83097678..0f387301 100644 --- a/frontend/components/admin/dashboard/DashboardDesigner.tsx +++ b/frontend/components/admin/dashboard/DashboardDesigner.tsx @@ -72,7 +72,7 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D // 화면 해상도 자동 감지 const [screenResolution] = useState(() => detectScreenResolution()); - const [resolution, setResolution] = useState(screenResolution); + const [resolution, setResolution] = useState("fhd"); // 초기값은 FHD, 로드 시 덮어씀 // resolution 변경 감지 및 요소 자동 조정 const handleResolutionChange = useCallback( @@ -171,23 +171,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); @@ -432,8 +428,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, @@ -459,6 +462,12 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D }, }; + console.log("💾 대시보드 업데이트 요청:", { + dashboardId, + updateData, + elementsCount: elementsData.length, + }); + savedDashboard = await dashboardApi.updateDashboard(dashboardId, updateData); } else { // 새 대시보드 생성 @@ -519,7 +528,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; } 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} + /> + ))} +
)} From 73e3bf4159c24f2a8bb22f0a59ccc9805ead27d5 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 23 Oct 2025 09:56:35 +0900 Subject: [PATCH 7/8] =?UTF-8?q?=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EA=B0=90=EC=A7=80=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/DashboardCanvas.tsx | 4 ++-- .../admin/dashboard/DashboardDesigner.tsx | 10 +++++++- .../admin/dashboard/ResolutionSelector.tsx | 24 +++++++++++++++---- 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/frontend/components/admin/dashboard/DashboardCanvas.tsx b/frontend/components/admin/dashboard/DashboardCanvas.tsx index ac0d69a4..c77cf541 100644 --- a/frontend/components/admin/dashboard/DashboardCanvas.tsx +++ b/frontend/components/admin/dashboard/DashboardCanvas.tsx @@ -492,9 +492,9 @@ export const DashboardCanvas = forwardRef( }} /> ))} */} - {/* 그리드 박스들 (12px 간격, 캔버스 너비에 꽉 차게) */} + {/* 그리드 박스들 (12px 간격, 캔버스 너비에 꽉 차게, 마지막 행 제외) */} {verticalGuidelines.map((x, xIdx) => - horizontalGuidelines.map((y, yIdx) => ( + horizontalGuidelines.slice(0, -1).map((y, yIdx) => (
(() => detectScreenResolution()); - const [resolution, setResolution] = useState("fhd"); // 초기값은 FHD, 로드 시 덮어씀 + const [resolution, setResolution] = useState(() => { + // 새 대시보드인 경우 (dashboardId 없음) 화면 해상도 감지값 사용 + // 기존 대시보드 편집인 경우 FHD로 시작 (로드 시 덮어씀) + return initialDashboardId ? "fhd" : detectScreenResolution(); + }); // resolution 변경 감지 및 요소 자동 조정 const handleResolutionChange = useCallback( @@ -143,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]); // 대시보드 데이터 로드 diff --git a/frontend/components/admin/dashboard/ResolutionSelector.tsx b/frontend/components/admin/dashboard/ResolutionSelector.tsx index 5f5bda53..62ba377d 100644 --- a/frontend/components/admin/dashboard/ResolutionSelector.tsx +++ b/frontend/components/admin/dashboard/ResolutionSelector.tsx @@ -57,11 +57,27 @@ export function detectScreenResolution(): Resolution { const width = window.screen.width; const height = window.screen.height; + let detectedResolution: Resolution; + // 화면 해상도에 따라 적절한 캔버스 해상도 반환 - if (width >= 3840 || height >= 2160) return "uhd"; - if (width >= 2560 || height >= 1440) return "qhd"; - if (width >= 1920 || height >= 1080) return "fhd"; - return "hd"; + if (width >= 3840 || height >= 2160) { + detectedResolution = "uhd"; + } else if (width >= 2560 || height >= 1440) { + detectedResolution = "qhd"; + } else if (width >= 1920 || height >= 1080) { + detectedResolution = "fhd"; + } else { + detectedResolution = "hd"; + } + + console.log("🖥️ 화면 해상도 자동 감지:", { + screenWidth: width, + screenHeight: height, + detectedResolution, + canvasSize: RESOLUTIONS[detectedResolution], + }); + + return detectedResolution; } /** From d29d4b596db28ee08240696f5629cdb6067c0ab3 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Thu, 23 Oct 2025 10:06:00 +0900 Subject: [PATCH 8/8] =?UTF-8?q?=ED=99=94=EB=A9=B4=EB=84=88=EB=B9=84=20?= =?UTF-8?q?=EA=B0=90=EC=A7=80=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dashboard/ResolutionSelector.tsx | 41 +++++++++++++++---- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/frontend/components/admin/dashboard/ResolutionSelector.tsx b/frontend/components/admin/dashboard/ResolutionSelector.tsx index 62ba377d..33b444f0 100644 --- a/frontend/components/admin/dashboard/ResolutionSelector.tsx +++ b/frontend/components/admin/dashboard/ResolutionSelector.tsx @@ -54,25 +54,50 @@ interface ResolutionSelectorProps { export function detectScreenResolution(): Resolution { if (typeof window === "undefined") return "fhd"; - const width = window.screen.width; - const height = window.screen.height; + // 1. 브라우저 뷰포트 크기 (실제 사용 가능한 공간) + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + // 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; - // 화면 해상도에 따라 적절한 캔버스 해상도 반환 - if (width >= 3840 || height >= 2160) { + // 뷰포트와 논리적 해상도 중 더 큰 값을 기준으로 결정 + // (크램쉘 모드나 특수한 경우에도 대응) + const effectiveWidth = Math.max(viewportWidth, logicalWidth); + const effectiveHeight = Math.max(viewportHeight, logicalHeight); + + // 캔버스가 여유있게 들어갈 수 있는 크기로 결정 + // 여유 공간: 좌우 패딩, 사이드바 등을 고려하여 약 400-500px 여유 + if (effectiveWidth >= 3400) { + // UHD 캔버스 2940px + 여유 460px detectedResolution = "uhd"; - } else if (width >= 2560 || height >= 1440) { + } else if (effectiveWidth >= 2400) { + // QHD 캔버스 1960px + 여유 440px detectedResolution = "qhd"; - } else if (width >= 1920 || height >= 1080) { + } else if (effectiveWidth >= 1900) { + // FHD 캔버스 1560px + 여유 340px detectedResolution = "fhd"; } else { + // HD 캔버스 1160px 이하 detectedResolution = "hd"; } console.log("🖥️ 화면 해상도 자동 감지:", { - screenWidth: width, - screenHeight: height, + 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], });