// POP 그리드 유틸리티 (리플로우, 겹침 해결, 위치 계산) import { PopGridPosition, GridMode, GRID_BREAKPOINTS, PopLayoutData, } from "../types/pop-layout"; // ======================================== // 리플로우 (행 그룹 기반 자동 재배치) // ======================================== /** * 행 그룹 리플로우 * * CSS Flexbox wrap 원리로 자동 재배치한다. * 1. 같은 행의 컴포넌트를 한 묶음으로 처리 * 2. 최소 2x2칸 보장 (터치 가능한 최소 크기) * 3. 한 줄에 안 들어가면 다음 줄로 줄바꿈 (숨김 없음) * 4. 설계 너비의 50% 이상인 컴포넌트는 전체 너비 확장 * 5. 리플로우 후 겹침 해결 */ export function convertAndResolvePositions( components: Array<{ id: string; position: PopGridPosition }>, targetMode: GridMode ): Array<{ id: string; position: PopGridPosition }> { if (components.length === 0) return []; const targetColumns = GRID_BREAKPOINTS[targetMode].columns; const designColumns = GRID_BREAKPOINTS["tablet_landscape"].columns; if (targetColumns >= designColumns) { return components.map(c => ({ id: c.id, position: { ...c.position } })); } const ratio = targetColumns / designColumns; const MIN_COL_SPAN = 2; const MIN_ROW_SPAN = 2; const rowGroups: Record> = {}; components.forEach(comp => { const r = comp.position.row; if (!rowGroups[r]) rowGroups[r] = []; rowGroups[r].push(comp); }); const placed: Array<{ id: string; position: PopGridPosition }> = []; let outputRow = 1; const sortedRows = Object.keys(rowGroups).map(Number).sort((a, b) => a - b); for (const rowKey of sortedRows) { const group = rowGroups[rowKey].sort((a, b) => a.position.col - b.position.col); let currentCol = 1; let maxRowSpanInLine = 0; for (const comp of group) { const pos = comp.position; const isMainContent = pos.colSpan >= designColumns * 0.5; let scaledSpan = isMainContent ? targetColumns : Math.max(MIN_COL_SPAN, Math.round(pos.colSpan * ratio)); scaledSpan = Math.min(scaledSpan, targetColumns); const scaledRowSpan = Math.max(MIN_ROW_SPAN, pos.rowSpan); if (currentCol + scaledSpan - 1 > targetColumns) { outputRow += Math.max(1, maxRowSpanInLine); currentCol = 1; maxRowSpanInLine = 0; } placed.push({ id: comp.id, position: { col: currentCol, row: outputRow, colSpan: scaledSpan, rowSpan: scaledRowSpan, }, }); maxRowSpanInLine = Math.max(maxRowSpanInLine, scaledRowSpan); currentCol += scaledSpan; } outputRow += Math.max(1, maxRowSpanInLine); } return resolveOverlaps(placed, 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 }> { 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; while (attempts < 100) { 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; } // ======================================== // 자동 배치 (새 컴포넌트 드롭 시) // ======================================== export function findNextEmptyPosition( existingPositions: PopGridPosition[], colSpan: number, rowSpan: number, columns: number ): PopGridPosition { let row = 1; let col = 1; let attempts = 0; while (attempts < 1000) { 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 }; } // ======================================== // 유효 위치 계산 // ======================================== /** * 컴포넌트의 유효 위치를 계산한다. * 우선순위: 1. 오버라이드 → 2. 자동 재배치 → 3. 원본 위치 */ function getEffectiveComponentPosition( componentId: string, layout: PopLayoutData, mode: GridMode, autoResolvedPositions?: Array<{ id: string; position: PopGridPosition }> ): PopGridPosition | null { const component = layout.components[componentId]; if (!component) return null; const override = layout.overrides?.[mode]?.positions?.[componentId]; if (override) { return { ...component.position, ...override }; } 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; } return component.position; } /** * 모든 컴포넌트의 유효 위치를 일괄 계산한다. * 숨김 처리된 컴포넌트는 제외. */ export function getAllEffectivePositions( layout: PopLayoutData, mode: GridMode ): Map { const result = new Map(); 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) { result.set(componentId, position); } }); return result; }