624 lines
17 KiB
TypeScript
624 lines
17 KiB
TypeScript
import {
|
|
PopGridPosition,
|
|
GridMode,
|
|
GRID_BREAKPOINTS,
|
|
GridBreakpoint,
|
|
GapPreset,
|
|
GAP_PRESETS,
|
|
PopLayoutDataV5,
|
|
PopComponentDefinitionV5,
|
|
BLOCK_SIZE,
|
|
BLOCK_GAP,
|
|
BLOCK_PADDING,
|
|
getBlockColumns,
|
|
} from "../types/pop-layout";
|
|
|
|
// ========================================
|
|
// Gap/Padding 조정 (V6: 블록 간격 고정이므로 항상 원본 반환)
|
|
// ========================================
|
|
|
|
export function getAdjustedBreakpoint(
|
|
base: GridBreakpoint,
|
|
preset: GapPreset
|
|
): GridBreakpoint {
|
|
return { ...base };
|
|
}
|
|
|
|
// ========================================
|
|
// 그리드 위치 변환 (V6: 단일 좌표계이므로 변환 불필요)
|
|
// ========================================
|
|
|
|
/**
|
|
* V6: 단일 좌표계이므로 변환 없이 원본 반환
|
|
* @deprecated V6에서는 좌표 변환이 불필요합니다
|
|
*/
|
|
export function convertPositionToMode(
|
|
position: PopGridPosition,
|
|
targetMode: GridMode
|
|
): PopGridPosition {
|
|
return position;
|
|
}
|
|
|
|
/**
|
|
* V6 행 그룹 리플로우 (방식 F)
|
|
*
|
|
* 원리: CSS Flexbox wrap과 동일.
|
|
* 1. 같은 행의 컴포넌트를 한 묶음으로 처리
|
|
* 2. 최소 2x2칸 보장 (터치 가능한 최소 크기)
|
|
* 3. 한 줄에 안 들어가면 다음 줄로 줄바꿈 (숨김 없음)
|
|
* 4. 설계 너비의 50% 이상 → 전체 너비 확장
|
|
* 5. 리플로우 후 겹침 해결 (resolveOverlaps)
|
|
*/
|
|
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;
|
|
|
|
// 1. 원본 row 기준 그룹핑
|
|
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;
|
|
|
|
// 2. 각 행 그룹을 순서대로 처리
|
|
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);
|
|
}
|
|
|
|
// 3. 겹침 해결 (행 그룹 간 rowSpan 충돌 처리)
|
|
return resolveOverlaps(placed, targetColumns);
|
|
}
|
|
|
|
// ========================================
|
|
// 검토 필요 판별 (V6: 자동 줄바꿈이므로 검토 필요 없음)
|
|
// ========================================
|
|
|
|
/**
|
|
* V6: 단일 좌표계 + 자동 줄바꿈이므로 검토 필요 없음
|
|
* 항상 false 반환
|
|
*/
|
|
export function needsReview(
|
|
currentMode: GridMode,
|
|
hasOverride: boolean
|
|
): boolean {
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @deprecated V6에서는 자동 줄바꿈이므로 화면 밖 개념 없음
|
|
*/
|
|
export function isOutOfBounds(
|
|
originalPosition: PopGridPosition,
|
|
currentMode: GridMode,
|
|
overridePosition?: PopGridPosition | null
|
|
): boolean {
|
|
return false;
|
|
}
|
|
|
|
// ========================================
|
|
// 겹침 감지 및 해결
|
|
// ========================================
|
|
|
|
/**
|
|
* 두 위치가 겹치는지 확인
|
|
*/
|
|
export function isOverlapping(a: PopGridPosition, b: PopGridPosition): boolean {
|
|
// 열 겹침 체크
|
|
const aColEnd = a.col + a.colSpan - 1;
|
|
const bColEnd = b.col + b.colSpan - 1;
|
|
const colOverlap = !(aColEnd < b.col || bColEnd < a.col);
|
|
|
|
// 행 겹침 체크
|
|
const aRowEnd = a.row + a.rowSpan - 1;
|
|
const bRowEnd = b.row + b.rowSpan - 1;
|
|
const rowOverlap = !(aRowEnd < b.row || bRowEnd < a.row);
|
|
|
|
return colOverlap && rowOverlap;
|
|
}
|
|
|
|
/**
|
|
* 겹침 해결 (아래로 밀기)
|
|
*/
|
|
export function resolveOverlaps(
|
|
positions: Array<{ id: string; position: PopGridPosition }>,
|
|
columns: number
|
|
): Array<{ id: string; position: PopGridPosition }> {
|
|
// row, col 순으로 정렬
|
|
const sorted = [...positions].sort((a, b) =>
|
|
a.position.row - b.position.row || a.position.col - b.position.col
|
|
);
|
|
|
|
const resolved: Array<{ id: string; position: PopGridPosition }> = [];
|
|
|
|
sorted.forEach((item) => {
|
|
let { row, col, colSpan, rowSpan } = item.position;
|
|
|
|
// 열이 범위를 초과하면 조정
|
|
if (col + colSpan - 1 > columns) {
|
|
colSpan = columns - col + 1;
|
|
}
|
|
|
|
// 기존 배치와 겹치면 아래로 이동
|
|
let attempts = 0;
|
|
const maxAttempts = 100;
|
|
|
|
while (attempts < maxAttempts) {
|
|
const currentPos: PopGridPosition = { col, row, colSpan, rowSpan };
|
|
const hasOverlap = resolved.some(r => isOverlapping(currentPos, r.position));
|
|
|
|
if (!hasOverlap) break;
|
|
|
|
row++;
|
|
attempts++;
|
|
}
|
|
|
|
resolved.push({
|
|
id: item.id,
|
|
position: { col, row, colSpan, rowSpan },
|
|
});
|
|
});
|
|
|
|
return resolved;
|
|
}
|
|
|
|
// ========================================
|
|
// 좌표 변환
|
|
// ========================================
|
|
|
|
/**
|
|
* V6: 마우스 좌표 → 블록 그리드 좌표 변환
|
|
* 블록 크기가 고정(BLOCK_SIZE)이므로 계산이 단순함
|
|
*/
|
|
export function mouseToGridPosition(
|
|
mouseX: number,
|
|
mouseY: number,
|
|
canvasRect: DOMRect,
|
|
columns: number,
|
|
rowHeight: number,
|
|
gap: number,
|
|
padding: number
|
|
): { col: number; row: number } {
|
|
const relX = mouseX - canvasRect.left - padding;
|
|
const relY = mouseY - canvasRect.top - padding;
|
|
|
|
const cellStride = BLOCK_SIZE + gap;
|
|
|
|
const col = Math.max(1, Math.min(columns, Math.floor(relX / cellStride) + 1));
|
|
const row = Math.max(1, Math.floor(relY / cellStride) + 1);
|
|
|
|
return { col, row };
|
|
}
|
|
|
|
/**
|
|
* V6: 블록 그리드 좌표 → 픽셀 좌표 변환
|
|
*/
|
|
export function gridToPixelPosition(
|
|
col: number,
|
|
row: number,
|
|
colSpan: number,
|
|
rowSpan: number,
|
|
canvasWidth: number,
|
|
columns: number,
|
|
rowHeight: number,
|
|
gap: number,
|
|
padding: number
|
|
): { x: number; y: number; width: number; height: number } {
|
|
const cellStride = BLOCK_SIZE + gap;
|
|
|
|
return {
|
|
x: padding + (col - 1) * cellStride,
|
|
y: padding + (row - 1) * cellStride,
|
|
width: BLOCK_SIZE * colSpan + gap * (colSpan - 1),
|
|
height: BLOCK_SIZE * rowSpan + gap * (rowSpan - 1),
|
|
};
|
|
}
|
|
|
|
// ========================================
|
|
// 위치 검증
|
|
// ========================================
|
|
|
|
/**
|
|
* 위치가 그리드 범위 내에 있는지 확인
|
|
*/
|
|
export function isValidPosition(
|
|
position: PopGridPosition,
|
|
columns: number
|
|
): boolean {
|
|
return (
|
|
position.col >= 1 &&
|
|
position.row >= 1 &&
|
|
position.colSpan >= 1 &&
|
|
position.rowSpan >= 1 &&
|
|
position.col + position.colSpan - 1 <= columns
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 위치를 그리드 범위 내로 조정
|
|
*/
|
|
export function clampPosition(
|
|
position: PopGridPosition,
|
|
columns: number
|
|
): PopGridPosition {
|
|
let { col, row, colSpan, rowSpan } = position;
|
|
|
|
// 최소값 보장
|
|
col = Math.max(1, col);
|
|
row = Math.max(1, row);
|
|
colSpan = Math.max(1, colSpan);
|
|
rowSpan = Math.max(1, rowSpan);
|
|
|
|
// 열 범위 초과 방지
|
|
if (col + colSpan - 1 > columns) {
|
|
if (col > columns) {
|
|
col = 1;
|
|
}
|
|
colSpan = columns - col + 1;
|
|
}
|
|
|
|
return { col, row, colSpan, rowSpan };
|
|
}
|
|
|
|
// ========================================
|
|
// 자동 배치
|
|
// ========================================
|
|
|
|
/**
|
|
* 다음 빈 위치 찾기
|
|
*/
|
|
export function findNextEmptyPosition(
|
|
existingPositions: PopGridPosition[],
|
|
colSpan: number,
|
|
rowSpan: number,
|
|
columns: number
|
|
): PopGridPosition {
|
|
let row = 1;
|
|
let col = 1;
|
|
|
|
const maxAttempts = 1000;
|
|
let attempts = 0;
|
|
|
|
while (attempts < maxAttempts) {
|
|
const candidatePos: PopGridPosition = { col, row, colSpan, rowSpan };
|
|
|
|
// 범위 체크
|
|
if (col + colSpan - 1 > columns) {
|
|
col = 1;
|
|
row++;
|
|
continue;
|
|
}
|
|
|
|
// 겹침 체크
|
|
const hasOverlap = existingPositions.some(pos =>
|
|
isOverlapping(candidatePos, pos)
|
|
);
|
|
|
|
if (!hasOverlap) {
|
|
return candidatePos;
|
|
}
|
|
|
|
// 다음 위치로 이동
|
|
col++;
|
|
if (col + colSpan - 1 > columns) {
|
|
col = 1;
|
|
row++;
|
|
}
|
|
|
|
attempts++;
|
|
}
|
|
|
|
// 실패 시 마지막 행에 배치
|
|
return { col: 1, row: row + 1, colSpan, rowSpan };
|
|
}
|
|
|
|
/**
|
|
* 컴포넌트들을 자동으로 배치
|
|
*/
|
|
export function autoLayoutComponents(
|
|
components: Array<{ id: string; colSpan: number; rowSpan: number }>,
|
|
columns: number
|
|
): Array<{ id: string; position: PopGridPosition }> {
|
|
const result: Array<{ id: string; position: PopGridPosition }> = [];
|
|
|
|
let currentRow = 1;
|
|
let currentCol = 1;
|
|
|
|
components.forEach(comp => {
|
|
// 현재 행에 공간이 부족하면 다음 행으로
|
|
if (currentCol + comp.colSpan - 1 > columns) {
|
|
currentRow++;
|
|
currentCol = 1;
|
|
}
|
|
|
|
result.push({
|
|
id: comp.id,
|
|
position: {
|
|
col: currentCol,
|
|
row: currentRow,
|
|
colSpan: comp.colSpan,
|
|
rowSpan: comp.rowSpan,
|
|
},
|
|
});
|
|
|
|
currentCol += comp.colSpan;
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
// ========================================
|
|
// 유효 위치 계산 (통합 함수)
|
|
// ========================================
|
|
|
|
/**
|
|
* 컴포넌트의 유효 위치를 계산합니다.
|
|
* 우선순위: 1. 오버라이드 → 2. 자동 재배치 → 3. 원본 위치
|
|
*
|
|
* @param componentId 컴포넌트 ID
|
|
* @param layout 전체 레이아웃 데이터
|
|
* @param mode 현재 그리드 모드
|
|
* @param autoResolvedPositions 미리 계산된 자동 재배치 위치 (선택적)
|
|
*/
|
|
export function getEffectiveComponentPosition(
|
|
componentId: string,
|
|
layout: PopLayoutDataV5,
|
|
mode: GridMode,
|
|
autoResolvedPositions?: Array<{ id: string; position: PopGridPosition }>
|
|
): PopGridPosition | null {
|
|
const component = layout.components[componentId];
|
|
if (!component) return null;
|
|
|
|
// 1순위: 오버라이드가 있으면 사용
|
|
const override = layout.overrides?.[mode]?.positions?.[componentId];
|
|
if (override) {
|
|
return { ...component.position, ...override };
|
|
}
|
|
|
|
// 2순위: 자동 재배치된 위치 사용
|
|
if (autoResolvedPositions) {
|
|
const autoResolved = autoResolvedPositions.find(p => p.id === componentId);
|
|
if (autoResolved) {
|
|
return autoResolved.position;
|
|
}
|
|
} else {
|
|
// 자동 재배치 직접 계산
|
|
const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({
|
|
id,
|
|
position: comp.position,
|
|
}));
|
|
const resolved = convertAndResolvePositions(componentsArray, mode);
|
|
const autoResolved = resolved.find(p => p.id === componentId);
|
|
if (autoResolved) {
|
|
return autoResolved.position;
|
|
}
|
|
}
|
|
|
|
// 3순위: 원본 위치 (12칸 모드)
|
|
return component.position;
|
|
}
|
|
|
|
/**
|
|
* 모든 컴포넌트의 유효 위치를 일괄 계산합니다.
|
|
* 숨김 처리된 컴포넌트는 제외됩니다.
|
|
*
|
|
* v5.1: 자동 줄바꿈 시스템으로 인해 모든 컴포넌트가 그리드 안에 배치되므로
|
|
* "화면 밖" 개념이 제거되었습니다.
|
|
*/
|
|
export function getAllEffectivePositions(
|
|
layout: PopLayoutDataV5,
|
|
mode: GridMode
|
|
): Map<string, PopGridPosition> {
|
|
const result = new Map<string, PopGridPosition>();
|
|
|
|
// 숨김 처리된 컴포넌트 ID 목록
|
|
const hiddenIds = layout.overrides?.[mode]?.hidden || [];
|
|
|
|
// 자동 재배치 위치 미리 계산
|
|
const componentsArray = Object.entries(layout.components).map(([id, comp]) => ({
|
|
id,
|
|
position: comp.position,
|
|
}));
|
|
const autoResolvedPositions = convertAndResolvePositions(componentsArray, mode);
|
|
|
|
// 각 컴포넌트의 유효 위치 계산
|
|
Object.keys(layout.components).forEach(componentId => {
|
|
// 숨김 처리된 컴포넌트는 제외
|
|
if (hiddenIds.includes(componentId)) {
|
|
return;
|
|
}
|
|
|
|
const position = getEffectiveComponentPosition(
|
|
componentId,
|
|
layout,
|
|
mode,
|
|
autoResolvedPositions
|
|
);
|
|
|
|
// v5.1: 자동 줄바꿈으로 인해 모든 컴포넌트가 그리드 안에 있음
|
|
// 따라서 추가 필터링 불필요
|
|
if (position) {
|
|
result.set(componentId, position);
|
|
}
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
// ========================================
|
|
// V5 → V6 런타임 변환 (DB 미수정, 로드 시 변환)
|
|
// ========================================
|
|
|
|
const V5_BASE_COLUMNS = 12;
|
|
const V5_BASE_ROW_HEIGHT = 48;
|
|
const V5_BASE_GAP = 16;
|
|
const V5_DESIGN_WIDTH = 1024;
|
|
|
|
/**
|
|
* V5 레이아웃 판별: gridConfig.rowHeight가 V5 기본값(48)이고
|
|
* 좌표가 12칸 체계인 경우만 V5로 판정
|
|
*/
|
|
function isV5GridConfig(layout: PopLayoutDataV5): boolean {
|
|
if (layout.gridConfig?.rowHeight === BLOCK_SIZE) return false;
|
|
|
|
const maxCol = Object.values(layout.components).reduce((max, comp) => {
|
|
const end = comp.position.col + comp.position.colSpan - 1;
|
|
return Math.max(max, end);
|
|
}, 0);
|
|
|
|
return maxCol <= V5_BASE_COLUMNS;
|
|
}
|
|
|
|
function convertV5PositionToV6(
|
|
pos: PopGridPosition,
|
|
v6DesignColumns: number,
|
|
): PopGridPosition {
|
|
const colRatio = v6DesignColumns / V5_BASE_COLUMNS;
|
|
const rowRatio = (V5_BASE_ROW_HEIGHT + V5_BASE_GAP) / (BLOCK_SIZE + BLOCK_GAP);
|
|
|
|
const newCol = Math.max(1, Math.round((pos.col - 1) * colRatio) + 1);
|
|
let newColSpan = Math.max(1, Math.round(pos.colSpan * colRatio));
|
|
const newRowSpan = Math.max(1, Math.round(pos.rowSpan * rowRatio));
|
|
|
|
if (newCol + newColSpan - 1 > v6DesignColumns) {
|
|
newColSpan = v6DesignColumns - newCol + 1;
|
|
}
|
|
|
|
return { col: newCol, row: pos.row, colSpan: newColSpan, rowSpan: newRowSpan };
|
|
}
|
|
|
|
/**
|
|
* V5 레이아웃을 V6 블록 좌표로 런타임 변환
|
|
* - 기본 모드(tablet_landscape) 좌표를 블록 단위로 변환
|
|
* - 모드별 overrides 폐기 (자동 줄바꿈으로 대체)
|
|
* - DB 데이터는 건드리지 않음 (메모리에서만 변환)
|
|
*/
|
|
export function convertV5LayoutToV6(layout: PopLayoutDataV5): PopLayoutDataV5 {
|
|
// V5 오버라이드는 V6에서 무효 (4/6/8칸용 좌표가 13/22/31칸에 맞지 않음)
|
|
// 좌표 변환 필요 여부와 무관하게 항상 제거
|
|
if (!isV5GridConfig(layout)) {
|
|
return {
|
|
...layout,
|
|
gridConfig: {
|
|
rowHeight: BLOCK_SIZE,
|
|
gap: BLOCK_GAP,
|
|
padding: BLOCK_PADDING,
|
|
},
|
|
overrides: undefined,
|
|
};
|
|
}
|
|
|
|
const v6Columns = getBlockColumns(V5_DESIGN_WIDTH);
|
|
|
|
const rowGroups: Record<number, string[]> = {};
|
|
Object.entries(layout.components).forEach(([id, comp]) => {
|
|
const r = comp.position.row;
|
|
if (!rowGroups[r]) rowGroups[r] = [];
|
|
rowGroups[r].push(id);
|
|
});
|
|
|
|
const convertedPositions: Record<string, PopGridPosition> = {};
|
|
Object.entries(layout.components).forEach(([id, comp]) => {
|
|
convertedPositions[id] = convertV5PositionToV6(comp.position, v6Columns);
|
|
});
|
|
|
|
const sortedRows = Object.keys(rowGroups).map(Number).sort((a, b) => a - b);
|
|
const rowMapping: Record<number, number> = {};
|
|
let v6Row = 1;
|
|
for (const v5Row of sortedRows) {
|
|
rowMapping[v5Row] = v6Row;
|
|
const maxSpan = Math.max(
|
|
...rowGroups[v5Row].map(id => convertedPositions[id].rowSpan)
|
|
);
|
|
v6Row += maxSpan;
|
|
}
|
|
|
|
const newComponents = { ...layout.components };
|
|
Object.entries(newComponents).forEach(([id, comp]) => {
|
|
const converted = convertedPositions[id];
|
|
const mappedRow = rowMapping[comp.position.row] ?? converted.row;
|
|
newComponents[id] = {
|
|
...comp,
|
|
position: { ...converted, row: mappedRow },
|
|
};
|
|
});
|
|
|
|
const newModals = layout.modals?.map(modal => {
|
|
const modalComps = { ...modal.components };
|
|
Object.entries(modalComps).forEach(([id, comp]) => {
|
|
modalComps[id] = {
|
|
...comp,
|
|
position: convertV5PositionToV6(comp.position, v6Columns),
|
|
};
|
|
});
|
|
return {
|
|
...modal,
|
|
gridConfig: { rowHeight: BLOCK_SIZE, gap: BLOCK_GAP, padding: BLOCK_PADDING },
|
|
components: modalComps,
|
|
overrides: undefined,
|
|
};
|
|
});
|
|
|
|
return {
|
|
...layout,
|
|
gridConfig: { rowHeight: BLOCK_SIZE, gap: BLOCK_GAP, padding: BLOCK_PADDING },
|
|
components: newComponents,
|
|
overrides: undefined,
|
|
modals: newModals,
|
|
};
|
|
}
|