import { PopGridPosition, GridMode, GRID_BREAKPOINTS, PopLayoutDataV5, PopComponentDefinitionV5, } from "../types/pop-layout"; // ======================================== // 그리드 위치 변환 // ======================================== /** * 12칸 기준 위치를 다른 모드로 변환 */ export function convertPositionToMode( position: PopGridPosition, targetMode: GridMode ): PopGridPosition { const sourceColumns = 12; const targetColumns = GRID_BREAKPOINTS[targetMode].columns; // 같은 칸 수면 그대로 반환 if (sourceColumns === targetColumns) { return position; } const ratio = targetColumns / sourceColumns; // 열 위치 변환 let newCol = Math.max(1, Math.ceil((position.col - 1) * ratio) + 1); let newColSpan = Math.max(1, Math.round(position.colSpan * ratio)); // 범위 초과 방지 if (newCol > targetColumns) { newCol = 1; } if (newCol + newColSpan - 1 > targetColumns) { newColSpan = targetColumns - newCol + 1; } return { col: newCol, row: position.row, colSpan: Math.max(1, newColSpan), rowSpan: position.rowSpan, }; } /** * 여러 컴포넌트를 모드별로 변환하고 겹침 해결 */ export function convertAndResolvePositions( components: Array<{ id: string; position: PopGridPosition }>, targetMode: GridMode ): Array<{ id: string; position: PopGridPosition }> { const targetColumns = GRID_BREAKPOINTS[targetMode].columns; // 1단계: 각 컴포넌트를 비율로 변환 const converted = components.map(comp => ({ id: comp.id, position: convertPositionToMode(comp.position, targetMode), })); // 2단계: 겹침 해결 (아래로 밀기) return resolveOverlaps(converted, targetColumns); } // ======================================== // 초과 컴포넌트 감지 // ======================================== /** * 컴포넌트가 현재 모드에서 화면 밖으로 초과하는지 확인 * * 판단 우선순위: * 1. 오버라이드 위치가 있으면 오버라이드 위치로 판단 * 2. 오버라이드 없으면 원본 위치로 판단 * * @param originalPosition 원본 위치 (12칸 기준) * @param currentMode 현재 그리드 모드 * @param overridePosition 오버라이드 위치 (있으면) */ export function isOutOfBounds( originalPosition: PopGridPosition, currentMode: GridMode, overridePosition?: PopGridPosition | null ): boolean { const targetColumns = GRID_BREAKPOINTS[currentMode].columns; // 12칸 모드면 초과 불가 if (targetColumns === 12) { return false; } // 오버라이드가 있으면 오버라이드 위치로 판단 if (overridePosition) { // 오버라이드 시작 열이 범위 내면 "초과 아님" return overridePosition.col > targetColumns; } // 오버라이드 없으면 원본 시작 열이 현재 모드 칸 수를 초과하면 "화면 밖" return originalPosition.col > targetColumns; } // ======================================== // 겹침 감지 및 해결 // ======================================== /** * 두 위치가 겹치는지 확인 */ export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean { // 열 겹침 체크 const aColEnd = a.col + a.colSpan - 1; const bColEnd = b.col + b.colSpan - 1; const colOverlap = !(aColEnd < b.col || bColEnd < a.col); // 행 겹침 체크 const aRowEnd = a.row + a.rowSpan - 1; const bRowEnd = b.row + b.rowSpan - 1; const rowOverlap = !(aRowEnd < b.row || bRowEnd < a.row); return colOverlap && rowOverlap; } /** * 겹침 해결 (아래로 밀기) */ export function resolveOverlaps( positions: Array<{ id: string; position: PopGridPosition }>, columns: number ): Array<{ id: string; position: PopGridPosition }> { // row, col 순으로 정렬 const sorted = [...positions].sort((a, b) => a.position.row - b.position.row || a.position.col - b.position.col ); const resolved: Array<{ id: string; position: PopGridPosition }> = []; sorted.forEach((item) => { let { row, col, colSpan, rowSpan } = item.position; // 열이 범위를 초과하면 조정 if (col + colSpan - 1 > columns) { colSpan = columns - col + 1; } // 기존 배치와 겹치면 아래로 이동 let attempts = 0; const maxAttempts = 100; while (attempts < maxAttempts) { const currentPos: PopGridPosition = { col, row, colSpan, rowSpan }; const hasOverlap = resolved.some(r => isOverlapping(currentPos, r.position)); if (!hasOverlap) break; row++; attempts++; } resolved.push({ id: item.id, position: { col, row, colSpan, rowSpan }, }); }); return resolved; } // ======================================== // 좌표 변환 // ======================================== /** * 마우스 좌표 → 그리드 좌표 변환 * * CSS Grid 계산 방식: * - 사용 가능 너비 = 캔버스 너비 - 패딩*2 - gap*(columns-1) * - 각 칸 너비 = 사용 가능 너비 / columns * - 셀 N의 시작 X = padding + (N-1) * (칸너비 + gap) */ export function mouseToGridPosition( mouseX: number, mouseY: number, canvasRect: DOMRect, columns: number, rowHeight: number, gap: number, padding: number ): { col: number; row: number } { // 캔버스 내 상대 위치 (패딩 영역 포함) const relX = mouseX - canvasRect.left - padding; const relY = mouseY - canvasRect.top - padding; // CSS Grid 1fr 계산과 동일하게 // 사용 가능 너비 = 전체 너비 - 양쪽 패딩 - (칸 사이 gap) const availableWidth = canvasRect.width - padding * 2 - gap * (columns - 1); const colWidth = availableWidth / columns; // 각 셀의 실제 간격 (셀 너비 + gap) const cellStride = colWidth + gap; // 그리드 좌표 계산 (1부터 시작) // relX를 cellStride로 나누면 몇 번째 칸인지 알 수 있음 const col = Math.max(1, Math.min(columns, Math.floor(relX / cellStride) + 1)); const row = Math.max(1, Math.floor(relY / (rowHeight + gap)) + 1); return { col, row }; } /** * 그리드 좌표 → 픽셀 좌표 변환 */ export function gridToPixelPosition( col: number, row: number, colSpan: number, rowSpan: number, canvasWidth: number, columns: number, rowHeight: number, gap: number, padding: number ): { x: number; y: number; width: number; height: number } { const totalGap = gap * (columns - 1); const colWidth = (canvasWidth - padding * 2 - totalGap) / columns; return { x: padding + (col - 1) * (colWidth + gap), y: padding + (row - 1) * (rowHeight + gap), width: colWidth * colSpan + gap * (colSpan - 1), height: rowHeight * rowSpan + gap * (rowSpan - 1), }; } // ======================================== // 위치 검증 // ======================================== /** * 위치가 그리드 범위 내에 있는지 확인 */ export function isValidPosition( position: PopGridPosition, columns: number ): boolean { return ( position.col >= 1 && position.row >= 1 && position.colSpan >= 1 && position.rowSpan >= 1 && position.col + position.colSpan - 1 <= columns ); } /** * 위치를 그리드 범위 내로 조정 */ export function clampPosition( position: PopGridPosition, columns: number ): PopGridPosition { let { col, row, colSpan, rowSpan } = position; // 최소값 보장 col = Math.max(1, col); row = Math.max(1, row); colSpan = Math.max(1, colSpan); rowSpan = Math.max(1, rowSpan); // 열 범위 초과 방지 if (col + colSpan - 1 > columns) { if (col > columns) { col = 1; } colSpan = columns - col + 1; } return { col, row, colSpan, rowSpan }; } // ======================================== // 자동 배치 // ======================================== /** * 다음 빈 위치 찾기 */ export function findNextEmptyPosition( existingPositions: PopGridPosition[], colSpan: number, rowSpan: number, columns: number ): PopGridPosition { let row = 1; let col = 1; const maxAttempts = 1000; let attempts = 0; while (attempts < maxAttempts) { const candidatePos: PopGridPosition = { col, row, colSpan, rowSpan }; // 범위 체크 if (col + colSpan - 1 > columns) { col = 1; row++; continue; } // 겹침 체크 const hasOverlap = existingPositions.some(pos => isOverlapping(candidatePos, pos) ); if (!hasOverlap) { return candidatePos; } // 다음 위치로 이동 col++; if (col + colSpan - 1 > columns) { col = 1; row++; } attempts++; } // 실패 시 마지막 행에 배치 return { col: 1, row: row + 1, colSpan, rowSpan }; } /** * 컴포넌트들을 자동으로 배치 */ export function autoLayoutComponents( components: Array<{ id: string; colSpan: number; rowSpan: number }>, columns: number ): Array<{ id: string; position: PopGridPosition }> { const result: Array<{ id: string; position: PopGridPosition }> = []; let currentRow = 1; let currentCol = 1; components.forEach(comp => { // 현재 행에 공간이 부족하면 다음 행으로 if (currentCol + comp.colSpan - 1 > columns) { currentRow++; currentCol = 1; } result.push({ id: comp.id, position: { col: currentCol, row: currentRow, colSpan: comp.colSpan, rowSpan: comp.rowSpan, }, }); currentCol += comp.colSpan; }); return result; } // ======================================== // 유효 위치 계산 (통합 함수) // ======================================== /** * 컴포넌트의 유효 위치를 계산합니다. * 우선순위: 1. 오버라이드 → 2. 자동 재배치 → 3. 원본 위치 * * @param componentId 컴포넌트 ID * @param layout 전체 레이아웃 데이터 * @param mode 현재 그리드 모드 * @param autoResolvedPositions 미리 계산된 자동 재배치 위치 (선택적) */ export function getEffectiveComponentPosition( componentId: string, layout: PopLayoutDataV5, mode: GridMode, autoResolvedPositions?: Array<{ id: string; position: PopGridPosition }> ): PopGridPosition | null { const component = layout.components[componentId]; if (!component) return null; // 1순위: 오버라이드가 있으면 사용 const override = layout.overrides?.[mode]?.positions?.[componentId]; if (override) { return { ...component.position, ...override }; } // 2순위: 자동 재배치된 위치 사용 if (autoResolvedPositions) { const autoResolved = autoResolvedPositions.find(p => p.id === componentId); if (autoResolved) { return autoResolved.position; } } else { // 자동 재배치 직접 계산 const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({ id, position: comp.position, })); const resolved = convertAndResolvePositions(componentsArray, mode); const autoResolved = resolved.find(p => p.id === componentId); if (autoResolved) { return autoResolved.position; } } // 3순위: 원본 위치 (12칸 모드) return component.position; } /** * 모든 컴포넌트의 유효 위치를 일괄 계산합니다. * 숨김 처리된 컴포넌트와 화면 밖 컴포넌트는 제외됩니다. */ export function getAllEffectivePositions( layout: PopLayoutDataV5, mode: GridMode ): Map { const result = new Map(); // 숨김 처리된 컴포넌트 ID 목록 const hiddenIds = layout.overrides?.[mode]?.hidden || []; // 자동 재배치 위치 미리 계산 const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({ id, position: comp.position, })); const autoResolvedPositions = convertAndResolvePositions(componentsArray, mode); // 각 컴포넌트의 유효 위치 계산 Object.keys(layout.components).forEach(componentId => { // 숨김 처리된 컴포넌트는 제외 if (hiddenIds.includes(componentId)) { return; } const position = getEffectiveComponentPosition( componentId, layout, mode, autoResolvedPositions ); if (position) { // 화면 밖 컴포넌트도 제외 (오버라이드 위치 고려) const overridePos = layout.overrides?.[mode]?.positions?.[componentId]; const overridePosition = overridePos ? { ...layout.components[componentId].position, ...overridePos } : null; if (!isOutOfBounds(layout.components[componentId].position, mode, overridePosition)) { result.set(componentId, position); } } }); return result; }