캔버스 작동방식 수정 중간커밋

This commit is contained in:
kjs 2025-10-21 17:51:00 +09:00
parent ec4d8f9b94
commit 41dee6956d
4 changed files with 1744 additions and 32 deletions

View File

@ -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에 포함하여 실시간 리렌더링

View File

@ -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<number>();
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 },
};
}

View File

@ -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;
}

View File

@ -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<string>();
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,
};
}