ERP-node/frontend/components/pop/designer/utils/gridUtils.ts

624 lines
17 KiB
TypeScript
Raw Normal View History

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