178 lines
4.9 KiB
TypeScript
178 lines
4.9 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* 그리드 표시 모드
|
|
*
|
|
* CSS Grid로 셀 배치 (엑셀형 분할/병합 결과 반영)
|
|
* 각 셀에 @container 적용하여 내부 아이템 반응형
|
|
*
|
|
* 반응형 자동 조정:
|
|
* - containerWidth에 따라 열 수를 자동 축소
|
|
* - 설정된 열 수가 최대값이고, 공간이 부족하면 줄어듦
|
|
* - 셀당 최소 너비(MIN_CELL_WIDTH) 기준으로 판단
|
|
*/
|
|
|
|
import React, { useMemo } from "react";
|
|
import type { DashboardCell } from "../../types";
|
|
|
|
// ===== 상수 =====
|
|
|
|
/** 셀 하나의 최소 너비 (px). 이보다 좁아지면 열 수 축소 */
|
|
const MIN_CELL_WIDTH = 160;
|
|
|
|
// ===== 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 (
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
<span className="text-xs text-muted-foreground">셀 없음</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="h-full w-full"
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: `repeat(${actualColumns}, 1fr)`,
|
|
gridTemplateRows: `repeat(${actualRows}, 1fr)`,
|
|
gap: `${gap}px`,
|
|
}}
|
|
>
|
|
{remappedCells.map((cell) => (
|
|
<div
|
|
key={cell.id}
|
|
className="@container min-h-0 min-w-0 overflow-hidden rounded-md border border-border/50 bg-card"
|
|
style={{
|
|
gridColumn: cell.gridColumn,
|
|
gridRow: cell.gridRow,
|
|
}}
|
|
>
|
|
{cell.itemId ? (
|
|
renderItem(cell.itemId)
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center">
|
|
<span className="text-[10px] text-muted-foreground/50">
|
|
빈 셀
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|