"use client"; /** * 그리드 표시 모드 * * CSS Grid로 셀 배치 (엑셀형 분할/병합 결과 반영) * 각 셀에 @container 적용하여 내부 아이템 반응형 * * 반응형 자동 조정: * - containerWidth에 따라 열 수를 자동 축소 * - 설정된 열 수가 최대값이고, 공간이 부족하면 줄어듦 * - 셀당 최소 너비(MIN_CELL_WIDTH) 기준으로 판단 */ import React, { useMemo } from "react"; import type { DashboardCell } from "../../types"; // ===== 상수 ===== /** 셀 하나의 최소 너비 (px). 이보다 좁아지면 열 수 축소 */ const MIN_CELL_WIDTH = 80; // ===== Props ===== export interface GridModeProps { /** 셀 배치 정보 */ cells: DashboardCell[]; /** 설정된 열 수 (최대값) */ columns: number; /** 설정된 행 수 */ rows: number; /** 아이템 간 간격 (px) */ gap?: number; /** 컨테이너 너비 (px, 반응형 자동 조정용) */ containerWidth?: number; /** 셀의 아이템 렌더링. itemId가 null이면 빈 셀 */ renderItem: (itemId: string) => React.ReactNode; } // ===== 반응형 열 수 계산 ===== /** * 컨테이너 너비에 맞는 실제 열 수를 계산 * * 설정된 columns가 최대값이고, 공간이 부족하면 축소. * gap도 고려하여 계산. * * 예: columns=3, containerWidth=400, gap=8, MIN_CELL_WIDTH=160 * 사용 가능 너비 = 400 - (3-1)*8 = 384 * 셀당 너비 = 384/3 = 128 < 160 -> 열 축소 * columns=2: 사용 가능 = 400 - (2-1)*8 = 392, 셀당 = 196 >= 160 -> OK */ function computeResponsiveColumns( configColumns: number, containerWidth: number, gap: number ): number { if (containerWidth <= 0) return configColumns; for (let cols = configColumns; cols >= 1; cols--) { const totalGap = (cols - 1) * gap; const cellWidth = (containerWidth - totalGap) / cols; if (cellWidth >= MIN_CELL_WIDTH) return cols; } return 1; } /** * 열 수가 줄어들 때 셀 배치를 자동 재배열 * * 원본 gridColumn/gridRow를 actualColumns에 맞게 재매핑 * 셀이 원래 위치를 유지하려고 시도하되, 넘치면 아래로 이동 */ function remapCells( cells: DashboardCell[], configColumns: number, actualColumns: number, configRows: number ): { remappedCells: DashboardCell[]; actualRows: number } { // 열 수가 같으면 원본 그대로 if (actualColumns >= configColumns) { return { remappedCells: cells, actualRows: configRows }; } // 셀을 원래 위치 순서대로 정렬 (행 우선) const sorted = [...cells].sort((a, b) => { const aRow = parseInt(a.gridRow.split(" / ")[0]) || 0; const bRow = parseInt(b.gridRow.split(" / ")[0]) || 0; if (aRow !== bRow) return aRow - bRow; const aCol = parseInt(a.gridColumn.split(" / ")[0]) || 0; const bCol = parseInt(b.gridColumn.split(" / ")[0]) || 0; return aCol - bCol; }); // 순서대로 새 위치에 배치 let maxRow = 0; const remapped = sorted.map((cell, index) => { const newCol = (index % actualColumns) + 1; const newRow = Math.floor(index / actualColumns) + 1; maxRow = Math.max(maxRow, newRow); return { ...cell, gridColumn: `${newCol} / ${newCol + 1}`, gridRow: `${newRow} / ${newRow + 1}`, }; }); return { remappedCells: remapped, actualRows: maxRow }; } // ===== 메인 컴포넌트 ===== export function GridModeComponent({ cells, columns, rows, gap = 8, containerWidth, renderItem, }: GridModeProps) { // 반응형 열 수 계산 const actualColumns = useMemo( () => containerWidth ? computeResponsiveColumns(columns, containerWidth, gap) : columns, [columns, containerWidth, gap] ); // 열 수가 줄었으면 셀 재배열 const { remappedCells, actualRows } = useMemo( () => remapCells(cells, columns, actualColumns, rows), [cells, columns, actualColumns, rows] ); if (!remappedCells.length) { return (
셀 없음
); } return (
{remappedCells.map((cell) => (
{cell.itemId ? ( renderItem(cell.itemId) ) : (
빈 셀
)}
))}
); }