264 lines
9.1 KiB
TypeScript
264 lines
9.1 KiB
TypeScript
"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<DashboardElement>) => 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<HTMLDivElement, DashboardCanvasProps>(
|
|
(
|
|
{
|
|
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<DashboardElement>) => {
|
|
// 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 subGridSize = Math.floor(cellSize / 3);
|
|
const gridSize = cellSize + 5; // 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`;
|
|
|
|
// 12개 컬럼 구분선 위치 계산
|
|
const columnLines = Array.from({ length: GRID_CONFIG.COLUMNS + 1 }, (_, i) => i * cellWithGap);
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className={`relative w-full rounded-lg shadow-inner ${isDragOver ? "bg-blue-50/50" : ""} `}
|
|
style={{
|
|
backgroundColor,
|
|
height: `${canvasHeight}px`,
|
|
minHeight: `${canvasHeight}px`,
|
|
// 세밀한 그리드 배경
|
|
backgroundImage: `
|
|
linear-gradient(rgba(59, 130, 246, 0.08) 1px, transparent 1px),
|
|
linear-gradient(90deg, rgba(59, 130, 246, 0.08) 1px, transparent 1px)
|
|
`,
|
|
backgroundSize: gridSize,
|
|
backgroundPosition: "0 0",
|
|
backgroundRepeat: "repeat",
|
|
}}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
onClick={handleCanvasClick}
|
|
>
|
|
{/* 12개 컬럼 메인 구분선 */}
|
|
{columnLines.map((x, i) => (
|
|
<div
|
|
key={`col-${i}`}
|
|
className="pointer-events-none absolute top-0 h-full"
|
|
style={{
|
|
left: `${x}px`,
|
|
width: "2px",
|
|
zIndex: 1,
|
|
}}
|
|
/>
|
|
))}
|
|
{/* 배치된 요소들 렌더링 */}
|
|
{elements.length === 0 && (
|
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-gray-400">
|
|
<div className="text-center">
|
|
<div className="text-sm">상단 메뉴에서 차트나 위젯을 선택하세요</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{elements.map((element) => (
|
|
<CanvasElement
|
|
key={element.id}
|
|
element={element}
|
|
isSelected={selectedElement === element.id}
|
|
cellSize={cellSize}
|
|
canvasWidth={canvasWidth}
|
|
onUpdate={handleUpdateWithCollisionDetection}
|
|
onRemove={onRemoveElement}
|
|
onSelect={onSelectElement}
|
|
onConfigure={onConfigureElement}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
},
|
|
);
|
|
|
|
DashboardCanvas.displayName = "DashboardCanvas";
|