/** * 대시보드 그리드 시스템 유틸리티 * - 12 컬럼 그리드 시스템 * - 정사각형 셀 (가로 = 세로) * - 스냅 기능 */ // 기본 그리드 설정 (FHD 기준) export const GRID_CONFIG = { COLUMNS: 12, // 모든 해상도에서 12칸 고정 GAP: 5, // 셀 간격 고정 SNAP_THRESHOLD: 10, // 스냅 임계값 (px) ELEMENT_PADDING: 4, // 요소 주위 여백 (px) SUB_GRID_DIVISIONS: 5, // 각 그리드 칸을 5x5로 세분화 (세밀한 조정용) // 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 포함) */ export const getCellWithGap = () => { return GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP; }; /** * 전체 캔버스 너비 계산 */ export const getCanvasWidth = () => { const cellWithGap = getCellWithGap(); return GRID_CONFIG.COLUMNS * cellWithGap - GRID_CONFIG.GAP; }; /** * 좌표를 서브 그리드에 스냅 (세밀한 조정 가능) * @param value - 스냅할 좌표값 * @param subGridSize - 서브 그리드 크기 (선택사항, 기본값: cellSize/3 ≈ 43px) * @returns 스냅된 좌표값 */ export const snapToGrid = (value: number, subGridSize?: number): number => { // 서브 그리드 크기가 지정되지 않으면 기본 그리드 크기의 1/3 사용 (3x3 서브그리드) const snapSize = subGridSize ?? Math.floor(GRID_CONFIG.CELL_SIZE / 3); // 서브 그리드 단위로 스냅 const gridIndex = Math.round(value / snapSize); return gridIndex * snapSize; }; /** * 좌표를 그리드에 스냅 (임계값 적용) * @param value - 현재 좌표값 * @param cellSize - 셀 크기 (선택사항) * @returns 스냅된 좌표값 (임계값 내에 있으면 스냅, 아니면 원래 값) */ export const snapToGridWithThreshold = (value: number, cellSize: number = GRID_CONFIG.CELL_SIZE): number => { const snapped = snapToGrid(value, cellSize); const distance = Math.abs(value - snapped); return distance <= GRID_CONFIG.SNAP_THRESHOLD ? snapped : value; }; /** * 크기를 그리드 단위로 스냅 * @param size - 스냅할 크기 * @param minCells - 최소 셀 개수 (기본값: 2) * @param cellSize - 셀 크기 (선택사항) * @returns 스냅된 크기 */ export const snapSizeToGrid = ( size: number, minCells: number = 2, cellSize: number = GRID_CONFIG.CELL_SIZE, ): number => { const cellWithGap = cellSize + GRID_CONFIG.GAP; const cells = Math.max(minCells, Math.round(size / cellWithGap)); return cells * cellWithGap - GRID_CONFIG.GAP; }; /** * 위치와 크기를 모두 그리드에 스냅 */ 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); // 크기 스냅 const snappedWidth = snapSizeToGrid(bounds.size.width); const snappedHeight = snapSizeToGrid(bounds.size.height); // 캔버스 경계 체크 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; };