diff --git a/src/features/dashboard/edit-layout/model/useDashboardLayout.ts b/src/features/dashboard/edit-layout/model/useDashboardLayout.ts index 89e720f..80304b3 100644 --- a/src/features/dashboard/edit-layout/model/useDashboardLayout.ts +++ b/src/features/dashboard/edit-layout/model/useDashboardLayout.ts @@ -7,6 +7,7 @@ import { toast } from 'sonner'; import type { Layout, LayoutItem } from 'react-grid-layout'; import { saveDashboardLayout } from '@/entities/dashboard-layout/api/save-dashboard-layout'; +import { areLayoutsEqual, normalizeLayout } from '@/shared/dashboard/lib/normalize-layout'; import type { DashboardLayoutState } from '@/entities/dashboard-layout/model/dashboard-layout.types'; // 저장·상태로 남기는 값은 위치(i,x,y,w,h)만 — minW/minH 등 위젯 제약은 카탈로그가 소유하며 @@ -19,14 +20,22 @@ interface UseDashboardLayoutParams { pageType: string; /** 서버(RSC)에서 조회한 초기 레이아웃 */ initialLayout: DashboardLayoutState; + constraintsById: Partial>>; } export function useDashboardLayout({ workspaceId, pageType, initialLayout, + constraintsById, }: UseDashboardLayoutParams) { - const [layout, setLayout] = useState(initialLayout.layout); + const initialNormalizedLayout = normalizeLayout(initialLayout.layout, 12, constraintsById); + const shouldPersistInitialNormalization = !areLayoutsEqual( + initialLayout.layout, + initialNormalizedLayout, + ); + + const [layout, setLayout] = useState(initialNormalizedLayout); const [editMode, setEditMode] = useState(false); const didMountRef = useRef(false); const saveQueueRef = useRef(Promise.resolve()); @@ -34,7 +43,7 @@ export function useDashboardLayout({ useEffect(() => { if (!didMountRef.current) { didMountRef.current = true; - return; + if (!shouldPersistInitialNormalization) return; } const timeoutId = window.setTimeout(() => { @@ -45,23 +54,23 @@ export function useDashboardLayout({ console.error(error); toast.error('대시보드 레이아웃 저장에 실패했습니다.'); }); - }, 400); + }, shouldPersistInitialNormalization ? 0 : 400); return () => window.clearTimeout(timeoutId); - }, [layout, workspaceId, pageType]); + }, [layout, workspaceId, pageType, shouldPersistInitialNormalization]); const handleLayoutChange = useCallback((next: Layout) => { - const positions = next.map(toPosition); + const positions = normalizeLayout(next.map(toPosition), 12, constraintsById); setLayout(positions); - }, []); + }, [constraintsById]); const addWidget = useCallback( (item: LayoutItem) => setLayout((prev) => { if (prev.some((entry) => entry.i === item.i)) return prev; - return [...prev, toPosition(item)]; + return normalizeLayout([...prev, toPosition(item)], 12, constraintsById); }), - [], + [constraintsById], ); const removeWidget = useCallback( diff --git a/src/shared/dashboard/lib/normalize-layout.ts b/src/shared/dashboard/lib/normalize-layout.ts new file mode 100644 index 0000000..c0c3194 --- /dev/null +++ b/src/shared/dashboard/lib/normalize-layout.ts @@ -0,0 +1,97 @@ +import type { Layout, LayoutItem } from 'react-grid-layout'; + +interface LayoutRect { + x: number; + y: number; + w: number; + h: number; +} + +function isOverlapping(left: LayoutRect, right: LayoutRect) { + return ( + left.x < right.x + right.w && + left.x + left.w > right.x && + left.y < right.y + right.h && + left.y + left.h > right.y + ); +} + +type LayoutConstraintMap = Partial>>; + +function getBoundedSize( + value: number, + minValue: number | undefined, + maxValue: number | undefined, + fallbackMin = 1, +) { + const lowerBound = Math.max(fallbackMin, minValue ?? fallbackMin); + const upperBound = Math.max(lowerBound, maxValue ?? value); + + return Math.min(Math.max(value, lowerBound), upperBound); +} + +function clampLayoutItem( + item: LayoutItem, + cols: number, + constraints?: Partial, +): LayoutItem { + const width = getBoundedSize(item.w, constraints?.minW, Math.min(constraints?.maxW ?? cols, cols)); + const height = getBoundedSize(item.h, constraints?.minH, constraints?.maxH); + const x = Math.max(0, Math.min(item.x, cols - width)); + const y = Math.max(0, item.y); + + return { ...constraints, ...item, x, y, w: width, h: height }; +} + +export function areLayoutsEqual(left: Layout, right: Layout) { + return ( + left.length === right.length && + left.every((item, index) => { + const other = right[index]; + return ( + item.i === other?.i && + item.x === other.x && + item.y === other.y && + item.w === other.w && + item.h === other.h + ); + }) + ); +} + +/** + * 저장된 레이아웃이 오래된 기본값/위젯 크기 변경과 충돌해도 + * 렌더 시점에 서로 겹치지 않도록 y축으로 밀어내며 정규화한다. + */ +export function normalizeLayout(layout: Layout, cols = 12, constraintsById: LayoutConstraintMap = {}): Layout { + const working = layout + .map((item, index) => ({ + ...clampLayoutItem(item, cols, constraintsById[item.i]), + _index: index, + })) + .sort((left, right) => left.y - right.y || left.x - right.x || left._index - right._index); + + const placed: Array = []; + + working.forEach((item) => { + const nextItem = { ...item }; + + while (true) { + const overlapping = placed.filter((placedItem) => isOverlapping(nextItem, placedItem)); + + if (overlapping.length === 0) break; + + nextItem.y = Math.max(...overlapping.map((placedItem) => placedItem.y + placedItem.h)); + } + + placed.push(nextItem); + }); + + return placed + .sort((left, right) => left._index - right._index) + .map((item) => { + const { _index: removedIndex, ...normalizedItem } = item; + void removedIndex; + return normalizedItem; + }); +} diff --git a/src/views/dashboard/ui/DashboardGrid.tsx b/src/views/dashboard/ui/DashboardGrid.tsx index 004299c..05129fe 100644 --- a/src/views/dashboard/ui/DashboardGrid.tsx +++ b/src/views/dashboard/ui/DashboardGrid.tsx @@ -13,6 +13,7 @@ import type { Layout, ResizeHandleAxis } from 'react-grid-layout'; import { Maximize2, GripVertical, Trash2 } from 'lucide-react'; import { getWidgetSize } from '@/shared/dashboard/lib/widget-size'; +import { normalizeLayout } from '@/shared/dashboard/lib/normalize-layout'; import type { WidgetDefinition } from '@/shared/dashboard/model/widget.types'; import type { WorkspacePurpose } from '@/shared/dashboard/model/template.types'; @@ -60,10 +61,15 @@ export default function DashboardGrid({ const visibleLayout = layout .filter((item) => byId.has(item.i)) .map((item) => ({ ...byId.get(item.i)!.layout, ...item })); + const normalizedVisibleLayout = normalizeLayout( + visibleLayout, + 12, + Object.fromEntries(visibleLayout.map((item) => [item.i, byId.get(item.i)!.layout])), + ); return (
- {visibleLayout.length === 0 ? ( + {normalizedVisibleLayout.length === 0 ? (

아직 추가된 위젯이 없습니다.

필요한 위젯을 추가해 워크스페이스를 구성해보세요.

@@ -74,14 +80,14 @@ export default function DashboardGrid({ - {visibleLayout.map((item) => { + {normalizedVisibleLayout.map((item) => { const id = item.i; const widget = byId.get(id)!; const size = getWidgetSize(item.w, item.h); diff --git a/src/views/dashboard/ui/DashboardView.tsx b/src/views/dashboard/ui/DashboardView.tsx index 20acecf..b388680 100644 --- a/src/views/dashboard/ui/DashboardView.tsx +++ b/src/views/dashboard/ui/DashboardView.tsx @@ -20,6 +20,9 @@ import AddWidgetBar from './AddWidgetBar'; import DashboardGrid from './DashboardGrid'; const CATALOG_WIDGETS = Object.values(WIDGET_CATALOG); +const WIDGET_CONSTRAINTS_BY_ID = Object.fromEntries( + CATALOG_WIDGETS.map((widget) => [widget.layout.i, widget.layout]), +); interface DashboardViewProps { /** 레이아웃 영속화 키 */ @@ -42,7 +45,12 @@ export default function DashboardView({ pageType = 'dashboard', }: DashboardViewProps) { const { layout, editMode, handleLayoutChange, addWidget, removeWidget, toggleEdit } = - useDashboardLayout({ workspaceId, pageType, initialLayout }); + useDashboardLayout({ + workspaceId, + pageType, + initialLayout, + constraintsById: WIDGET_CONSTRAINTS_BY_ID, + }); // 이 템플릿이 허용하는 위젯 중, 아직 배치되지 않은 것 = 추가 가능 목록 // TEMPLATE_WIDGETS[purpose]는 WidgetId[]라 카탈로그에 항상 존재한다.