Files
vexplor/frontend/components/pop/designer/utils/gridUtils.ts
SeongHyun Kim 320100c4e2 refactor: POP 그리드 시스템 명칭 통일 + Dead Code 제거
V5→V6 전환 과정에서 누적된 버전 접미사, 미사용 함수, 레거시 잔재를
정리하여 코드 관리성을 확보한다. 14개 파일 수정, 365줄 순감.
[타입 리네이밍] (14개 파일)
- PopLayoutDataV5 → PopLayoutData
- PopComponentDefinitionV5 → PopComponentDefinition
- PopGlobalSettingsV5 → PopGlobalSettings
- PopModeOverrideV5 → PopModeOverride
- createEmptyPopLayoutV5 → createEmptyLayout
- isV5Layout → isPopLayout
- addComponentToV5Layout → addComponentToLayout
- createComponentDefinitionV5 → createComponentDefinition
- 구 이름은 deprecated 별칭으로 유지 (하위 호환)
[Dead Code 삭제] (gridUtils.ts -350줄)
- getAdjustedBreakpoint, convertPositionToMode, isOutOfBounds,
  mouseToGridPosition, gridToPixelPosition, isValidPosition,
  clampPosition, autoLayoutComponents (전부 외부 사용 0건)
- needsReview + ReviewPanel/ReviewItem (항상 false, 미사용)
- getEffectiveComponentPosition export → 내부 함수로 전환
[레거시 로더 분리] (신규 legacyLoader.ts)
- convertV5LayoutToV6 → loadLegacyLayout (legacyLoader.ts)
- V5 변환 상수/함수를 gridUtils에서 분리
[주석 정리]
- "v5 그리드" → "POP 블록 그리드"
- "하위 호환용" → "뷰포트 프리셋" / "레이아웃 설정용"
- 파일 헤더, 섹션 구분, 함수 JSDoc 정리
기능 변경 0건. DB 변경 0건. 백엔드 변경 0건.
2026-03-13 16:39:51 +09:00

253 lines
7.2 KiB
TypeScript

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