diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 022b53c1..995806d9 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -44,6 +44,13 @@ import { FileAttachmentDetailModal } from "./FileAttachmentDetailModal"; import { initializeComponents } from "@/lib/registry/components"; import { ScreenFileAPI } from "@/lib/api/screenFile"; import { safeMigrateLayout, needsMigration } from "@/lib/utils/widthToColumnSpan"; +import { + detectDropZones, + applyDropZone, + adjustLayoutOnColumnChange, + DropZone, + DetectedDropZones, +} from "@/lib/utils/slotBasedLayout"; import StyleEditor from "./StyleEditor"; import { RealtimePreview } from "./RealtimePreviewDynamic"; @@ -190,6 +197,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD currentPosition: { x: 0, y: 0, z: 1 }, grabOffset: { x: 0, y: 0 }, justFinishedDrag: false, // 드래그 종료 직후 클릭 방지용 + detectedDropZones: null as DetectedDropZones | null, // 슬롯 기반 드롭존 + originalLayoutSnapshot: [] as ComponentData[], // 드래그 시작 시점의 원본 레이아웃 (크기 복구용) }); // Pan 모드 상태 (스페이스바 + 드래그) @@ -581,7 +590,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD } } - // gridColumns 변경 시 크기를 격자에 맞게 자동 조정 + // gridColumns 변경 시 크기를 격자에 맞게 자동 조정 + 슬롯 기반 레이아웃 조정 if (path === "gridColumns" && layout.gridSettings?.snapToGrid && newComp.type !== "group") { const currentGridInfo = calculateGridInfo(screenResolution.width, screenResolution.height, { columns: layout.gridSettings.columns, @@ -702,13 +711,37 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD return newComp; }); - const newLayout = { ...layout, components: updatedComponents }; + // gridColumns 변경 시 슬롯 기반 레이아웃 조정 적용 + let finalComponents = updatedComponents; + if (path === "gridColumns" && canvasRef.current) { + console.log("🎯 gridColumns 변경 감지 - 슬롯 기반 레이아웃 조정 시작:", { + componentId, + newColumns: value, + }); + + const canvasWidth = canvasRef.current.getBoundingClientRect().width; + finalComponents = adjustLayoutOnColumnChange( + componentId, + value, + updatedComponents, + canvasWidth, + layout.gridSettings || { columns: 12, gap: 16, padding: 0, snapToGrid: false }, + 2, // minColumns + ); + + console.log("✅ 슬롯 기반 레이아웃 조정 완료:", { + originalCount: updatedComponents.length, + finalCount: finalComponents.length, + }); + } + + const newLayout = { ...layout, components: finalComponents }; setLayout(newLayout); saveToHistory(newLayout); // selectedComponent가 업데이트된 컴포넌트와 같다면 selectedComponent도 업데이트 if (selectedComponent && selectedComponent.id === componentId) { - const updatedSelectedComponent = updatedComponents.find((c) => c.id === componentId); + const updatedSelectedComponent = finalComponents.find((c) => c.id === componentId); if (updatedSelectedComponent) { console.log("🔄 selectedComponent 동기화:", { componentId, @@ -744,7 +777,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD }); } }, - [layout, gridInfo, saveToHistory], + [layout, gridInfo, saveToHistory, selectedComponent, screenResolution, canvasRef], ); // 컴포넌트 시스템 초기화 @@ -2797,12 +2830,14 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD y: relativeMouseY - component.position.y, }, justFinishedDrag: false, + detectedDropZones: null, // 슬롯 기반 드롭존 초기화 + originalLayoutSnapshot: JSON.parse(JSON.stringify(layout.components)), // 원본 레이아웃 스냅샷 저장 }); }, [groupState.selectedComponents, layout.components, dragState.justFinishedDrag], ); - // 드래그 중 위치 업데이트 (성능 최적화 + 실시간 업데이트) + // 드래그 중 위치 업데이트 (슬롯 기반 시스템) const updateDragPosition = useCallback( (event: MouseEvent) => { if (!dragState.isDragging || !dragState.draggedComponent || !canvasRef.current) return; @@ -2819,39 +2854,105 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD z: (dragState.draggedComponent.position as Position).z || 1, }; - // 드래그 상태 업데이트 - console.log("🔥 ScreenDesigner updateDragPosition:", { + // 슬롯 기반 드롭존 감지 (원본 레이아웃 스냅샷 사용) + const canvasWidth = rect.width; + const detectedDropZones = detectDropZones( + newPosition, + dragState.draggedComponent, + dragState.originalLayoutSnapshot.length > 0 ? dragState.originalLayoutSnapshot : layout.components, + canvasWidth, + layout.gridSettings || { columns: 12, gap: 16, padding: 0, snapToGrid: false }, + 2, // minColumns + ); + + console.log("🔥 슬롯 기반 드롭존 감지:", { draggedComponentId: dragState.draggedComponent.id, - oldPosition: dragState.currentPosition, - newPosition: newPosition, + position: newPosition, + detectedDropZones: { + horizontal: detectedDropZones.horizontal?.id || "없음", + vertical: detectedDropZones.vertical?.id || "없음", + best: detectedDropZones.best?.id || "없음", + }, }); - setDragState((prev) => { - const newState = { - ...prev, - currentPosition: { ...newPosition }, // 새로운 객체 생성 - }; - console.log("🔄 ScreenDesigner dragState 업데이트:", { - prevPosition: prev.currentPosition, - newPosition: newState.currentPosition, - stateChanged: - prev.currentPosition.x !== newState.currentPosition.x || - prev.currentPosition.y !== newState.currentPosition.y, - }); - return newState; - }); - - // 성능 최적화: 드래그 중에는 상태 업데이트만 하고, - // 실제 레이아웃 업데이트는 endDrag에서 처리 - // 속성 패널에서는 dragState.currentPosition을 참조하여 실시간 표시 + // 드래그 상태 업데이트 + setDragState((prev) => ({ + ...prev, + currentPosition: { ...newPosition }, + detectedDropZones, + })); }, - [dragState.isDragging, dragState.draggedComponent, dragState.grabOffset], + [ + dragState.isDragging, + dragState.draggedComponent, + dragState.grabOffset, + dragState.originalLayoutSnapshot, + layout.components, + layout.gridSettings, + ], ); - // 드래그 종료 + // 드래그 종료 (슬롯 기반 시스템) const endDrag = useCallback(() => { if (dragState.isDragging && dragState.draggedComponent) { - // 주 드래그 컴포넌트의 최종 위치 계산 + console.log("🎯 드래그 종료 - 슬롯 기반 배치:", { + draggedComponentId: dragState.draggedComponent.id, + detectedDropZones: dragState.detectedDropZones, + }); + + // 최적의 드롭존이 있으면 슬롯 기반 배치 + if (dragState.detectedDropZones?.best) { + const bestDropZone = dragState.detectedDropZones.best; + + console.log("✅ 슬롯 기반 배치 적용:", { + dropZoneId: bestDropZone.id, + slot: bestDropZone.slot, + rowIndex: bestDropZone.rowIndex, + strategy: bestDropZone.placementCheck.strategy, + }); + + // 슬롯 기반 레이아웃 조정 적용 + const updatedComponents = applyDropZone(bestDropZone, dragState.draggedComponent, layout.components); + + const newLayout = { ...layout, components: updatedComponents }; + setLayout(newLayout); + + // 선택된 컴포넌트 업데이트 + if (selectedComponent && selectedComponent.id === dragState.draggedComponent.id) { + const updatedSelectedComponent = updatedComponents.find((c) => c.id === selectedComponent.id); + if (updatedSelectedComponent) { + setSelectedComponent(updatedSelectedComponent); + } + } + + // 히스토리에 저장 + saveToHistory(newLayout); + + // 드래그 상태 초기화 + setDragState({ + isDragging: false, + draggedComponent: null, + draggedComponents: [], + originalPosition: { x: 0, y: 0, z: 1 }, + currentPosition: { x: 0, y: 0, z: 1 }, + grabOffset: { x: 0, y: 0 }, + justFinishedDrag: true, + detectedDropZones: null, + originalLayoutSnapshot: [], // 원본 스냅샷 초기화 + }); + + // 짧은 시간 후 justFinishedDrag 플래그 해제 + setTimeout(() => { + setDragState((prev) => ({ + ...prev, + justFinishedDrag: false, + })); + }, 100); + + return; + } + + // 드롭존이 없으면 기존 격자 스냅 로직 사용 (폴백) const draggedComponent = layout.components.find((c) => c.id === dragState.draggedComponent); let finalPosition = dragState.currentPosition; @@ -2882,7 +2983,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD }, ); - console.log("🎯 격자 스냅 적용됨:", { + console.log("🎯 격자 스냅 적용됨 (폴백):", { resolution: `${screenResolution.width}x${screenResolution.height}`, originalPosition: dragState.currentPosition, snappedPosition: finalPosition, @@ -3008,6 +3109,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD currentPosition: { x: 0, y: 0, z: 1 }, grabOffset: { x: 0, y: 0 }, justFinishedDrag: true, + detectedDropZones: null, + originalLayoutSnapshot: [], // 원본 스냅샷 초기화 }); // 짧은 시간 후 justFinishedDrag 플래그 해제 @@ -3017,7 +3120,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD justFinishedDrag: false, })); }, 100); - }, [dragState, layout, gridInfo, saveToHistory]); + }, [dragState, layout, gridInfo, saveToHistory, selectedComponent, saveToHistory, screenResolution]); // 드래그 선택 시작 const startSelectionDrag = useCallback( @@ -4058,6 +4161,34 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD }; } } + } else if (dragState.isDragging && dragState.detectedDropZones?.best?.adjustment) { + // 드래그 중이지만 이 컴포넌트는 드래그되지 않는 경우 + // adjustment에서 이 컴포넌트가 영향받는지 체크 + const adjustment = dragState.detectedDropZones.best.adjustment; + const adjustedComp = adjustment.adjustedComponents.find((c) => c.id === component.id); + + if (adjustedComp) { + // 이 컴포넌트가 조정되었으면 실시간 미리보기 적용 + const resizeInfo = adjustment.resizedComponents.find((r) => r.id === component.id); + + displayComponent = { + ...adjustedComp, + style: { + ...component.style, + transition: "all 0.2s ease-out", // 부드러운 애니메이션 + opacity: 0.9, // 약간 투명하게 (미리보기임을 표시) + }, + }; + + if (resizeInfo) { + console.log(`🎨 실시간 미리보기: ${component.id}`, { + oldColumns: resizeInfo.oldColumns, + newColumns: resizeInfo.newColumns, + oldWidth: component.size.width, + newWidth: displayComponent.size.width, + }); + } + } } // 전역 파일 상태도 key에 포함하여 실시간 리렌더링 diff --git a/frontend/lib/utils/slotAdjustment.ts b/frontend/lib/utils/slotAdjustment.ts new file mode 100644 index 00000000..b1340c9b --- /dev/null +++ b/frontend/lib/utils/slotAdjustment.ts @@ -0,0 +1,897 @@ +/** + * 슬롯 기반 레이아웃 조정 로직 + * + * 핵심 기능: + * 1. 특정 슬롯에 컴포넌트 배치 가능 여부 체크 + * 2. 배치 시 다른 컴포넌트들을 자동으로 조정 + * 3. 조정 우선순위: 빈 공간 활용 → 컴포넌트 축소 → 아래로 이동 + */ + +import { ComponentData, GridSettings, Position } from "@/types/screen"; +import { + SlotMap, + GridInfo, + getComponentColumns, + areSlotsEmpty, + getComponentsInSlots, + countEmptySlots, + findComponentSlots, + slotToPosition, + positionToSlot, + rowToPosition, + buildSlotMap, + positionToRow, +} from "./slotCalculations"; +import { calculateWidthFromColumns } from "./gridUtils"; + +/** + * 배치 가능 여부 체크 결과 + */ +export interface PlacementCheck { + canPlace: boolean; + strategy: "EMPTY_SPACE" | "SHRINK_COMPONENTS" | "MOVE_DOWN" | "IMPOSSIBLE"; + reason?: string; + affectedComponents: string[]; // 영향받는 컴포넌트 ID + requiredSpace: number; // 필요한 슬롯 수 + availableSpace: number; // 사용 가능한 빈 슬롯 수 +} + +/** + * 레이아웃 조정 결과 + */ +export interface LayoutAdjustment { + success: boolean; + adjustedComponents: ComponentData[]; + resizedComponents: Array<{ + id: string; + oldColumns: number; + newColumns: number; + oldSlots: number[]; + newSlots: number[]; + }>; + movedComponents: Array<{ + id: string; + oldRow: number; + newRow: number; + oldPosition: Position; + newPosition: Position; + }>; + placement: Position; // 드래그된 컴포넌트의 최종 배치 위치 +} + +/** + * 특정 슬롯에 컴포넌트 배치 가능 여부 체크 + */ +export function canPlaceInSlot( + targetSlot: number, + columns: number, + rowIndex: number, + slotMap: SlotMap, + allComponents: ComponentData[], + minColumns: number = 2, +): PlacementCheck { + const endSlot = Math.min(11, targetSlot + columns - 1); + + console.log("🔍 배치 가능 여부 체크:", { + targetSlot, + endSlot, + columns, + rowIndex, + }); + + // 슬롯이 비어있는지 체크 + const isEmpty = areSlotsEmpty(targetSlot, endSlot, rowIndex, slotMap); + + if (isEmpty) { + return { + canPlace: true, + strategy: "EMPTY_SPACE", + affectedComponents: [], + requiredSpace: columns, + availableSpace: columns, + }; + } + + // 겹치는 컴포넌트 찾기 + const affectedComponents = getComponentsInSlots(targetSlot, endSlot, rowIndex, slotMap); + + // 빈 슬롯 수 계산 + const emptySlots = countEmptySlots(rowIndex, slotMap); + + console.log("📊 행 분석:", { + emptySlots, + affectedComponents, + requiredSpace: columns, + }); + + // 컴포넌트를 축소해서 공간 확보 가능한지 먼저 체크 + const canShrink = checkIfCanShrink(affectedComponents, columns, rowIndex, slotMap, allComponents, minColumns); + + console.log("✂️ 축소 가능 여부:", { + possible: canShrink.possible, + availableSpace: canShrink.availableSpace, + emptySlots, + totalAvailable: emptySlots + canShrink.availableSpace, + required: columns, + }); + + // 빈 공간 + 축소 가능 공간이 충분하면 축소 전략 사용 + if (emptySlots + canShrink.availableSpace >= columns) { + return { + canPlace: true, + strategy: "SHRINK_COMPONENTS", + affectedComponents, + requiredSpace: columns, + availableSpace: emptySlots + canShrink.availableSpace, + }; + } + + // 축소로도 불가능하면 아래로 이동 + return { + canPlace: true, + strategy: "MOVE_DOWN", + affectedComponents, + requiredSpace: columns, + availableSpace: emptySlots, + }; +} + +/** + * 컴포넌트들을 축소할 수 있는지 체크 + */ +function checkIfCanShrink( + componentIds: string[], + requiredSpace: number, + rowIndex: number, + slotMap: SlotMap, + allComponents: ComponentData[], + minColumns: number, +): { possible: boolean; availableSpace: number } { + let totalShrinkable = 0; + + for (const componentId of componentIds) { + const component = allComponents.find((c) => c.id === componentId); + if (!component) continue; + + const currentColumns = getComponentColumns(component); + const shrinkable = Math.max(0, currentColumns - minColumns); + totalShrinkable += shrinkable; + } + + console.log("✂️ 축소 가능 공간:", { + componentIds, + totalShrinkable, + requiredSpace, + possible: totalShrinkable >= requiredSpace, + }); + + return { + possible: totalShrinkable >= requiredSpace, + availableSpace: totalShrinkable, + }; +} + +/** + * 슬롯에 컴포넌트 배치 시 레이아웃 조정 계산 + */ +export function calculateSlotPlacement( + targetSlot: number, + draggedColumns: number, + draggedComponentId: string, + rowIndex: number, + slotMap: SlotMap, + allComponents: ComponentData[], + gridInfo: GridInfo, + gridSettings: GridSettings, + minColumns: number = 2, +): LayoutAdjustment { + const endSlot = Math.min(11, targetSlot + draggedColumns - 1); + + console.log("🎯 레이아웃 조정 계산 시작:", { + targetSlot, + endSlot, + draggedColumns, + rowIndex, + }); + + // 배치 가능 여부 체크 + const check = canPlaceInSlot(targetSlot, draggedColumns, rowIndex, slotMap, allComponents, minColumns); + + if (!check.canPlace) { + return { + success: false, + adjustedComponents: allComponents, + resizedComponents: [], + movedComponents: [], + placement: { x: 0, y: 0, z: 1 }, + }; + } + + // 전략별 조정 수행 + switch (check.strategy) { + case "EMPTY_SPACE": + return placeInEmptySpace(targetSlot, draggedColumns, draggedComponentId, rowIndex, allComponents, gridInfo); + + case "SHRINK_COMPONENTS": + return placeWithShrinking( + targetSlot, + draggedColumns, + draggedComponentId, + rowIndex, + slotMap, + allComponents, + gridInfo, + gridSettings, + minColumns, + ); + + case "MOVE_DOWN": + return placeWithMovingDown( + targetSlot, + draggedColumns, + draggedComponentId, + rowIndex, + slotMap, + allComponents, + gridInfo, + gridSettings, + ); + + default: + return { + success: false, + adjustedComponents: allComponents, + resizedComponents: [], + movedComponents: [], + placement: { x: 0, y: 0, z: 1 }, + }; + } +} + +/** + * 전략 1: 빈 공간에 배치 (조정 불필요) + */ +function placeInEmptySpace( + targetSlot: number, + draggedColumns: number, + draggedComponentId: string, + rowIndex: number, + allComponents: ComponentData[], + gridInfo: GridInfo, +): LayoutAdjustment { + console.log("✅ 빈 공간에 배치 (조정 불필요)"); + + const x = slotToPosition(targetSlot, gridInfo); + const y = rowToPosition(rowIndex, gridInfo); + + return { + success: true, + adjustedComponents: allComponents, + resizedComponents: [], + movedComponents: [], + placement: { x, y, z: 1 }, + }; +} + +/** + * 전략 2: 컴포넌트 축소하여 배치 + */ +function placeWithShrinking( + targetSlot: number, + draggedColumns: number, + draggedComponentId: string, + rowIndex: number, + slotMap: SlotMap, + allComponents: ComponentData[], + gridInfo: GridInfo, + gridSettings: GridSettings, + minColumns: number, +): LayoutAdjustment { + console.log("✂️ 컴포넌트 축소하여 배치"); + + const { columnWidth, gap } = gridInfo; + const endSlot = Math.min(11, targetSlot + draggedColumns - 1); + + // 겹치는 컴포넌트들 (드롭 위치에 직접 겹치는 컴포넌트) + const directlyAffected = getComponentsInSlots(targetSlot, endSlot, rowIndex, slotMap); + + // 같은 행의 모든 컴포넌트들 (왼쪽/오른쪽 모두) + const allComponentsInRow = allComponents.filter((c) => { + const compRow = positionToRow(c.position.y, gridInfo); + return compRow === rowIndex && c.id !== draggedComponentId; + }); + + let adjustedComponents = [...allComponents]; + const resizedComponents: LayoutAdjustment["resizedComponents"] = []; + + // 같은 행의 모든 컴포넌트를 슬롯 순서대로 정렬 + const componentsWithSlots = allComponentsInRow + .map((c) => { + const slots = findComponentSlots(c.id, rowIndex, slotMap); + return { + component: c, + startSlot: slots?.startSlot ?? 0, + columns: getComponentColumns(c), + }; + }) + .sort((a, b) => a.startSlot - b.startSlot); + + // targetSlot 이전/이후 컴포넌트 분리 + // 컴포넌트의 끝이 targetSlot 이전이면 beforeTarget + const beforeTarget = componentsWithSlots.filter((c) => c.startSlot + c.columns - 1 < targetSlot); + // 컴포넌트의 시작이 targetSlot 이후거나, targetSlot과 겹치면 atOrAfterTarget + const atOrAfterTarget = componentsWithSlots.filter((c) => c.startSlot + c.columns - 1 >= targetSlot); + + // 이전 컴포넌트들의 총 컬럼 수 + const beforeColumns = beforeTarget.reduce((sum, c) => sum + c.columns, 0); + + // 이후 컴포넌트들의 총 컬럼 수 + const afterColumns = atOrAfterTarget.reduce((sum, c) => sum + c.columns, 0); + + // 필요한 총 컬럼: 이전 + 드래그 + 이후 + const totalNeeded = beforeColumns + draggedColumns + afterColumns; + + console.log("📊 레이아웃 분석:", { + targetSlot, + beforeColumns, + draggedColumns, + afterColumns, + totalNeeded, + spaceNeeded: totalNeeded - 12, + before: beforeTarget.map((c) => `${c.component.id}:${c.columns}`), + after: atOrAfterTarget.map((c) => `${c.component.id}:${c.columns}`), + }); + + // atOrAfterTarget 컴포넌트들 중 targetSlot과 겹치는 컴포넌트가 있는지 체크 + const overlappingComponents = atOrAfterTarget.filter((c) => { + const endSlot = c.startSlot + c.columns - 1; + const targetEndSlot = Math.min(11, targetSlot + draggedColumns - 1); + // 겹침 조건: 컴포넌트 범위와 목표 범위가 겹치는지 + return !(endSlot < targetSlot || c.startSlot > targetEndSlot); + }); + + console.log("🔍 겹침 컴포넌트:", { + overlapping: overlappingComponents.map((c) => `${c.component.id}:${c.startSlot}-${c.startSlot + c.columns - 1}`), + }); + + // 실제로 겹치는 슬롯 수 계산 + let overlapSlots = 0; + if (overlappingComponents.length > 0) { + const targetEndSlot = Math.min(11, targetSlot + draggedColumns - 1); + + // 각 겹치는 컴포넌트의 겹침 범위를 계산하여 최대값 사용 + // (여러 컴포넌트와 겹칠 경우, 전체 겹침 범위) + const allOverlapSlots = new Set(); + + for (const compInfo of overlappingComponents) { + const compEndSlot = compInfo.startSlot + compInfo.columns - 1; + + // 겹치는 슬롯 범위 + const overlapStart = Math.max(compInfo.startSlot, targetSlot); + const overlapEnd = Math.min(compEndSlot, targetEndSlot); + + for (let slot = overlapStart; slot <= overlapEnd; slot++) { + allOverlapSlots.add(slot); + } + } + + overlapSlots = allOverlapSlots.size; + } + + // 필요한 공간 = 실제 겹치는 슬롯 수 + let spaceNeeded = overlapSlots; + + // 12컬럼 초과 체크 (겹침과는 별개로 전체 공간 부족) + if (totalNeeded > 12) { + spaceNeeded = Math.max(spaceNeeded, totalNeeded - 12); + } + + console.log("📊 공간 분석:", { + hasOverlap: overlappingComponents.length > 0, + overlapSlots, + draggedColumns, + totalNeeded, + spaceNeeded, + overlapping: overlappingComponents.map( + (c) => `${c.component.id}:슬롯${c.startSlot}-${c.startSlot + c.columns - 1}`, + ), + }); + + // 필요한 만큼 축소 + if (spaceNeeded > 0) { + // atOrAfterTarget 컴포넌트들을 축소 + for (const compInfo of atOrAfterTarget) { + if (spaceNeeded <= 0) break; + + const oldColumns = compInfo.columns; + const maxShrink = oldColumns - minColumns; + + if (maxShrink > 0) { + const shrinkAmount = Math.min(spaceNeeded, maxShrink); + const newColumns = oldColumns - shrinkAmount; + const newWidth = newColumns * columnWidth + (newColumns - 1) * gap; + + const componentIndex = adjustedComponents.findIndex((c) => c.id === compInfo.component.id); + if (componentIndex !== -1) { + adjustedComponents[componentIndex] = { + ...adjustedComponents[componentIndex], + gridColumns: newColumns, + columnSpan: newColumns, + size: { + ...adjustedComponents[componentIndex].size, + width: newWidth, + }, + } as ComponentData; + + resizedComponents.push({ + id: compInfo.component.id, + oldColumns, + newColumns, + oldSlots: Array.from({ length: oldColumns }, (_, i) => compInfo.startSlot + i), + newSlots: Array.from({ length: newColumns }, (_, i) => compInfo.startSlot + i), + }); + + spaceNeeded -= shrinkAmount; + + console.log(`✂️ ${compInfo.component.id} 축소:`, { + oldColumns, + newColumns, + shrinkAmount, + remainingNeed: spaceNeeded, + }); + } + } + } + } + + console.log("📋 초기 축소 완료:", { + totalResized: resizedComponents.length, + resizedComponents: resizedComponents.map((r) => ({ + id: r.id, + oldColumns: r.oldColumns, + newColumns: r.newColumns, + })), + }); + + // 배치 위치 계산 + let x = slotToPosition(targetSlot, gridInfo); + let y = rowToPosition(rowIndex, gridInfo); + + // 드래그될 컴포넌트가 차지할 슬롯 범위 (예약) + const reservedStartSlot = targetSlot; + const reservedEndSlot = Math.min(11, targetSlot + draggedColumns - 1); + + console.log("🎯 드래그 컴포넌트 슬롯 예약:", { + targetSlot, + draggedColumns, + reservedSlots: `${reservedStartSlot}-${reservedEndSlot}`, + currentSpaceNeeded: spaceNeeded, + }); + + // 같은 행의 모든 컴포넌트들의 X 위치를 재계산하여 슬롯에 맞게 정렬 + // 드래그될 컴포넌트의 슬롯은 건너뛰기 + const componentsInRowSorted = adjustedComponents + .filter((c) => { + const compRow = positionToRow(c.position.y, gridInfo); + return compRow === rowIndex && c.id !== draggedComponentId; + }) + .sort((a, b) => a.position.x - b.position.x); + + let currentSlot = 0; + const movedToNextRow: string[] = []; // 다음 행으로 이동한 컴포넌트들 + + for (const comp of componentsInRowSorted) { + const compIndex = adjustedComponents.findIndex((c) => c.id === comp.id); + if (compIndex !== -1) { + let compColumns = getComponentColumns(adjustedComponents[compIndex]); + + // 현재 슬롯이 예약된 범위와 겹치는지 체크 + const wouldOverlap = currentSlot <= reservedEndSlot && currentSlot + compColumns - 1 >= reservedStartSlot; + + if (wouldOverlap) { + // 예약된 슬롯을 건너뛰고 그 다음부터 배치 + currentSlot = reservedEndSlot + 1; + console.log(`⏭️ ${comp.id} 예약된 슬롯 건너뛰기, 새 시작 슬롯: ${currentSlot}`); + } + + // 화면 밖으로 나가는지 체크 (12컬럼 초과) + if (currentSlot + compColumns > 12) { + const overflow = currentSlot + compColumns - 12; + const oldColumns = compColumns; + const maxShrink = compColumns - minColumns; + + // 추가 축소 가능하면 축소 + if (maxShrink >= overflow) { + compColumns -= overflow; + const newWidth = compColumns * columnWidth + (compColumns - 1) * gap; + + adjustedComponents[compIndex] = { + ...adjustedComponents[compIndex], + gridColumns: compColumns, + columnSpan: compColumns, + size: { + ...adjustedComponents[compIndex].size, + width: newWidth, + }, + } as ComponentData; + + resizedComponents.push({ + id: comp.id, + oldColumns, + newColumns: compColumns, + oldSlots: [], + newSlots: [], + }); + + console.log(`✂️ ${comp.id} 추가 축소 (화면 경계):`, { + oldColumns, + newColumns: compColumns, + overflow, + }); + } else { + // 축소로도 안되면 다음 행으로 이동 + console.log(`⚠️ ${comp.id} 화면 밖으로 나감! (슬롯 ${currentSlot}, 컬럼 ${compColumns})`); + const newY = rowToPosition(rowIndex + 1, gridInfo); + adjustedComponents[compIndex] = { + ...adjustedComponents[compIndex], + position: { + x: 0, + y: newY, + z: adjustedComponents[compIndex].position.z, + }, + } as ComponentData; + + movedToNextRow.push(comp.id); + console.log(`⬇️ ${comp.id} 다음 행으로 이동 (y: ${newY})`); + continue; + } + } + + const newX = slotToPosition(currentSlot, gridInfo); + + adjustedComponents[compIndex] = { + ...adjustedComponents[compIndex], + position: { + ...adjustedComponents[compIndex].position, + x: newX, + }, + } as ComponentData; + + console.log(`📍 ${comp.id} 위치 재조정:`, { + oldX: comp.position.x, + newX, + slot: currentSlot, + columns: compColumns, + wasOverlapping: wouldOverlap, + }); + + currentSlot += compColumns; + } + } + + // 오른쪽 축소 효과: resizedComponents 중 targetSlot 오른쪽에 있는 컴포넌트들을 오른쪽으로 밀기 + for (const resized of resizedComponents) { + const compIndex = adjustedComponents.findIndex((c) => c.id === resized.id); + if (compIndex !== -1) { + const comp = adjustedComponents[compIndex]; + const compSlot = positionToSlot(comp.position.x, gridInfo); + + // targetSlot 오른쪽에 있고 축소된 컴포넌트만 처리 + if (compSlot >= targetSlot && resized.oldColumns > resized.newColumns) { + const shrinkAmount = resized.oldColumns - resized.newColumns; + const newX = comp.position.x + shrinkAmount * (columnWidth + gap); + + adjustedComponents[compIndex] = { + ...adjustedComponents[compIndex], + position: { + ...adjustedComponents[compIndex].position, + x: newX, + }, + } as ComponentData; + + console.log(`➡️ ${resized.id} 오른쪽으로 이동:`, { + oldX: comp.position.x, + newX, + shrinkAmount, + oldColumns: resized.oldColumns, + newColumns: resized.newColumns, + }); + } + } + } + + // 축소된 컴포넌트 복구 로직 + // 조건: + // 1. 축소된 컴포넌트가 있고 + // 2. 컴포넌트가 다음 행으로 이동했거나 + // 3. 드래그 컴포넌트가 현재 행에서 이미 축소된 컴포넌트와 겹치지 않는 위치에 배치되는 경우 + if (resizedComponents.length > 0) { + console.log("🔍 축소된 컴포넌트 복구 가능 여부 확인:", { + movedToNextRow: movedToNextRow.length, + resizedComponents: resizedComponents.map((r) => r.id), + targetSlot, + draggedColumns, + }); + + // 🔧 중요: 업데이트된 컴포넌트 기준으로 새로운 슬롯맵 생성 + const updatedSlotMap = buildSlotMap(adjustedComponents, gridInfo); + + // 드래그 컴포넌트가 차지할 슬롯 범위 + const draggedStartSlot = targetSlot; + const draggedEndSlot = Math.min(11, targetSlot + draggedColumns - 1); + + // 축소된 컴포넌트들이 차지하는 슬롯 범위 (원본 크기 기준) + let canRestoreAll = true; + const restoredSlotRanges: Array<{ id: string; startSlot: number; endSlot: number }> = []; + + for (const resizeInfo of resizedComponents) { + const comp = adjustedComponents.find((c) => c.id === resizeInfo.id); + if (!comp || movedToNextRow.includes(resizeInfo.id)) continue; + + // 현재 위치를 슬롯으로 변환 (업데이트된 슬롯맵 사용) + const compSlots = findComponentSlots(resizeInfo.id, rowIndex, updatedSlotMap); + if (!compSlots) { + console.log(`⚠️ ${resizeInfo.id}의 슬롯을 찾을 수 없음`); + canRestoreAll = false; + break; + } + + // 원본 크기로 복구했을 때의 슬롯 범위 계산 + const currentStartSlot = compSlots.startSlot; + const restoredEndSlot = currentStartSlot + resizeInfo.oldColumns - 1; + + console.log(`📍 ${resizeInfo.id} 슬롯 정보:`, { + currentSlots: `${currentStartSlot}-${compSlots.endSlot} (${compSlots.columns}컬럼)`, + restoredSlots: `${currentStartSlot}-${restoredEndSlot} (${resizeInfo.oldColumns}컬럼)`, + }); + + // 드래그 컴포넌트와 겹치는지 확인 + const wouldOverlapWithDragged = !(restoredEndSlot < draggedStartSlot || currentStartSlot > draggedEndSlot); + + if (wouldOverlapWithDragged) { + console.log(`⚠️ ${resizeInfo.id} 복구 시 드래그 컴포넌트와 겹침:`, { + restoredRange: `${currentStartSlot}-${restoredEndSlot}`, + draggedRange: `${draggedStartSlot}-${draggedEndSlot}`, + }); + canRestoreAll = false; + break; + } + + restoredSlotRanges.push({ + id: resizeInfo.id, + startSlot: currentStartSlot, + endSlot: restoredEndSlot, + }); + } + + // 복구된 컴포넌트들끼리도 겹치는지 확인 + if (canRestoreAll) { + for (let i = 0; i < restoredSlotRanges.length; i++) { + for (let j = i + 1; j < restoredSlotRanges.length; j++) { + const range1 = restoredSlotRanges[i]; + const range2 = restoredSlotRanges[j]; + const overlap = !(range1.endSlot < range2.startSlot || range1.startSlot > range2.endSlot); + if (overlap) { + console.log(`⚠️ 복구 시 컴포넌트끼리 겹침: ${range1.id} vs ${range2.id}`); + canRestoreAll = false; + break; + } + } + if (!canRestoreAll) break; + } + } + + // 총 컬럼 수 체크 (12컬럼 초과 방지) + if (canRestoreAll) { + let totalColumnsInRow = draggedColumns; + const componentsInCurrentRow = adjustedComponents.filter((c) => { + const compRow = positionToRow(c.position.y, gridInfo); + return compRow === rowIndex && c.id !== draggedComponentId && !movedToNextRow.includes(c.id); + }); + + for (const comp of componentsInCurrentRow) { + const resizeInfo = resizedComponents.find((r) => r.id === comp.id); + if (resizeInfo) { + totalColumnsInRow += resizeInfo.oldColumns; // 원본 크기 + } else { + totalColumnsInRow += getComponentColumns(comp); // 현재 크기 + } + } + + if (totalColumnsInRow > 12) { + console.log(`⚠️ 복구하면 12컬럼 초과: ${totalColumnsInRow}`); + canRestoreAll = false; + } + } + + // 복구 가능하면 복구 실행 + if (canRestoreAll) { + console.log("✅ 공간이 충분하여 축소된 컴포넌트 복구"); + + for (const resizeInfo of resizedComponents) { + if (!movedToNextRow.includes(resizeInfo.id)) { + const compIndex = adjustedComponents.findIndex((c) => c.id === resizeInfo.id); + if (compIndex !== -1) { + const originalColumns = resizeInfo.oldColumns; + const originalWidth = calculateWidthFromColumns(originalColumns, gridInfo, gridSettings); + + adjustedComponents[compIndex] = { + ...adjustedComponents[compIndex], + gridColumns: originalColumns, + columnSpan: originalColumns, + size: { + ...adjustedComponents[compIndex].size, + width: originalWidth, + }, + } as ComponentData; + + console.log(`🔄 ${resizeInfo.id} 원래 크기로 복구:`, { + from: resizeInfo.newColumns, + to: originalColumns, + }); + } + } + } + + // 복구 후 위치 재조정 (슬롯 기반) + // 드래그 컴포넌트는 targetSlot에 배치하고, 나머지는 슬롯 순서대로 배치 + console.log("🔄 복구 후 슬롯 기반 재정렬 시작:", { + targetSlot, + draggedColumns, + restoredComponents: resizedComponents.map((r) => `${r.id}:${r.oldColumns}컬럼`), + }); + + // 1. 현재 행의 모든 컴포넌트를 슬롯 기준으로 수집 + const componentsInRow = adjustedComponents.filter((c) => { + const compRow = positionToRow(c.position.y, gridInfo); + return compRow === rowIndex && c.id !== draggedComponentId; + }); + + // 2. 각 컴포넌트의 시작 슬롯 계산 (업데이트된 슬롯맵 기준) + const updatedSlotMap2 = buildSlotMap(adjustedComponents, gridInfo); + const componentsForReposition = componentsInRow + .map((c) => { + const slots = findComponentSlots(c.id, rowIndex, updatedSlotMap2); + return { + component: c, + startSlot: slots?.startSlot ?? 0, + columns: getComponentColumns(c), + }; + }) + .sort((a, b) => a.startSlot - b.startSlot); + + // 3. 드래그 컴포넌트를 targetSlot에 삽입 + const draggedSlotInfo = { + component: null as any, // 임시 + startSlot: targetSlot, + columns: draggedColumns, + isDragged: true, + }; + + // 4. 슬롯 순서대로 전체 배열 구성 + const allSlotsSorted = [...componentsForReposition, draggedSlotInfo].sort((a, b) => a.startSlot - b.startSlot); + + console.log("📋 슬롯 순서:", { + components: allSlotsSorted.map((s) => + s.isDragged + ? `[드래그:${s.startSlot}슬롯,${s.columns}컬럼]` + : `${s.component.id}:${s.startSlot}슬롯,${s.columns}컬럼`, + ), + }); + + // 5. 슬롯 순서대로 X 좌표 재배치 + let currentSlot = 0; + for (const slotInfo of allSlotsSorted) { + const posX = slotToPosition(currentSlot, gridInfo); + const compColumns = slotInfo.columns; + const compWidth = calculateWidthFromColumns(compColumns, gridInfo, gridSettings); + + if (slotInfo.isDragged) { + // 드래그 컴포넌트 + x = posX; + console.log(` 📍 드래그 컴포넌트: 슬롯${currentSlot}, x=${posX}, ${compColumns}컬럼`); + } else { + // 일반 컴포넌트 + const compIndex = adjustedComponents.findIndex((c) => c.id === slotInfo.component.id); + if (compIndex !== -1) { + adjustedComponents[compIndex] = { + ...adjustedComponents[compIndex], + position: { + ...adjustedComponents[compIndex].position, + x: posX, + }, + } as ComponentData; + + console.log(` 📍 ${slotInfo.component.id}: 슬롯${currentSlot}, x=${posX}, ${compColumns}컬럼`); + } + } + + currentSlot += compColumns; + } + + console.log("📐 복구 후 위치 재조정 완료", { finalDraggedX: x, finalDraggedSlot: targetSlot }); + } else { + console.log("⚠️ 복구 조건 불만족, 축소 상태 유지"); + } + + // 복구 여부와 관계없이 resizedComponents 초기화 + resizedComponents.length = 0; + } + + return { + success: true, + adjustedComponents, + resizedComponents, + movedComponents: [], + placement: { x, y, z: 1 }, + }; +} + +/** + * 전략 3: 겹치는 컴포넌트를 아래로 이동 + */ +function placeWithMovingDown( + targetSlot: number, + draggedColumns: number, + draggedComponentId: string, + rowIndex: number, + slotMap: SlotMap, + allComponents: ComponentData[], + gridInfo: GridInfo, + gridSettings: GridSettings, +): LayoutAdjustment { + console.log("⬇️ 겹치는 컴포넌트를 아래로 이동"); + + const endSlot = Math.min(11, targetSlot + draggedColumns - 1); + + // 겹치는 컴포넌트들 + const affectedComponentIds = getComponentsInSlots(targetSlot, endSlot, rowIndex, slotMap); + + let adjustedComponents = [...allComponents]; + const movedComponents: LayoutAdjustment["movedComponents"] = []; + + // 다음 행으로 이동 + const newRowIndex = rowIndex + 1; + const newY = rowToPosition(newRowIndex, gridInfo); + + for (const componentId of affectedComponentIds) { + const component = allComponents.find((c) => c.id === componentId); + if (!component) continue; + + const componentIndex = adjustedComponents.findIndex((c) => c.id === componentId); + if (componentIndex !== -1) { + const oldPosition = adjustedComponents[componentIndex].position; + const newPosition = { ...oldPosition, y: newY }; + + adjustedComponents[componentIndex] = { + ...adjustedComponents[componentIndex], + position: newPosition, + }; + + movedComponents.push({ + id: componentId, + oldRow: rowIndex, + newRow: newRowIndex, + oldPosition, + newPosition, + }); + + console.log(`⬇️ ${componentId} 이동:`, { + from: `행 ${rowIndex}`, + to: `행 ${newRowIndex}`, + }); + } + } + + // 배치 위치 계산 + const x = slotToPosition(targetSlot, gridInfo); + const y = rowToPosition(rowIndex, gridInfo); + + return { + success: true, + adjustedComponents, + resizedComponents: [], + movedComponents, + placement: { x, y, z: 1 }, + }; +} diff --git a/frontend/lib/utils/slotBasedLayout.ts b/frontend/lib/utils/slotBasedLayout.ts new file mode 100644 index 00000000..84987869 --- /dev/null +++ b/frontend/lib/utils/slotBasedLayout.ts @@ -0,0 +1,380 @@ +/** + * 슬롯 기반 레이아웃 시스템 - 메인 인터페이스 + * + * ScreenDesigner에서 사용하는 주요 함수들을 제공 + */ + +import { ComponentData, GridSettings, Position } from "@/types/screen"; +import { + SlotMap, + GridInfo, + createGridInfo, + buildSlotMap, + positionToSlot, + positionToRow, + getComponentColumns, + slotToPosition, + rowToPosition, +} from "./slotCalculations"; +import { PlacementCheck, LayoutAdjustment, canPlaceInSlot, calculateSlotPlacement } from "./slotAdjustment"; + +/** + * 드롭존 정보 + */ +export interface DropZone { + id: string; + slot: number; // 시작 슬롯 (0-11) + endSlot: number; // 종료 슬롯 (0-11) + rowIndex: number; + position: Position; + width: number; + height: number; + placementCheck: PlacementCheck; + adjustment?: LayoutAdjustment; // 배치 시 조정 정보 +} + +/** + * 드래그 중 감지된 드롭존 목록 + */ +export interface DetectedDropZones { + horizontal: DropZone | null; // 같은 행의 드롭존 + vertical: DropZone | null; // 다음 행의 드롭존 + best: DropZone | null; // 가장 적합한 드롭존 +} + +/** + * 드래그 위치에서 가장 가까운 슬롯 찾기 + */ +export function findNearestSlot( + dragPosition: Position, + canvasWidth: number, + gridSettings: GridSettings, +): { slot: number; rowIndex: number; gridInfo: GridInfo } { + const gridInfo = createGridInfo(canvasWidth, gridSettings); + const slot = positionToSlot(dragPosition.x, gridInfo); + const rowIndex = positionToRow(dragPosition.y, gridInfo); + + console.log("🎯 가장 가까운 슬롯:", { + dragPosition, + slot, + rowIndex, + }); + + return { slot, rowIndex, gridInfo }; +} + +/** + * 드래그 중 드롭존 감지 + */ +export function detectDropZones( + dragPosition: Position, + draggedComponent: ComponentData, + allComponents: ComponentData[], + canvasWidth: number, + gridSettings: GridSettings, + minColumns: number = 2, +): DetectedDropZones { + console.log("🔍 드롭존 감지 시작:", { + dragPosition, + draggedComponentId: draggedComponent.id, + draggedColumns: getComponentColumns(draggedComponent), + }); + + // 드래그 중인 컴포넌트를 제외한 컴포넌트들 + const otherComponents = allComponents.filter((c) => c.id !== draggedComponent.id); + + // 그리드 정보 생성 + const gridInfo = createGridInfo(canvasWidth, gridSettings); + + // 슬롯 맵 생성 + const slotMap = buildSlotMap(otherComponents, gridInfo); + + // 드래그 위치에서 가장 가까운 슬롯 + const { slot: targetSlot, rowIndex } = findNearestSlot(dragPosition, canvasWidth, gridSettings); + + const draggedColumns = getComponentColumns(draggedComponent); + + // 수평 드롭존 (같은 행) + const horizontalDropZone = createDropZone( + targetSlot, + draggedColumns, + draggedComponent.id, + rowIndex, + slotMap, + otherComponents, + gridInfo, + gridSettings, + minColumns, + ); + + // 수직 드롭존 (다음 행) + const verticalDropZone = createDropZone( + targetSlot, + draggedColumns, + draggedComponent.id, + rowIndex + 1, + slotMap, + otherComponents, + gridInfo, + gridSettings, + minColumns, + ); + + // 최적의 드롭존 선택 + const best = selectBestDropZone(horizontalDropZone, verticalDropZone); + + console.log("✅ 드롭존 감지 완료:", { + horizontal: horizontalDropZone ? "있음" : "없음", + vertical: verticalDropZone ? "있음" : "없음", + best: best?.id || "없음", + }); + + return { + horizontal: horizontalDropZone, + vertical: verticalDropZone, + best, + }; +} + +/** + * 드롭존 생성 + */ +function createDropZone( + targetSlot: number, + draggedColumns: number, + draggedComponentId: string, + rowIndex: number, + slotMap: SlotMap, + allComponents: ComponentData[], + gridInfo: GridInfo, + gridSettings: GridSettings, + minColumns: number, +): DropZone | null { + // 슬롯 범위 체크 + const endSlot = Math.min(11, targetSlot + draggedColumns - 1); + + // 배치 가능 여부 체크 + const placementCheck = canPlaceInSlot(targetSlot, draggedColumns, rowIndex, slotMap, allComponents, minColumns); + + if (!placementCheck.canPlace) { + return null; + } + + // 레이아웃 조정 계산 + const adjustment = calculateSlotPlacement( + targetSlot, + draggedColumns, + draggedComponentId, + rowIndex, + slotMap, + allComponents, + gridInfo, + gridSettings, + minColumns, + ); + + if (!adjustment.success) { + return null; + } + + // 드롭존 위치 및 크기 계산 + const x = slotToPosition(targetSlot, gridInfo); + const y = rowToPosition(rowIndex, gridInfo); + const width = draggedColumns * gridInfo.columnWidth + (draggedColumns - 1) * gridInfo.gap; + const height = 100; // 기본 높이 + + return { + id: `dropzone-${rowIndex}-${targetSlot}`, + slot: targetSlot, + endSlot, + rowIndex, + position: { x, y, z: 0 }, + width, + height, + placementCheck, + adjustment, + }; +} + +/** + * 최적의 드롭존 선택 + */ +function selectBestDropZone(horizontal: DropZone | null, vertical: DropZone | null): DropZone | null { + // 수평 드롭존 우선 (같은 행에 배치) + if (horizontal) { + // 빈 공간에 배치 가능하면 최우선 + if (horizontal.placementCheck.strategy === "EMPTY_SPACE") { + console.log("🏆 최적 드롭존: 수평 (빈 공간)"); + return horizontal; + } + + // 축소로 배치 가능하면 그 다음 우선 + if (horizontal.placementCheck.strategy === "SHRINK_COMPONENTS") { + console.log("🏆 최적 드롭존: 수평 (축소)"); + return horizontal; + } + } + + // 수직 드롭존 (다음 행으로) + if (vertical) { + console.log("🏆 최적 드롭존: 수직 (다음 행)"); + return vertical; + } + + // 수평 드롭존 (이동 전략) + if (horizontal) { + console.log("🏆 최적 드롭존: 수평 (이동)"); + return horizontal; + } + + console.log("❌ 적합한 드롭존 없음"); + return null; +} + +/** + * 드롭존 적용 (실제 레이아웃 업데이트) + */ +export function applyDropZone( + dropZone: DropZone, + draggedComponent: ComponentData, + allComponents: ComponentData[], +): ComponentData[] { + console.log("🎯 드롭존 적용:", { + dropZoneId: dropZone.id, + strategy: dropZone.placementCheck.strategy, + draggedComponentId: draggedComponent.id, + }); + + if (!dropZone.adjustment) { + console.error("❌ 조정 정보 없음"); + return allComponents; + } + + const { adjustment } = dropZone; + + // adjustment.adjustedComponents는 드래그 중인 컴포넌트를 제외한 다른 컴포넌트들만 포함 + // 따라서 드래그된 컴포넌트를 추가해야 함 + let updatedComponents = [...adjustment.adjustedComponents]; + + // 드래그된 컴포넌트가 이미 있는지 확인 + const draggedIndex = updatedComponents.findIndex((c) => c.id === draggedComponent.id); + + const draggedColumns = getComponentColumns(draggedComponent); + const updatedDraggedComponent = { + ...draggedComponent, + position: adjustment.placement, + gridColumns: draggedColumns, + columnSpan: draggedColumns, + } as ComponentData; + + if (draggedIndex !== -1) { + // 이미 있으면 업데이트 + updatedComponents[draggedIndex] = updatedDraggedComponent; + console.log("✅ 드래그 컴포넌트 업데이트 (기존):", { + id: draggedComponent.id, + position: adjustment.placement, + columns: draggedColumns, + }); + } else { + // 없으면 추가 + updatedComponents.push(updatedDraggedComponent); + console.log("✅ 드래그 컴포넌트 추가 (신규):", { + id: draggedComponent.id, + position: adjustment.placement, + columns: draggedColumns, + totalComponents: updatedComponents.length, + }); + } + + console.log("✅ 드롭존 적용 완료:", { + totalComponents: updatedComponents.length, + resized: adjustment.resizedComponents.length, + moved: adjustment.movedComponents.length, + }); + + return updatedComponents; +} + +/** + * 속성 패널에서 컬럼 수 변경 시 레이아웃 조정 + */ +export function adjustLayoutOnColumnChange( + targetComponentId: string, + newColumns: number, + allComponents: ComponentData[], + canvasWidth: number, + gridSettings: GridSettings, + minColumns: number = 2, +): ComponentData[] { + console.log("🔧 컬럼 수 변경 레이아웃 조정:", { + targetComponentId, + newColumns, + }); + + // 대상 컴포넌트 찾기 + const targetComponent = allComponents.find((c) => c.id === targetComponentId); + if (!targetComponent) { + console.error("❌ 대상 컴포넌트 없음"); + return allComponents; + } + + // 그리드 정보 생성 + const gridInfo = createGridInfo(canvasWidth, gridSettings); + + // 현재 위치의 슬롯 계산 + const targetSlot = positionToSlot(targetComponent.position.x, gridInfo); + const rowIndex = positionToRow(targetComponent.position.y, gridInfo); + + // 다른 컴포넌트들로 슬롯 맵 생성 + const otherComponents = allComponents.filter((c) => c.id !== targetComponentId); + const slotMap = buildSlotMap(otherComponents, gridInfo); + + // 레이아웃 조정 계산 + const adjustment = calculateSlotPlacement( + targetSlot, + newColumns, + targetComponentId, + rowIndex, + slotMap, + allComponents, + gridInfo, + gridSettings, + minColumns, + ); + + if (!adjustment.success) { + console.error("❌ 레이아웃 조정 실패"); + return allComponents; + } + + // 대상 컴포넌트 업데이트 + let updatedComponents = [...adjustment.adjustedComponents]; + const targetIndex = updatedComponents.findIndex((c) => c.id === targetComponentId); + + if (targetIndex !== -1) { + const newWidth = newColumns * gridInfo.columnWidth + (newColumns - 1) * gridInfo.gap; + + updatedComponents[targetIndex] = { + ...updatedComponents[targetIndex], + gridColumns: newColumns, + columnSpan: newColumns, + size: { + ...updatedComponents[targetIndex].size, + width: newWidth, + }, + } as ComponentData; + + console.log("✅ 대상 컴포넌트 업데이트:", { + id: targetComponentId, + newColumns, + newWidth, + }); + } + + console.log("✅ 레이아웃 조정 완료:", { + resized: adjustment.resizedComponents.length, + moved: adjustment.movedComponents.length, + }); + + return updatedComponents; +} diff --git a/frontend/lib/utils/slotCalculations.ts b/frontend/lib/utils/slotCalculations.ts new file mode 100644 index 00000000..06e2e8aa --- /dev/null +++ b/frontend/lib/utils/slotCalculations.ts @@ -0,0 +1,304 @@ +/** + * 슬롯 기반 레이아웃 계산 유틸리티 + * + * 핵심 개념: + * - 12컬럼 그리드를 12개의 슬롯(0-11)으로 나눔 + * - 각 컴포넌트는 여러 슬롯을 차지 + * - 드래그 위치를 슬롯 인덱스로 변환하여 정확한 배치 가능 + */ + +import { ComponentData, GridSettings, Position } from "@/types/screen"; + +/** + * 슬롯 맵 타입 정의 + * rowIndex를 키로 하고, 각 행은 12개의 슬롯(0-11)을 가짐 + * 각 슬롯은 컴포넌트 ID 또는 null(빈 슬롯) + */ +export type SlotMap = { + [rowIndex: number]: { + [slotIndex: number]: string | null; // 컴포넌트 ID 또는 null + }; +}; + +/** + * 슬롯 점유 정보 + */ +export interface SlotOccupancy { + componentId: string; + startSlot: number; // 0-11 + endSlot: number; // 0-11 + columns: number; +} + +/** + * 그리드 정보 (슬롯 계산용) + */ +export interface GridInfo { + columnWidth: number; + rowHeight: number; + gap: number; + totalColumns: number; +} + +/** + * 캔버스 너비와 그리드 설정으로 GridInfo 생성 + */ +export function createGridInfo(canvasWidth: number, gridSettings: GridSettings): GridInfo { + const { columns = 12, gap = 16 } = gridSettings; + const columnWidth = (canvasWidth - gap * (columns - 1)) / columns; + const rowHeight = 200; // 기본 행 높이 (Y 좌표 100px 이내를 같은 행으로 간주) + + return { + columnWidth, + rowHeight, + gap, + totalColumns: columns, + }; +} + +/** + * 컴포넌트의 컬럼 수 가져오기 + */ +export function getComponentColumns(component: ComponentData): number { + const anyComp = component as any; + return anyComp.gridColumns || anyComp.columnSpan || 12; +} + +/** + * X 좌표를 슬롯 인덱스로 변환 (0-11) + */ +export function positionToSlot(x: number, gridInfo: GridInfo): number { + const { columnWidth, gap } = gridInfo; + const slotWidth = columnWidth + gap; + + // X 좌표를 슬롯 인덱스로 변환 + let slot = Math.round(x / slotWidth); + + // 0-11 범위로 제한 + slot = Math.max(0, Math.min(11, slot)); + + return slot; +} + +/** + * Y 좌표를 행 인덱스로 변환 + */ +export function positionToRow(y: number, gridInfo: GridInfo): number { + const { rowHeight } = gridInfo; + return Math.floor(y / rowHeight); +} + +/** + * 슬롯 인덱스를 X 좌표로 변환 + */ +export function slotToPosition(slot: number, gridInfo: GridInfo): number { + const { columnWidth, gap } = gridInfo; + const slotWidth = columnWidth + gap; + return slot * slotWidth; +} + +/** + * 행 인덱스를 Y 좌표로 변환 + */ +export function rowToPosition(rowIndex: number, gridInfo: GridInfo): number { + const { rowHeight } = gridInfo; + return rowIndex * rowHeight; +} + +/** + * 컴포넌트 배열로부터 슬롯 맵 생성 + */ +export function buildSlotMap(components: ComponentData[], gridInfo: GridInfo): SlotMap { + const slotMap: SlotMap = {}; + + console.log("🗺️ 슬롯 맵 생성 시작:", { + componentCount: components.length, + gridInfo, + }); + + for (const component of components) { + const columns = getComponentColumns(component); + const x = component.position.x; + const y = component.position.y; + + // 컴포넌트의 시작 슬롯 계산 + const startSlot = positionToSlot(x, gridInfo); + + // 행 인덱스 계산 + const rowIndex = positionToRow(y, gridInfo); + + // 차지하는 슬롯 수 = 컬럼 수 + const endSlot = Math.min(11, startSlot + columns - 1); + + // 해당 행 초기화 + if (!slotMap[rowIndex]) { + slotMap[rowIndex] = {}; + // 모든 슬롯을 null로 초기화 + for (let i = 0; i < 12; i++) { + slotMap[rowIndex][i] = null; + } + } + + // 슬롯 점유 표시 + for (let slot = startSlot; slot <= endSlot; slot++) { + slotMap[rowIndex][slot] = component.id; + } + + console.log(`📍 컴포넌트 ${component.id}:`, { + position: { x, y }, + rowIndex, + startSlot, + endSlot, + columns, + slots: `${startSlot}-${endSlot}`, + }); + } + + // 각 행의 빈 슬롯 로그 + Object.entries(slotMap).forEach(([rowIndex, slots]) => { + const emptySlots = Object.entries(slots) + .filter(([_, componentId]) => componentId === null) + .map(([slotIndex, _]) => slotIndex); + + console.log(`📊 행 ${rowIndex} 상태:`, { + occupied: 12 - emptySlots.length, + empty: emptySlots.length, + emptySlots, + }); + }); + + return slotMap; +} + +/** + * 특정 행의 슬롯 점유 정보 추출 + */ +export function getRowOccupancy(rowIndex: number, slotMap: SlotMap): SlotOccupancy[] { + const row = slotMap[rowIndex]; + if (!row) return []; + + const occupancies: SlotOccupancy[] = []; + let currentComponent: string | null = null; + let startSlot = -1; + + for (let slot = 0; slot < 12; slot++) { + const componentId = row[slot]; + + if (componentId !== currentComponent) { + // 이전 컴포넌트 정보 저장 + if (currentComponent !== null && startSlot !== -1) { + occupancies.push({ + componentId: currentComponent, + startSlot, + endSlot: slot - 1, + columns: slot - startSlot, + }); + } + + // 새 컴포넌트 시작 + if (componentId !== null) { + currentComponent = componentId; + startSlot = slot; + } else { + currentComponent = null; + startSlot = -1; + } + } + } + + // 마지막 컴포넌트 처리 + if (currentComponent !== null && startSlot !== -1) { + occupancies.push({ + componentId: currentComponent, + startSlot, + endSlot: 11, + columns: 12 - startSlot, + }); + } + + return occupancies; +} + +/** + * 특정 슬롯 범위가 비어있는지 체크 + */ +export function areSlotsEmpty(startSlot: number, endSlot: number, rowIndex: number, slotMap: SlotMap): boolean { + const row = slotMap[rowIndex]; + if (!row) return true; // 행이 없으면 비어있음 + + for (let slot = startSlot; slot <= endSlot; slot++) { + if (row[slot] !== null) { + return false; + } + } + + return true; +} + +/** + * 특정 슬롯 범위를 차지하는 컴포넌트 ID 목록 + */ +export function getComponentsInSlots(startSlot: number, endSlot: number, rowIndex: number, slotMap: SlotMap): string[] { + const row = slotMap[rowIndex]; + if (!row) return []; + + const componentIds = new Set(); + + for (let slot = startSlot; slot <= endSlot; slot++) { + const componentId = row[slot]; + if (componentId !== null) { + componentIds.add(componentId); + } + } + + return Array.from(componentIds); +} + +/** + * 행의 빈 슬롯 수 계산 + */ +export function countEmptySlots(rowIndex: number, slotMap: SlotMap): number { + const row = slotMap[rowIndex]; + if (!row) return 12; // 행이 없으면 모두 비어있음 + + let emptyCount = 0; + for (let slot = 0; slot < 12; slot++) { + if (row[slot] === null) { + emptyCount++; + } + } + + return emptyCount; +} + +/** + * 컴포넌트가 차지하는 슬롯 범위 찾기 + */ +export function findComponentSlots( + componentId: string, + rowIndex: number, + slotMap: SlotMap, +): { startSlot: number; endSlot: number; columns: number } | null { + const row = slotMap[rowIndex]; + if (!row) return null; + + let startSlot = -1; + let endSlot = -1; + + for (let slot = 0; slot < 12; slot++) { + if (row[slot] === componentId) { + if (startSlot === -1) { + startSlot = slot; + } + endSlot = slot; + } + } + + if (startSlot === -1) return null; + + return { + startSlot, + endSlot, + columns: endSlot - startSlot + 1, + }; +}