"use client"; import React, { forwardRef, useState, useCallback, useMemo } from "react"; import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types"; import { CanvasElement } from "./CanvasElement"; import { GRID_CONFIG, snapToGrid, calculateGridConfig } from "./gridUtils"; import { resolveAllCollisions } from "./collisionUtils"; interface DashboardCanvasProps { elements: DashboardElement[]; selectedElement: string | null; onCreateElement: (type: ElementType, subtype: ElementSubtype, x: number, y: number) => void; onUpdateElement: (id: string, updates: Partial) => void; onRemoveElement: (id: string) => void; onSelectElement: (id: string | null) => void; onConfigureElement?: (element: DashboardElement) => void; backgroundColor?: string; canvasWidth?: number; canvasHeight?: number; } /** * 대시보드 캔버스 컴포넌트 * - 드래그 앤 드롭 영역 * - 12 컬럼 그리드 배경 * - 스냅 기능 * - 요소 배치 및 관리 */ export const DashboardCanvas = forwardRef( ( { elements, selectedElement, onCreateElement, onUpdateElement, onRemoveElement, onSelectElement, onConfigureElement, backgroundColor = "#f9fafb", canvasWidth = 1560, canvasHeight = 768, }, ref, ) => { const [isDragOver, setIsDragOver] = useState(false); // 현재 캔버스 크기에 맞는 그리드 설정 계산 const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]); const cellSize = gridConfig.CELL_SIZE; // 충돌 방지 기능이 포함된 업데이트 핸들러 const handleUpdateWithCollisionDetection = useCallback( (id: string, updates: Partial) => { // position이나 size가 아닌 다른 속성 업데이트는 충돌 감지 없이 바로 처리 if (!updates.position && !updates.size) { onUpdateElement(id, updates); return; } // 업데이트할 요소 찾기 const elementIndex = elements.findIndex((el) => el.id === id); if (elementIndex === -1) { onUpdateElement(id, updates); return; } // position이나 size와 다른 속성이 함께 있으면 분리해서 처리 const positionSizeUpdates: any = {}; const otherUpdates: any = {}; Object.keys(updates).forEach((key) => { if (key === "position" || key === "size") { positionSizeUpdates[key] = (updates as any)[key]; } else { otherUpdates[key] = (updates as any)[key]; } }); // 다른 속성들은 먼저 바로 업데이트 if (Object.keys(otherUpdates).length > 0) { onUpdateElement(id, otherUpdates); } // position/size가 없으면 여기서 종료 if (Object.keys(positionSizeUpdates).length === 0) { return; } // 임시로 업데이트된 요소 배열 생성 const updatedElements = elements.map((el) => el.id === id ? { ...el, ...positionSizeUpdates, position: positionSizeUpdates.position || el.position, size: positionSizeUpdates.size || el.size, } : el, ); // 서브 그리드 크기 계산 (cellSize / 3) const subGridSize = Math.floor(cellSize / 3); // 충돌 해결 (서브 그리드 단위로 스냅 및 충돌 감지) const resolvedElements = resolveAllCollisions(updatedElements, id, subGridSize, canvasWidth, cellSize); // 변경된 요소들만 업데이트 resolvedElements.forEach((resolvedEl, idx) => { const originalEl = elements[idx]; if ( resolvedEl.position.x !== originalEl.position.x || resolvedEl.position.y !== originalEl.position.y || resolvedEl.size.width !== originalEl.size.width || resolvedEl.size.height !== originalEl.size.height ) { onUpdateElement(resolvedEl.id, { position: resolvedEl.position, size: resolvedEl.size, }); } }); }, [elements, onUpdateElement, cellSize, canvasWidth], ); // 드래그 오버 처리 const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.dataTransfer.dropEffect = "copy"; setIsDragOver(true); }, []); // 드래그 리브 처리 const handleDragLeave = useCallback((e: React.DragEvent) => { if (e.currentTarget === e.target) { setIsDragOver(false); } }, []); // 드롭 처리 (그리드 스냅 적용) const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); try { const dragData: DragData = JSON.parse(e.dataTransfer.getData("application/json")); if (!ref || typeof ref === "function") return; const rect = ref.current?.getBoundingClientRect(); if (!rect) return; // 캔버스 스크롤을 고려한 정확한 위치 계산 const rawX = e.clientX - rect.left + (ref.current?.scrollLeft || 0); const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0); // 마그네틱 스냅 (큰 그리드 우선, 없으면 서브그리드) const gridSize = cellSize + GRID_CONFIG.GAP; // GAP 포함한 실제 그리드 크기 const magneticThreshold = 15; // X 좌표 스냅 const nearestGridX = Math.round(rawX / gridSize) * gridSize; const distToGridX = Math.abs(rawX - nearestGridX); let snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(rawX / subGridSize) * subGridSize; // Y 좌표 스냅 const nearestGridY = Math.round(rawY / gridSize) * gridSize; const distToGridY = Math.abs(rawY - nearestGridY); const snappedY = distToGridY <= magneticThreshold ? nearestGridY : Math.round(rawY / subGridSize) * subGridSize; // X 좌표가 캔버스 너비를 벗어나지 않도록 제한 const maxX = canvasWidth - cellSize * 2; // 최소 2칸 너비 보장 snappedX = Math.max(0, Math.min(snappedX, maxX)); onCreateElement(dragData.type, dragData.subtype, snappedX, snappedY); } catch { // 드롭 데이터 파싱 오류 무시 } }, [ref, onCreateElement, canvasWidth, cellSize], ); // 캔버스 클릭 시 선택 해제 const handleCanvasClick = useCallback( (e: React.MouseEvent) => { if (e.target === e.currentTarget) { onSelectElement(null); } }, [onSelectElement], ); // 동적 그리드 크기 계산 const cellWithGap = cellSize + GRID_CONFIG.GAP; const gridSize = `${cellWithGap}px ${cellWithGap}px`; // 서브그리드 크기 계산 (gridConfig에서 정확하게 계산된 값 사용) const subGridSize = gridConfig.SUB_GRID_SIZE; // 12개 컬럼 구분선 위치 계산 const columnLines = Array.from({ length: GRID_CONFIG.COLUMNS + 1 }, (_, i) => i * cellWithGap); return (
{/* 12개 컬럼 메인 구분선 */} {columnLines.map((x, i) => (
))} {/* 배치된 요소들 렌더링 */} {elements.length === 0 && (
상단 메뉴에서 차트나 위젯을 선택하세요
)} {elements.map((element) => ( ))}
); }, ); DashboardCanvas.displayName = "DashboardCanvas";