import { PopGridPosition, GridMode, GRID_BREAKPOINTS, GridBreakpoint, GapPreset, GAP_PRESETS, PopLayoutDataV5, PopComponentDefinitionV5, } from "../types/pop-layout"; // ======================================== // Gap/Padding 조정 // ======================================== /** * Gap 프리셋에 따라 breakpoint의 gap/padding 조정 * * @param base 기본 breakpoint 설정 * @param preset Gap 프리셋 ("narrow" | "medium" | "wide") * @returns 조정된 breakpoint (gap, padding 계산됨) */ export function getAdjustedBreakpoint( base: GridBreakpoint, preset: GapPreset ): GridBreakpoint { const multiplier = GAP_PRESETS[preset]?.multiplier || 1.0; return { ...base, gap: Math.round(base.gap * multiplier), padding: Math.max(8, Math.round(base.padding * multiplier)), // 최소 8px }; } // ======================================== // 그리드 위치 변환 // ======================================== /** * 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, }; } /** * 여러 컴포넌트를 모드별로 변환하고 겹침 해결 * * v5.1 자동 줄바꿈: * - 원본 col > targetColumns인 컴포넌트는 자동으로 맨 아래에 배치 * - 정보 손실 방지: 모든 컴포넌트가 그리드 안에 배치됨 */ 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; // 1단계: 각 컴포넌트를 비율로 변환 (원본 col 보존) const converted = components.map(comp => ({ id: comp.id, position: convertPositionToMode(comp.position, targetMode), originalCol: comp.position.col, // 원본 col 보존 })); // 2단계: 정상 컴포넌트 vs 초과 컴포넌트 분리 const normalComponents = converted.filter(c => c.originalCol <= targetColumns); const overflowComponents = converted.filter(c => c.originalCol > targetColumns); // 3단계: 정상 컴포넌트의 최대 row 계산 const maxRow = normalComponents.length > 0 ? Math.max(...normalComponents.map(c => c.position.row + c.position.rowSpan - 1)) : 0; // 4단계: 초과 컴포넌트들을 맨 아래에 순차 배치 let currentRow = maxRow + 1; const wrappedComponents = overflowComponents.map(comp => { const wrappedPosition: PopGridPosition = { col: 1, // 왼쪽 끝부터 시작 row: currentRow, colSpan: Math.min(comp.position.colSpan, targetColumns), // 최대 칸 수 제한 rowSpan: comp.position.rowSpan, }; currentRow += comp.position.rowSpan; // 다음 행으로 이동 return { id: comp.id, position: wrappedPosition, }; }); // 5단계: 정상 + 줄바꿈 컴포넌트 병합 const adjusted = [ ...normalComponents.map(c => ({ id: c.id, position: c.position })), ...wrappedComponents, ]; // 6단계: 겹침 해결 (아래로 밀기) return resolveOverlaps(adjusted, targetColumns); } // ======================================== // 검토 필요 판별 // ======================================== /** * 컴포넌트가 현재 모드에서 "검토 필요" 상태인지 확인 * * v5.1 검토 필요 기준: * - 12칸 모드(기본 모드)가 아님 * - 해당 모드에서 오버라이드가 없음 (아직 편집 안 함) * * @param currentMode 현재 그리드 모드 * @param hasOverride 해당 모드에서 오버라이드 존재 여부 * @returns true = 검토 필요, false = 검토 완료 또는 불필요 */ export function needsReview( currentMode: GridMode, hasOverride: boolean ): boolean { const targetColumns = GRID_BREAKPOINTS[currentMode].columns; // 12칸 모드는 기본 모드이므로 검토 불필요 if (targetColumns === 12) { return false; } // 오버라이드가 있으면 이미 편집함 → 검토 완료 if (hasOverride) { return false; } // 오버라이드 없으면 → 검토 필요 return true; } /** * @deprecated v5.1부터 needsReview() 사용 권장 * * 기존 isOutOfBounds는 "화면 밖" 개념이었으나, * v5.1 자동 줄바꿈으로 인해 모든 컴포넌트가 그리드 안에 배치됩니다. * 대신 needsReview()로 "검토 필요" 여부를 판별하세요. */ 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; } // 오버라이드 없으면 원본 col로 판단 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; } /** * 모든 컴포넌트의 유효 위치를 일괄 계산합니다. * 숨김 처리된 컴포넌트는 제외됩니다. * * 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; }