276 lines
8.5 KiB
TypeScript
276 lines
8.5 KiB
TypeScript
/**
|
|
* 대시보드 그리드 시스템 유틸리티
|
|
* - 12 컬럼 그리드 시스템
|
|
* - 정사각형 셀 (가로 = 세로)
|
|
* - 스냅 기능
|
|
*/
|
|
|
|
// 기본 그리드 설정 (FHD 기준)
|
|
export const GRID_CONFIG = {
|
|
COLUMNS: 12, // 모든 해상도에서 12칸 고정
|
|
GAP: 5, // 셀 간격 고정
|
|
SNAP_THRESHOLD: 10, // 스냅 임계값 (px)
|
|
ELEMENT_PADDING: 4, // 요소 주위 여백 (px)
|
|
SUB_GRID_DIVISIONS: 5, // 각 그리드 칸을 5x5로 세분화 (세밀한 조정용)
|
|
// 가이드라인 시스템
|
|
GUIDELINE_SPACING: 12, // 가이드라인 간격 (px)
|
|
SNAP_DISTANCE: 10, // 자석 스냅 거리 (px)
|
|
GUIDELINE_COLOR: "rgba(59, 130, 246, 0.3)", // 가이드라인 색상
|
|
ROW_HEIGHT: 96, // 각 행의 높이 (12px * 8 = 96px)
|
|
GRID_BOX_SIZE: 40, // 그리드 박스 크기 (px) - [ ] 한 칸의 크기
|
|
GRID_BOX_GAP: 12, // 그리드 박스 간 간격 (px)
|
|
// CELL_SIZE와 CANVAS_WIDTH는 해상도에 따라 동적 계산
|
|
} as const;
|
|
|
|
/**
|
|
* 캔버스 너비에 맞춰 셀 크기 계산
|
|
* 공식: (CELL_SIZE + GAP) * 12 - GAP = canvasWidth
|
|
* CELL_SIZE = (canvasWidth + GAP) / 12 - GAP
|
|
*/
|
|
export function calculateCellSize(canvasWidth: number): number {
|
|
return Math.floor((canvasWidth + GRID_CONFIG.GAP) / GRID_CONFIG.COLUMNS) - GRID_CONFIG.GAP;
|
|
}
|
|
|
|
/**
|
|
* 서브 그리드 크기 계산 (세밀한 조정용)
|
|
*/
|
|
export function calculateSubGridSize(cellSize: number): number {
|
|
return Math.floor(cellSize / GRID_CONFIG.SUB_GRID_DIVISIONS);
|
|
}
|
|
|
|
/**
|
|
* 해상도별 그리드 설정 계산
|
|
*/
|
|
export function calculateGridConfig(canvasWidth: number) {
|
|
const cellSize = calculateCellSize(canvasWidth);
|
|
const subGridSize = calculateSubGridSize(cellSize);
|
|
return {
|
|
...GRID_CONFIG,
|
|
CELL_SIZE: cellSize,
|
|
SUB_GRID_SIZE: subGridSize,
|
|
CANVAS_WIDTH: canvasWidth,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 실제 그리드 셀 크기 계산 (gap 포함)
|
|
* @param canvasWidth - 캔버스 너비
|
|
*/
|
|
export const getCellWithGap = (canvasWidth: number = 1560) => {
|
|
const boxSize = calculateBoxSize(canvasWidth);
|
|
return boxSize + GRID_CONFIG.GRID_BOX_GAP;
|
|
};
|
|
|
|
/**
|
|
* 전체 캔버스 너비 계산
|
|
*/
|
|
export const getCanvasWidth = () => {
|
|
const cellWithGap = getCellWithGap();
|
|
return GRID_CONFIG.COLUMNS * cellWithGap - GRID_CONFIG.GAP;
|
|
};
|
|
|
|
/**
|
|
* 좌표를 서브 그리드에 스냅 (세밀한 조정 가능)
|
|
* @param value - 스냅할 좌표값
|
|
* @param subGridSize - 서브 그리드 크기 (선택사항)
|
|
* @returns 스냅된 좌표값
|
|
*/
|
|
export const snapToGrid = (value: number, subGridSize?: number): number => {
|
|
// 서브 그리드 크기가 지정되지 않으면 기본 박스 크기 사용
|
|
const snapSize = subGridSize ?? calculateBoxSize(1560);
|
|
|
|
// 그리드 단위로 스냅
|
|
const gridIndex = Math.round(value / snapSize);
|
|
return gridIndex * snapSize;
|
|
};
|
|
|
|
/**
|
|
* 좌표를 그리드에 스냅 (임계값 적용)
|
|
* @param value - 현재 좌표값
|
|
* @param cellSize - 셀 크기 (선택사항)
|
|
* @returns 스냅된 좌표값 (임계값 내에 있으면 스냅, 아니면 원래 값)
|
|
*/
|
|
export const snapToGridWithThreshold = (value: number, cellSize?: number): number => {
|
|
const snapSize = cellSize ?? calculateBoxSize(1560);
|
|
const snapped = snapToGrid(value, snapSize);
|
|
const distance = Math.abs(value - snapped);
|
|
|
|
return distance <= GRID_CONFIG.SNAP_THRESHOLD ? snapped : value;
|
|
};
|
|
|
|
/**
|
|
* 크기를 그리드 단위로 스냅
|
|
* @param size - 스냅할 크기
|
|
* @param minCells - 최소 셀 개수 (기본값: 2)
|
|
* @param cellSize - 셀 크기 (선택사항)
|
|
* @returns 스냅된 크기
|
|
*/
|
|
// 기존 snapSizeToGrid 제거 - 새 버전은 269번 줄에 있음
|
|
|
|
/**
|
|
* 위치와 크기를 모두 그리드에 스냅
|
|
*/
|
|
export interface GridPosition {
|
|
x: number;
|
|
y: number;
|
|
}
|
|
|
|
export interface GridSize {
|
|
width: number;
|
|
height: number;
|
|
}
|
|
|
|
export interface GridBounds {
|
|
position: GridPosition;
|
|
size: GridSize;
|
|
}
|
|
|
|
/**
|
|
* 요소의 위치와 크기를 그리드에 맞춰 조정
|
|
* @param bounds - 현재 위치와 크기
|
|
* @param canvasWidth - 캔버스 너비 (경계 체크용)
|
|
* @param canvasHeight - 캔버스 높이 (경계 체크용)
|
|
* @returns 그리드에 스냅된 위치와 크기
|
|
*/
|
|
export const snapBoundsToGrid = (bounds: GridBounds, canvasWidth?: number, canvasHeight?: number): GridBounds => {
|
|
// 위치 스냅
|
|
let snappedX = snapToGrid(bounds.position.x);
|
|
let snappedY = snapToGrid(bounds.position.y);
|
|
|
|
// 크기 스냅 (canvasWidth 기본값 1560)
|
|
const width = canvasWidth || 1560;
|
|
const snappedWidth = snapSizeToGrid(bounds.size.width, width);
|
|
const snappedHeight = snapSizeToGrid(bounds.size.height, width);
|
|
|
|
// 캔버스 경계 체크
|
|
if (canvasWidth) {
|
|
snappedX = Math.min(snappedX, canvasWidth - snappedWidth);
|
|
}
|
|
if (canvasHeight) {
|
|
snappedY = Math.min(snappedY, canvasHeight - snappedHeight);
|
|
}
|
|
|
|
// 음수 방지
|
|
snappedX = Math.max(0, snappedX);
|
|
snappedY = Math.max(0, snappedY);
|
|
|
|
return {
|
|
position: { x: snappedX, y: snappedY },
|
|
size: { width: snappedWidth, height: snappedHeight },
|
|
};
|
|
};
|
|
|
|
/**
|
|
* 좌표가 어느 그리드 셀에 속하는지 계산
|
|
* @param value - 좌표값
|
|
* @returns 그리드 인덱스 (0부터 시작)
|
|
*/
|
|
export const getGridIndex = (value: number): number => {
|
|
const cellWithGap = getCellWithGap();
|
|
return Math.floor(value / cellWithGap);
|
|
};
|
|
|
|
/**
|
|
* 그리드 인덱스를 좌표로 변환
|
|
* @param index - 그리드 인덱스
|
|
* @returns 좌표값
|
|
*/
|
|
export const gridIndexToCoordinate = (index: number): number => {
|
|
const cellWithGap = getCellWithGap();
|
|
return index * cellWithGap;
|
|
};
|
|
|
|
/**
|
|
* 스냅 가이드라인 표시용 좌표 계산
|
|
* @param value - 현재 좌표
|
|
* @returns 가장 가까운 그리드 라인들의 좌표 배열
|
|
*/
|
|
export const getNearbyGridLines = (value: number): number[] => {
|
|
const snapped = snapToGrid(value);
|
|
const cellWithGap = getCellWithGap();
|
|
|
|
return [snapped - cellWithGap, snapped, snapped + cellWithGap].filter((line) => line >= 0);
|
|
};
|
|
|
|
/**
|
|
* 위치가 스냅 임계값 내에 있는지 확인
|
|
* @param value - 현재 값
|
|
* @param snapValue - 스냅할 값
|
|
* @returns 임계값 내에 있으면 true
|
|
*/
|
|
export const isWithinSnapThreshold = (value: number, snapValue: number): boolean => {
|
|
return Math.abs(value - snapValue) <= GRID_CONFIG.SNAP_THRESHOLD;
|
|
};
|
|
|
|
// 박스 크기 계산 (캔버스 너비에 맞게)
|
|
export function calculateBoxSize(canvasWidth: number): number {
|
|
const totalGaps = 11 * GRID_CONFIG.GRID_BOX_GAP; // 12개 박스 사이 간격 11개
|
|
const availableWidth = canvasWidth - totalGaps;
|
|
return availableWidth / 12;
|
|
}
|
|
|
|
// 수직 그리드 박스 좌표 계산 (12개, 너비에 꽉 차게)
|
|
export function calculateVerticalGuidelines(canvasWidth: number): number[] {
|
|
const lines: number[] = [];
|
|
const boxSize = calculateBoxSize(canvasWidth);
|
|
|
|
for (let i = 0; i < 12; i++) {
|
|
const x = i * (boxSize + GRID_CONFIG.GRID_BOX_GAP);
|
|
lines.push(x);
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
// 수평 그리드 박스 좌표 계산 (캔버스 너비 기준으로 정사각형 유지)
|
|
export function calculateHorizontalGuidelines(canvasHeight: number, canvasWidth: number): number[] {
|
|
const lines: number[] = [];
|
|
const boxSize = calculateBoxSize(canvasWidth); // 수직과 동일한 박스 크기 사용
|
|
const cellSize = boxSize + GRID_CONFIG.GRID_BOX_GAP;
|
|
|
|
for (let y = 0; y <= canvasHeight; y += cellSize) {
|
|
lines.push(y);
|
|
}
|
|
return lines;
|
|
}
|
|
|
|
// 가장 가까운 가이드라인 찾기
|
|
export function findNearestGuideline(
|
|
value: number,
|
|
guidelines: number[],
|
|
): {
|
|
nearest: number;
|
|
distance: number;
|
|
} {
|
|
let nearest = guidelines[0];
|
|
let minDistance = Math.abs(value - guidelines[0]);
|
|
|
|
for (const guideline of guidelines) {
|
|
const distance = Math.abs(value - guideline);
|
|
if (distance < minDistance) {
|
|
minDistance = distance;
|
|
nearest = guideline;
|
|
}
|
|
}
|
|
|
|
return { nearest, distance: minDistance };
|
|
}
|
|
|
|
// 강제 스냅 (항상 가장 가까운 가이드라인에 스냅)
|
|
export function magneticSnap(value: number, guidelines: number[]): number {
|
|
const { nearest } = findNearestGuideline(value, guidelines);
|
|
return nearest; // 거리 체크 없이 무조건 스냅
|
|
}
|
|
|
|
// 크기를 그리드 박스 단위로 스냅 (박스 크기의 배수로만 가능)
|
|
export function snapSizeToGrid(size: number, canvasWidth: number): number {
|
|
const boxSize = calculateBoxSize(canvasWidth);
|
|
const cellSize = boxSize + GRID_CONFIG.GRID_BOX_GAP; // 박스 + 간격
|
|
|
|
// 최소 1개 박스 크기
|
|
const minBoxes = 1;
|
|
const boxes = Math.max(minBoxes, Math.round(size / cellSize));
|
|
|
|
// 박스 개수에서 마지막 간격 제거
|
|
return boxes * boxSize + (boxes - 1) * GRID_CONFIG.GRID_BOX_GAP;
|
|
}
|