253 lines
7.2 KiB
TypeScript
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;
|
|
}
|