From a53940cff9aeea4cd02391ae12410dfbd5aaf94d Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 13 Oct 2025 18:37:18 +0900 Subject: [PATCH] =?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; } -- 2.43.0