import { PopGridPosition, GridMode, GRID_BREAKPOINTS, GridBreakpoint, GapPreset, GAP_PRESETS, PopLayoutDataV5, PopComponentDefinitionV5, BLOCK_SIZE, BLOCK_GAP, BLOCK_PADDING, getBlockColumns, } from "../types/pop-layout"; // ======================================== // Gap/Padding 조정 (V6: 블록 간격 고정이므로 항상 원본 반환) // ======================================== export function getAdjustedBreakpoint( base: GridBreakpoint, preset: GapPreset ): GridBreakpoint { return { ...base }; } // ======================================== // 그리드 위치 변환 (V6: 단일 좌표계이므로 변환 불필요) // ======================================== /** * V6: 단일 좌표계이므로 변환 없이 원본 반환 * @deprecated V6에서는 좌표 변환이 불필요합니다 */ export function convertPositionToMode( position: PopGridPosition, targetMode: GridMode ): PopGridPosition { return position; } /** * V6 행 그룹 리플로우 (방식 F) * * 원리: CSS Flexbox wrap과 동일. * 1. 같은 행의 컴포넌트를 한 묶음으로 처리 * 2. 최소 2x2칸 보장 (터치 가능한 최소 크기) * 3. 한 줄에 안 들어가면 다음 줄로 줄바꿈 (숨김 없음) * 4. 설계 너비의 50% 이상 → 전체 너비 확장 * 5. 리플로우 후 겹침 해결 (resolveOverlaps) */ 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; // 1. 원본 row 기준 그룹핑 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; // 2. 각 행 그룹을 순서대로 처리 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); } // 3. 겹침 해결 (행 그룹 간 rowSpan 충돌 처리) return resolveOverlaps(placed, targetColumns); } // ======================================== // 검토 필요 판별 (V6: 자동 줄바꿈이므로 검토 필요 없음) // ======================================== /** * V6: 단일 좌표계 + 자동 줄바꿈이므로 검토 필요 없음 * 항상 false 반환 */ export function needsReview( currentMode: GridMode, hasOverride: boolean ): boolean { return false; } /** * @deprecated V6에서는 자동 줄바꿈이므로 화면 밖 개념 없음 */ export function isOutOfBounds( originalPosition: PopGridPosition, currentMode: GridMode, overridePosition?: PopGridPosition | null ): boolean { return false; } // ======================================== // 겹침 감지 및 해결 // ======================================== /** * 두 위치가 겹치는지 확인 */ 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; } // ======================================== // 좌표 변환 // ======================================== /** * V6: 마우스 좌표 → 블록 그리드 좌표 변환 * 블록 크기가 고정(BLOCK_SIZE)이므로 계산이 단순함 */ 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; const cellStride = BLOCK_SIZE + gap; const col = Math.max(1, Math.min(columns, Math.floor(relX / cellStride) + 1)); const row = Math.max(1, Math.floor(relY / cellStride) + 1); return { col, row }; } /** * V6: 블록 그리드 좌표 → 픽셀 좌표 변환 */ 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 cellStride = BLOCK_SIZE + gap; return { x: padding + (col - 1) * cellStride, y: padding + (row - 1) * cellStride, width: BLOCK_SIZE * colSpan + gap * (colSpan - 1), height: BLOCK_SIZE * 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; } /** * 모든 컴포넌트의 유효 위치를 일괄 계산합니다. * 숨김 처리된 컴포넌트는 제외됩니다. * * v5.1: 자동 줄바꿈 시스템으로 인해 모든 컴포넌트가 그리드 안에 배치되므로 * "화면 밖" 개념이 제거되었습니다. */ 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 ); // v5.1: 자동 줄바꿈으로 인해 모든 컴포넌트가 그리드 안에 있음 // 따라서 추가 필터링 불필요 if (position) { result.set(componentId, position); } }); return result; } // ======================================== // V5 → V6 런타임 변환 (DB 미수정, 로드 시 변환) // ======================================== const V5_BASE_COLUMNS = 12; const V5_BASE_ROW_HEIGHT = 48; const V5_BASE_GAP = 16; const V5_DESIGN_WIDTH = 1024; /** * V5 레이아웃 판별: gridConfig.rowHeight가 V5 기본값(48)이고 * 좌표가 12칸 체계인 경우만 V5로 판정 */ function isV5GridConfig(layout: PopLayoutDataV5): boolean { if (layout.gridConfig?.rowHeight === BLOCK_SIZE) return false; const maxCol = Object.values(layout.components).reduce((max, comp) => { const end = comp.position.col + comp.position.colSpan - 1; return Math.max(max, end); }, 0); return maxCol <= V5_BASE_COLUMNS; } function convertV5PositionToV6( pos: PopGridPosition, v6DesignColumns: number, ): PopGridPosition { const colRatio = v6DesignColumns / V5_BASE_COLUMNS; const rowRatio = (V5_BASE_ROW_HEIGHT + V5_BASE_GAP) / (BLOCK_SIZE + BLOCK_GAP); const newCol = Math.max(1, Math.round((pos.col - 1) * colRatio) + 1); let newColSpan = Math.max(1, Math.round(pos.colSpan * colRatio)); const newRowSpan = Math.max(1, Math.round(pos.rowSpan * rowRatio)); if (newCol + newColSpan - 1 > v6DesignColumns) { newColSpan = v6DesignColumns - newCol + 1; } return { col: newCol, row: pos.row, colSpan: newColSpan, rowSpan: newRowSpan }; } /** * V5 레이아웃을 V6 블록 좌표로 런타임 변환 * - 기본 모드(tablet_landscape) 좌표를 블록 단위로 변환 * - 모드별 overrides 폐기 (자동 줄바꿈으로 대체) * - DB 데이터는 건드리지 않음 (메모리에서만 변환) */ export function convertV5LayoutToV6(layout: PopLayoutDataV5): PopLayoutDataV5 { // V5 오버라이드는 V6에서 무효 (4/6/8칸용 좌표가 13/22/31칸에 맞지 않음) // 좌표 변환 필요 여부와 무관하게 항상 제거 if (!isV5GridConfig(layout)) { return { ...layout, gridConfig: { rowHeight: BLOCK_SIZE, gap: BLOCK_GAP, padding: BLOCK_PADDING, }, overrides: undefined, }; } const v6Columns = getBlockColumns(V5_DESIGN_WIDTH); const rowGroups: Record = {}; Object.entries(layout.components).forEach(([id, comp]) => { const r = comp.position.row; if (!rowGroups[r]) rowGroups[r] = []; rowGroups[r].push(id); }); const convertedPositions: Record = {}; Object.entries(layout.components).forEach(([id, comp]) => { convertedPositions[id] = convertV5PositionToV6(comp.position, v6Columns); }); const sortedRows = Object.keys(rowGroups).map(Number).sort((a, b) => a - b); const rowMapping: Record = {}; let v6Row = 1; for (const v5Row of sortedRows) { rowMapping[v5Row] = v6Row; const maxSpan = Math.max( ...rowGroups[v5Row].map(id => convertedPositions[id].rowSpan) ); v6Row += maxSpan; } const newComponents = { ...layout.components }; Object.entries(newComponents).forEach(([id, comp]) => { const converted = convertedPositions[id]; const mappedRow = rowMapping[comp.position.row] ?? converted.row; newComponents[id] = { ...comp, position: { ...converted, row: mappedRow }, }; }); const newModals = layout.modals?.map(modal => { const modalComps = { ...modal.components }; Object.entries(modalComps).forEach(([id, comp]) => { modalComps[id] = { ...comp, position: convertV5PositionToV6(comp.position, v6Columns), }; }); return { ...modal, gridConfig: { rowHeight: BLOCK_SIZE, gap: BLOCK_GAP, padding: BLOCK_PADDING }, components: modalComps, overrides: undefined, }; }); return { ...layout, gridConfig: { rowHeight: BLOCK_SIZE, gap: BLOCK_GAP, padding: BLOCK_PADDING }, components: newComponents, overrides: undefined, modals: newModals, }; }