Merge pull request '대시보드 그리드 스냅 적용' (#92) from feature/dashboard into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/92
This commit is contained in:
commit
cabaada5b8
|
|
@ -1,12 +1,14 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { DashboardElement, QueryResult } from './types';
|
||||
import { ChartRenderer } from './charts/ChartRenderer';
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { DashboardElement, QueryResult } from "./types";
|
||||
import { ChartRenderer } from "./charts/ChartRenderer";
|
||||
import { snapToGrid, snapSizeToGrid, GRID_CONFIG } from "./gridUtils";
|
||||
|
||||
interface CanvasElementProps {
|
||||
element: DashboardElement;
|
||||
isSelected: boolean;
|
||||
cellSize: number;
|
||||
onUpdate: (id: string, updates: Partial<DashboardElement>) => void;
|
||||
onRemove: (id: string) => void;
|
||||
onSelect: (id: string | null) => void;
|
||||
|
|
@ -15,145 +17,200 @@ interface CanvasElementProps {
|
|||
|
||||
/**
|
||||
* 캔버스에 배치된 개별 요소 컴포넌트
|
||||
* - 드래그로 이동 가능
|
||||
* - 크기 조절 핸들
|
||||
* - 드래그로 이동 가능 (그리드 스냅)
|
||||
* - 크기 조절 핸들 (그리드 스냅)
|
||||
* - 삭제 버튼
|
||||
*/
|
||||
export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelect, onConfigure }: CanvasElementProps) {
|
||||
export function CanvasElement({
|
||||
element,
|
||||
isSelected,
|
||||
cellSize,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
onSelect,
|
||||
onConfigure,
|
||||
}: CanvasElementProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0, elementX: 0, elementY: 0 });
|
||||
const [resizeStart, setResizeStart] = useState({
|
||||
x: 0, y: 0, width: 0, height: 0, elementX: 0, elementY: 0, handle: ''
|
||||
const [resizeStart, setResizeStart] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
elementX: 0,
|
||||
elementY: 0,
|
||||
handle: "",
|
||||
});
|
||||
const [chartData, setChartData] = useState<QueryResult | null>(null);
|
||||
const [isLoadingData, setIsLoadingData] = useState(false);
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 드래그/리사이즈 중 임시 위치/크기 (스냅 전)
|
||||
const [tempPosition, setTempPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
const [tempSize, setTempSize] = useState<{ width: number; height: number } | null>(null);
|
||||
|
||||
// 요소 선택 처리
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
// 닫기 버튼이나 리사이즈 핸들 클릭 시 무시
|
||||
if ((e.target as HTMLElement).closest('.element-close, .resize-handle')) {
|
||||
return;
|
||||
}
|
||||
|
||||
onSelect(element.id);
|
||||
setIsDragging(true);
|
||||
setDragStart({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
elementX: element.position.x,
|
||||
elementY: element.position.y
|
||||
});
|
||||
e.preventDefault();
|
||||
}, [element.id, element.position.x, element.position.y, onSelect]);
|
||||
|
||||
// 리사이즈 핸들 마우스다운
|
||||
const handleResizeMouseDown = useCallback((e: React.MouseEvent, handle: string) => {
|
||||
e.stopPropagation();
|
||||
setIsResizing(true);
|
||||
setResizeStart({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
width: element.size.width,
|
||||
height: element.size.height,
|
||||
elementX: element.position.x,
|
||||
elementY: element.position.y,
|
||||
handle
|
||||
});
|
||||
}, [element.size.width, element.size.height, element.position.x, element.position.y]);
|
||||
|
||||
// 마우스 이동 처리
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
const deltaX = e.clientX - dragStart.x;
|
||||
const deltaY = e.clientY - dragStart.y;
|
||||
|
||||
onUpdate(element.id, {
|
||||
position: {
|
||||
x: Math.max(0, dragStart.elementX + deltaX),
|
||||
y: Math.max(0, dragStart.elementY + deltaY)
|
||||
}
|
||||
});
|
||||
} else if (isResizing) {
|
||||
const deltaX = e.clientX - resizeStart.x;
|
||||
const deltaY = e.clientY - resizeStart.y;
|
||||
|
||||
let newWidth = resizeStart.width;
|
||||
let newHeight = resizeStart.height;
|
||||
let newX = resizeStart.elementX;
|
||||
let newY = resizeStart.elementY;
|
||||
|
||||
switch (resizeStart.handle) {
|
||||
case 'se': // 오른쪽 아래
|
||||
newWidth = Math.max(150, resizeStart.width + deltaX);
|
||||
newHeight = Math.max(150, resizeStart.height + deltaY);
|
||||
break;
|
||||
case 'sw': // 왼쪽 아래
|
||||
newWidth = Math.max(150, resizeStart.width - deltaX);
|
||||
newHeight = Math.max(150, resizeStart.height + deltaY);
|
||||
newX = resizeStart.elementX + deltaX;
|
||||
break;
|
||||
case 'ne': // 오른쪽 위
|
||||
newWidth = Math.max(150, resizeStart.width + deltaX);
|
||||
newHeight = Math.max(150, resizeStart.height - deltaY);
|
||||
newY = resizeStart.elementY + deltaY;
|
||||
break;
|
||||
case 'nw': // 왼쪽 위
|
||||
newWidth = Math.max(150, resizeStart.width - deltaX);
|
||||
newHeight = Math.max(150, resizeStart.height - deltaY);
|
||||
newX = resizeStart.elementX + deltaX;
|
||||
newY = resizeStart.elementY + deltaY;
|
||||
break;
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// 닫기 버튼이나 리사이즈 핸들 클릭 시 무시
|
||||
if ((e.target as HTMLElement).closest(".element-close, .resize-handle")) {
|
||||
return;
|
||||
}
|
||||
|
||||
onUpdate(element.id, {
|
||||
position: { x: Math.max(0, newX), y: Math.max(0, newY) },
|
||||
size: { width: newWidth, height: newHeight }
|
||||
onSelect(element.id);
|
||||
setIsDragging(true);
|
||||
setDragStart({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
elementX: element.position.x,
|
||||
elementY: element.position.y,
|
||||
});
|
||||
}
|
||||
}, [isDragging, isResizing, dragStart, resizeStart, element.id, onUpdate]);
|
||||
e.preventDefault();
|
||||
},
|
||||
[element.id, element.position.x, element.position.y, onSelect],
|
||||
);
|
||||
|
||||
// 마우스 업 처리
|
||||
// 리사이즈 핸들 마우스다운
|
||||
const handleResizeMouseDown = useCallback(
|
||||
(e: React.MouseEvent, handle: string) => {
|
||||
e.stopPropagation();
|
||||
setIsResizing(true);
|
||||
setResizeStart({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
width: element.size.width,
|
||||
height: element.size.height,
|
||||
elementX: element.position.x,
|
||||
elementY: element.position.y,
|
||||
handle,
|
||||
});
|
||||
},
|
||||
[element.size.width, element.size.height, element.position.x, element.position.y],
|
||||
);
|
||||
|
||||
// 마우스 이동 처리 (그리드 스냅 적용)
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (isDragging) {
|
||||
const deltaX = e.clientX - dragStart.x;
|
||||
const deltaY = e.clientY - dragStart.y;
|
||||
|
||||
// 임시 위치 계산 (스냅 안 됨)
|
||||
const rawX = Math.max(0, dragStart.elementX + deltaX);
|
||||
const rawY = Math.max(0, dragStart.elementY + deltaY);
|
||||
|
||||
setTempPosition({ x: rawX, y: rawY });
|
||||
} else if (isResizing) {
|
||||
const deltaX = e.clientX - resizeStart.x;
|
||||
const deltaY = e.clientY - resizeStart.y;
|
||||
|
||||
let newWidth = resizeStart.width;
|
||||
let newHeight = resizeStart.height;
|
||||
let newX = resizeStart.elementX;
|
||||
let newY = resizeStart.elementY;
|
||||
|
||||
const minSize = GRID_CONFIG.CELL_SIZE * 2; // 최소 2셀
|
||||
|
||||
switch (resizeStart.handle) {
|
||||
case "se": // 오른쪽 아래
|
||||
newWidth = Math.max(minSize, resizeStart.width + deltaX);
|
||||
newHeight = Math.max(minSize, resizeStart.height + deltaY);
|
||||
break;
|
||||
case "sw": // 왼쪽 아래
|
||||
newWidth = Math.max(minSize, resizeStart.width - deltaX);
|
||||
newHeight = Math.max(minSize, resizeStart.height + deltaY);
|
||||
newX = resizeStart.elementX + deltaX;
|
||||
break;
|
||||
case "ne": // 오른쪽 위
|
||||
newWidth = Math.max(minSize, resizeStart.width + deltaX);
|
||||
newHeight = Math.max(minSize, resizeStart.height - deltaY);
|
||||
newY = resizeStart.elementY + deltaY;
|
||||
break;
|
||||
case "nw": // 왼쪽 위
|
||||
newWidth = Math.max(minSize, resizeStart.width - deltaX);
|
||||
newHeight = Math.max(minSize, resizeStart.height - deltaY);
|
||||
newX = resizeStart.elementX + deltaX;
|
||||
newY = resizeStart.elementY + deltaY;
|
||||
break;
|
||||
}
|
||||
|
||||
// 임시 크기/위치 저장 (스냅 안 됨)
|
||||
setTempPosition({ x: Math.max(0, newX), y: Math.max(0, newY) });
|
||||
setTempSize({ width: newWidth, height: newHeight });
|
||||
}
|
||||
},
|
||||
[isDragging, isResizing, dragStart, resizeStart],
|
||||
);
|
||||
|
||||
// 마우스 업 처리 (그리드 스냅 적용)
|
||||
const handleMouseUp = useCallback(() => {
|
||||
if (isDragging && tempPosition) {
|
||||
// 드래그 종료 시 그리드에 스냅 (동적 셀 크기 사용)
|
||||
const snappedX = snapToGrid(tempPosition.x, cellSize);
|
||||
const snappedY = snapToGrid(tempPosition.y, cellSize);
|
||||
|
||||
onUpdate(element.id, {
|
||||
position: { x: snappedX, y: snappedY },
|
||||
});
|
||||
|
||||
setTempPosition(null);
|
||||
}
|
||||
|
||||
if (isResizing && tempPosition && tempSize) {
|
||||
// 리사이즈 종료 시 그리드에 스냅 (동적 셀 크기 사용)
|
||||
const snappedX = snapToGrid(tempPosition.x, cellSize);
|
||||
const snappedY = snapToGrid(tempPosition.y, cellSize);
|
||||
const snappedWidth = snapSizeToGrid(tempSize.width, 2, cellSize);
|
||||
const snappedHeight = snapSizeToGrid(tempSize.height, 2, cellSize);
|
||||
|
||||
onUpdate(element.id, {
|
||||
position: { x: snappedX, y: snappedY },
|
||||
size: { width: snappedWidth, height: snappedHeight },
|
||||
});
|
||||
|
||||
setTempPosition(null);
|
||||
setTempSize(null);
|
||||
}
|
||||
|
||||
setIsDragging(false);
|
||||
setIsResizing(false);
|
||||
}, []);
|
||||
}, [isDragging, isResizing, tempPosition, tempSize, element.id, onUpdate, cellSize]);
|
||||
|
||||
// 전역 마우스 이벤트 등록
|
||||
React.useEffect(() => {
|
||||
if (isDragging || isResizing) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}
|
||||
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
|
||||
|
||||
// 데이터 로딩
|
||||
const loadChartData = useCallback(async () => {
|
||||
if (!element.dataSource?.query || element.type !== 'chart') {
|
||||
if (!element.dataSource?.query || element.type !== "chart") {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoadingData(true);
|
||||
try {
|
||||
// console.log('🔄 쿼리 실행 시작:', element.dataSource.query);
|
||||
|
||||
|
||||
// 실제 API 호출
|
||||
const { dashboardApi } = await import('@/lib/api/dashboard');
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const result = await dashboardApi.executeQuery(element.dataSource.query);
|
||||
|
||||
|
||||
// console.log('✅ 쿼리 실행 결과:', result);
|
||||
|
||||
|
||||
setChartData({
|
||||
columns: result.columns || [],
|
||||
rows: result.rows || [],
|
||||
totalRows: result.rowCount || 0,
|
||||
executionTime: 0
|
||||
executionTime: 0,
|
||||
});
|
||||
} catch (error) {
|
||||
// console.error('❌ 데이터 로딩 오류:', error);
|
||||
|
|
@ -185,51 +242,56 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
|
|||
|
||||
// 스타일 클래스 생성
|
||||
const getContentClass = () => {
|
||||
if (element.type === 'chart') {
|
||||
if (element.type === "chart") {
|
||||
switch (element.subtype) {
|
||||
case 'bar': return 'bg-gradient-to-br from-indigo-400 to-purple-600';
|
||||
case 'pie': return 'bg-gradient-to-br from-pink-400 to-red-500';
|
||||
case 'line': return 'bg-gradient-to-br from-blue-400 to-cyan-400';
|
||||
default: return 'bg-gray-200';
|
||||
case "bar":
|
||||
return "bg-gradient-to-br from-indigo-400 to-purple-600";
|
||||
case "pie":
|
||||
return "bg-gradient-to-br from-pink-400 to-red-500";
|
||||
case "line":
|
||||
return "bg-gradient-to-br from-blue-400 to-cyan-400";
|
||||
default:
|
||||
return "bg-gray-200";
|
||||
}
|
||||
} else if (element.type === 'widget') {
|
||||
} else if (element.type === "widget") {
|
||||
switch (element.subtype) {
|
||||
case 'exchange': return 'bg-gradient-to-br from-pink-400 to-yellow-400';
|
||||
case 'weather': return 'bg-gradient-to-br from-cyan-400 to-indigo-800';
|
||||
default: return 'bg-gray-200';
|
||||
case "exchange":
|
||||
return "bg-gradient-to-br from-pink-400 to-yellow-400";
|
||||
case "weather":
|
||||
return "bg-gradient-to-br from-cyan-400 to-indigo-800";
|
||||
default:
|
||||
return "bg-gray-200";
|
||||
}
|
||||
}
|
||||
return 'bg-gray-200';
|
||||
return "bg-gray-200";
|
||||
};
|
||||
|
||||
// 드래그/리사이즈 중일 때는 임시 위치/크기 사용, 아니면 실제 값 사용
|
||||
const displayPosition = tempPosition || element.position;
|
||||
const displaySize = tempSize || element.size;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={elementRef}
|
||||
className={`
|
||||
absolute bg-white border-2 rounded-lg shadow-lg
|
||||
min-w-[150px] min-h-[150px] cursor-move
|
||||
${isSelected ? 'border-green-500 shadow-green-200' : 'border-gray-600'}
|
||||
`}
|
||||
className={`absolute min-h-[120px] min-w-[120px] cursor-move rounded-lg border-2 bg-white shadow-lg ${isSelected ? "border-blue-500 shadow-blue-200" : "border-gray-400"} ${isDragging || isResizing ? "transition-none" : "transition-all duration-150"} `}
|
||||
style={{
|
||||
left: element.position.x,
|
||||
top: element.position.y,
|
||||
width: element.size.width,
|
||||
height: element.size.height
|
||||
left: displayPosition.x,
|
||||
top: displayPosition.y,
|
||||
width: displaySize.width,
|
||||
height: displaySize.height,
|
||||
padding: `${GRID_CONFIG.ELEMENT_PADDING}px`,
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
{/* 헤더 */}
|
||||
<div className="bg-gray-50 p-3 border-b border-gray-200 flex justify-between items-center cursor-move">
|
||||
<span className="font-bold text-sm text-gray-800">{element.title}</span>
|
||||
<div className="flex cursor-move items-center justify-between border-b border-gray-200 bg-gray-50 p-3">
|
||||
<span className="text-sm font-bold text-gray-800">{element.title}</span>
|
||||
<div className="flex gap-1">
|
||||
{/* 설정 버튼 */}
|
||||
{onConfigure && (
|
||||
<button
|
||||
className="
|
||||
w-6 h-6 flex items-center justify-center
|
||||
text-gray-400 hover:bg-accent0 hover:text-white
|
||||
rounded transition-colors duration-200
|
||||
"
|
||||
className="hover:bg-accent0 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
|
||||
onClick={() => onConfigure(element)}
|
||||
title="설정"
|
||||
>
|
||||
|
|
@ -238,11 +300,7 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
|
|||
)}
|
||||
{/* 삭제 버튼 */}
|
||||
<button
|
||||
className="
|
||||
element-close w-6 h-6 flex items-center justify-center
|
||||
text-gray-400 hover:bg-destructive/100 hover:text-white
|
||||
rounded transition-colors duration-200
|
||||
"
|
||||
className="element-close hover:bg-destructive/100 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
|
||||
onClick={handleRemove}
|
||||
title="삭제"
|
||||
>
|
||||
|
|
@ -252,14 +310,14 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
|
|||
</div>
|
||||
|
||||
{/* 내용 */}
|
||||
<div className="h-[calc(100%-45px)] relative">
|
||||
{element.type === 'chart' ? (
|
||||
<div className="relative h-[calc(100%-45px)]">
|
||||
{element.type === "chart" ? (
|
||||
// 차트 렌더링
|
||||
<div className="w-full h-full bg-white">
|
||||
<div className="h-full w-full bg-white">
|
||||
{isLoadingData ? (
|
||||
<div className="w-full h-full flex items-center justify-center text-gray-500">
|
||||
<div className="flex h-full w-full items-center justify-center text-gray-500">
|
||||
<div className="text-center">
|
||||
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
||||
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
||||
<div className="text-sm">데이터 로딩 중...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -274,15 +332,13 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
|
|||
</div>
|
||||
) : (
|
||||
// 위젯 렌더링 (기존 방식)
|
||||
<div className={`
|
||||
w-full h-full p-5 flex items-center justify-center
|
||||
text-sm text-white font-medium text-center
|
||||
${getContentClass()}
|
||||
`}>
|
||||
<div
|
||||
className={`flex h-full w-full items-center justify-center p-5 text-center text-sm font-medium text-white ${getContentClass()} `}
|
||||
>
|
||||
<div>
|
||||
<div className="text-4xl mb-2">
|
||||
{element.type === 'widget' && element.subtype === 'exchange' && '💱'}
|
||||
{element.type === 'widget' && element.subtype === 'weather' && '☁️'}
|
||||
<div className="mb-2 text-4xl">
|
||||
{element.type === "widget" && element.subtype === "exchange" && "💱"}
|
||||
{element.type === "widget" && element.subtype === "weather" && "☁️"}
|
||||
</div>
|
||||
<div className="whitespace-pre-line">{element.content}</div>
|
||||
</div>
|
||||
|
|
@ -304,7 +360,7 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
|
|||
}
|
||||
|
||||
interface ResizeHandleProps {
|
||||
position: 'nw' | 'ne' | 'sw' | 'se';
|
||||
position: "nw" | "ne" | "sw" | "se";
|
||||
onMouseDown: (e: React.MouseEvent, handle: string) => void;
|
||||
}
|
||||
|
||||
|
|
@ -314,19 +370,20 @@ interface ResizeHandleProps {
|
|||
function ResizeHandle({ position, onMouseDown }: ResizeHandleProps) {
|
||||
const getPositionClass = () => {
|
||||
switch (position) {
|
||||
case 'nw': return 'top-[-5px] left-[-5px] cursor-nw-resize';
|
||||
case 'ne': return 'top-[-5px] right-[-5px] cursor-ne-resize';
|
||||
case 'sw': return 'bottom-[-5px] left-[-5px] cursor-sw-resize';
|
||||
case 'se': return 'bottom-[-5px] right-[-5px] cursor-se-resize';
|
||||
case "nw":
|
||||
return "top-[-5px] left-[-5px] cursor-nw-resize";
|
||||
case "ne":
|
||||
return "top-[-5px] right-[-5px] cursor-ne-resize";
|
||||
case "sw":
|
||||
return "bottom-[-5px] left-[-5px] cursor-sw-resize";
|
||||
case "se":
|
||||
return "bottom-[-5px] right-[-5px] cursor-se-resize";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
resize-handle absolute w-3 h-3 bg-green-500 border border-white
|
||||
${getPositionClass()}
|
||||
`}
|
||||
className={`resize-handle absolute h-3 w-3 border border-white bg-green-500 ${getPositionClass()} `}
|
||||
onMouseDown={(e) => onMouseDown(e, position)}
|
||||
/>
|
||||
);
|
||||
|
|
@ -337,55 +394,55 @@ function ResizeHandle({ position, onMouseDown }: ResizeHandleProps) {
|
|||
*/
|
||||
function generateSampleData(query: string, chartType: string): QueryResult {
|
||||
// 쿼리에서 키워드 추출하여 적절한 샘플 데이터 생성
|
||||
const isMonthly = query.toLowerCase().includes('month');
|
||||
const isSales = query.toLowerCase().includes('sales') || query.toLowerCase().includes('매출');
|
||||
const isUsers = query.toLowerCase().includes('users') || query.toLowerCase().includes('사용자');
|
||||
const isProducts = query.toLowerCase().includes('product') || query.toLowerCase().includes('상품');
|
||||
const isMonthly = query.toLowerCase().includes("month");
|
||||
const isSales = query.toLowerCase().includes("sales") || query.toLowerCase().includes("매출");
|
||||
const isUsers = query.toLowerCase().includes("users") || query.toLowerCase().includes("사용자");
|
||||
const isProducts = query.toLowerCase().includes("product") || query.toLowerCase().includes("상품");
|
||||
|
||||
let columns: string[];
|
||||
let rows: Record<string, any>[];
|
||||
|
||||
if (isMonthly && isSales) {
|
||||
// 월별 매출 데이터
|
||||
columns = ['month', 'sales', 'order_count'];
|
||||
columns = ["month", "sales", "order_count"];
|
||||
rows = [
|
||||
{ month: '2024-01', sales: 1200000, order_count: 45 },
|
||||
{ month: '2024-02', sales: 1350000, order_count: 52 },
|
||||
{ month: '2024-03', sales: 1180000, order_count: 41 },
|
||||
{ month: '2024-04', sales: 1420000, order_count: 58 },
|
||||
{ month: '2024-05', sales: 1680000, order_count: 67 },
|
||||
{ month: '2024-06', sales: 1540000, order_count: 61 },
|
||||
{ month: "2024-01", sales: 1200000, order_count: 45 },
|
||||
{ month: "2024-02", sales: 1350000, order_count: 52 },
|
||||
{ month: "2024-03", sales: 1180000, order_count: 41 },
|
||||
{ month: "2024-04", sales: 1420000, order_count: 58 },
|
||||
{ month: "2024-05", sales: 1680000, order_count: 67 },
|
||||
{ month: "2024-06", sales: 1540000, order_count: 61 },
|
||||
];
|
||||
} else if (isUsers) {
|
||||
// 사용자 가입 추이
|
||||
columns = ['week', 'new_users'];
|
||||
columns = ["week", "new_users"];
|
||||
rows = [
|
||||
{ week: '2024-W10', new_users: 23 },
|
||||
{ week: '2024-W11', new_users: 31 },
|
||||
{ week: '2024-W12', new_users: 28 },
|
||||
{ week: '2024-W13', new_users: 35 },
|
||||
{ week: '2024-W14', new_users: 42 },
|
||||
{ week: '2024-W15', new_users: 38 },
|
||||
{ week: "2024-W10", new_users: 23 },
|
||||
{ week: "2024-W11", new_users: 31 },
|
||||
{ week: "2024-W12", new_users: 28 },
|
||||
{ week: "2024-W13", new_users: 35 },
|
||||
{ week: "2024-W14", new_users: 42 },
|
||||
{ week: "2024-W15", new_users: 38 },
|
||||
];
|
||||
} else if (isProducts) {
|
||||
// 상품별 판매량
|
||||
columns = ['product_name', 'total_sold', 'revenue'];
|
||||
columns = ["product_name", "total_sold", "revenue"];
|
||||
rows = [
|
||||
{ product_name: '스마트폰', total_sold: 156, revenue: 234000000 },
|
||||
{ product_name: '노트북', total_sold: 89, revenue: 178000000 },
|
||||
{ product_name: '태블릿', total_sold: 134, revenue: 67000000 },
|
||||
{ product_name: '이어폰', total_sold: 267, revenue: 26700000 },
|
||||
{ product_name: '스마트워치', total_sold: 98, revenue: 49000000 },
|
||||
{ product_name: "스마트폰", total_sold: 156, revenue: 234000000 },
|
||||
{ product_name: "노트북", total_sold: 89, revenue: 178000000 },
|
||||
{ product_name: "태블릿", total_sold: 134, revenue: 67000000 },
|
||||
{ product_name: "이어폰", total_sold: 267, revenue: 26700000 },
|
||||
{ product_name: "스마트워치", total_sold: 98, revenue: 49000000 },
|
||||
];
|
||||
} else {
|
||||
// 기본 샘플 데이터
|
||||
columns = ['category', 'value', 'count'];
|
||||
columns = ["category", "value", "count"];
|
||||
rows = [
|
||||
{ category: 'A', value: 100, count: 10 },
|
||||
{ category: 'B', value: 150, count: 15 },
|
||||
{ category: 'C', value: 120, count: 12 },
|
||||
{ category: 'D', value: 180, count: 18 },
|
||||
{ category: 'E', value: 90, count: 9 },
|
||||
{ category: "A", value: 100, count: 10 },
|
||||
{ category: "B", value: 150, count: 15 },
|
||||
{ category: "C", value: 120, count: 12 },
|
||||
{ category: "D", value: 180, count: 18 },
|
||||
{ category: "E", value: 90, count: 9 },
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import React, { forwardRef, useState, useCallback } from 'react';
|
||||
import { DashboardElement, ElementType, ElementSubtype, DragData } from './types';
|
||||
import { CanvasElement } from './CanvasElement';
|
||||
import React, { forwardRef, useState, useCallback, useEffect } from "react";
|
||||
import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types";
|
||||
import { CanvasElement } from "./CanvasElement";
|
||||
import { GRID_CONFIG, snapToGrid } from "./gridUtils";
|
||||
|
||||
interface DashboardCanvasProps {
|
||||
elements: DashboardElement[];
|
||||
|
|
@ -17,17 +18,29 @@ interface DashboardCanvasProps {
|
|||
/**
|
||||
* 대시보드 캔버스 컴포넌트
|
||||
* - 드래그 앤 드롭 영역
|
||||
* - 그리드 배경
|
||||
* - 12 컬럼 그리드 배경
|
||||
* - 스냅 기능
|
||||
* - 요소 배치 및 관리
|
||||
*/
|
||||
export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||
({ elements, selectedElement, onCreateElement, onUpdateElement, onRemoveElement, onSelectElement, onConfigureElement }, ref) => {
|
||||
(
|
||||
{
|
||||
elements,
|
||||
selectedElement,
|
||||
onCreateElement,
|
||||
onUpdateElement,
|
||||
onRemoveElement,
|
||||
onSelectElement,
|
||||
onConfigureElement,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
|
||||
// 드래그 오버 처리
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
setIsDragOver(true);
|
||||
}, []);
|
||||
|
||||
|
|
@ -38,51 +51,71 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
}
|
||||
}, []);
|
||||
|
||||
// 드롭 처리
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
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;
|
||||
try {
|
||||
const dragData: DragData = JSON.parse(e.dataTransfer.getData("application/json"));
|
||||
|
||||
// 캔버스 스크롤을 고려한 정확한 위치 계산
|
||||
const x = e.clientX - rect.left + (ref.current?.scrollLeft || 0);
|
||||
const y = e.clientY - rect.top + (ref.current?.scrollTop || 0);
|
||||
if (!ref || typeof ref === "function") return;
|
||||
|
||||
onCreateElement(dragData.type, dragData.subtype, x, y);
|
||||
} catch (error) {
|
||||
// console.error('드롭 데이터 파싱 오류:', error);
|
||||
}
|
||||
}, [ref, onCreateElement]);
|
||||
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 snappedX = snapToGrid(rawX, GRID_CONFIG.CELL_SIZE);
|
||||
const snappedY = snapToGrid(rawY, GRID_CONFIG.CELL_SIZE);
|
||||
|
||||
onCreateElement(dragData.type, dragData.subtype, snappedX, snappedY);
|
||||
} catch (error) {
|
||||
// console.error('드롭 데이터 파싱 오류:', error);
|
||||
}
|
||||
},
|
||||
[ref, onCreateElement],
|
||||
);
|
||||
|
||||
// 캔버스 클릭 시 선택 해제
|
||||
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onSelectElement(null);
|
||||
}
|
||||
}, [onSelectElement]);
|
||||
const handleCanvasClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
onSelectElement(null);
|
||||
}
|
||||
},
|
||||
[onSelectElement],
|
||||
);
|
||||
|
||||
// 고정 그리드 크기
|
||||
const cellWithGap = GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP;
|
||||
const gridSize = `${cellWithGap}px ${cellWithGap}px`;
|
||||
|
||||
// 캔버스 높이를 요소들의 최대 y + height 기준으로 계산 (최소 화면 높이 보장)
|
||||
const minCanvasHeight = Math.max(
|
||||
typeof window !== "undefined" ? window.innerHeight : 800,
|
||||
...elements.map((el) => el.position.y + el.size.height + 100), // 하단 여백 100px
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`
|
||||
w-full min-h-full relative
|
||||
bg-gray-100
|
||||
bg-grid-pattern
|
||||
${isDragOver ? 'bg-accent' : ''}
|
||||
`}
|
||||
className={`relative rounded-lg bg-gray-50 shadow-inner ${isDragOver ? "bg-blue-50/50" : ""} `}
|
||||
style={{
|
||||
width: `${GRID_CONFIG.CANVAS_WIDTH}px`,
|
||||
minHeight: `${minCanvasHeight}px`,
|
||||
// 12 컬럼 그리드 배경
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(200, 200, 200, 0.3) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(200, 200, 200, 0.3) 1px, transparent 1px)
|
||||
linear-gradient(rgba(59, 130, 246, 0.15) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(59, 130, 246, 0.15) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '20px 20px'
|
||||
backgroundSize: gridSize,
|
||||
backgroundPosition: "0 0",
|
||||
backgroundRepeat: "repeat",
|
||||
}}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
|
|
@ -95,6 +128,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
key={element.id}
|
||||
element={element}
|
||||
isSelected={selectedElement === element.id}
|
||||
cellSize={GRID_CONFIG.CELL_SIZE}
|
||||
onUpdate={onUpdateElement}
|
||||
onRemove={onRemoveElement}
|
||||
onSelect={onSelectElement}
|
||||
|
|
@ -103,7 +137,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
|||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
DashboardCanvas.displayName = 'DashboardCanvas';
|
||||
DashboardCanvas.displayName = "DashboardCanvas";
|
||||
|
|
|
|||
|
|
@ -1,15 +1,17 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useCallback } from 'react';
|
||||
import { DashboardCanvas } from './DashboardCanvas';
|
||||
import { DashboardSidebar } from './DashboardSidebar';
|
||||
import { DashboardToolbar } from './DashboardToolbar';
|
||||
import { ElementConfigModal } from './ElementConfigModal';
|
||||
import { DashboardElement, ElementType, ElementSubtype } from './types';
|
||||
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||
import { DashboardCanvas } from "./DashboardCanvas";
|
||||
import { DashboardSidebar } from "./DashboardSidebar";
|
||||
import { DashboardToolbar } from "./DashboardToolbar";
|
||||
import { ElementConfigModal } from "./ElementConfigModal";
|
||||
import { DashboardElement, ElementType, ElementSubtype } from "./types";
|
||||
import { GRID_CONFIG } from "./gridUtils";
|
||||
|
||||
/**
|
||||
* 대시보드 설계 도구 메인 컴포넌트
|
||||
* - 드래그 앤 드롭으로 차트/위젯 배치
|
||||
* - 그리드 기반 레이아웃 (12 컬럼)
|
||||
* - 요소 이동, 크기 조절, 삭제 기능
|
||||
* - 레이아웃 저장/불러오기 기능
|
||||
*/
|
||||
|
|
@ -19,15 +21,15 @@ export default function DashboardDesigner() {
|
|||
const [elementCounter, setElementCounter] = useState(0);
|
||||
const [configModalElement, setConfigModalElement] = useState<DashboardElement | null>(null);
|
||||
const [dashboardId, setDashboardId] = useState<string | null>(null);
|
||||
const [dashboardTitle, setDashboardTitle] = useState<string>('');
|
||||
const [dashboardTitle, setDashboardTitle] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const canvasRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// URL 파라미터에서 대시보드 ID 읽기 및 데이터 로드
|
||||
React.useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const loadId = params.get('load');
|
||||
|
||||
const loadId = params.get("load");
|
||||
|
||||
if (loadId) {
|
||||
loadDashboard(loadId);
|
||||
}
|
||||
|
|
@ -38,20 +40,20 @@ export default function DashboardDesigner() {
|
|||
setIsLoading(true);
|
||||
try {
|
||||
// console.log('🔄 대시보드 로딩:', id);
|
||||
|
||||
const { dashboardApi } = await import('@/lib/api/dashboard');
|
||||
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
const dashboard = await dashboardApi.getDashboard(id);
|
||||
|
||||
|
||||
// console.log('✅ 대시보드 로딩 완료:', dashboard);
|
||||
|
||||
|
||||
// 대시보드 정보 설정
|
||||
setDashboardId(dashboard.id);
|
||||
setDashboardTitle(dashboard.title);
|
||||
|
||||
|
||||
// 요소들 설정
|
||||
if (dashboard.elements && dashboard.elements.length > 0) {
|
||||
setElements(dashboard.elements);
|
||||
|
||||
|
||||
// elementCounter를 가장 큰 ID 번호로 설정
|
||||
const maxId = dashboard.elements.reduce((max, el) => {
|
||||
const match = el.id.match(/element-(\d+)/);
|
||||
|
|
@ -63,55 +65,63 @@ export default function DashboardDesigner() {
|
|||
}, 0);
|
||||
setElementCounter(maxId);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// console.error('❌ 대시보드 로딩 오류:', error);
|
||||
alert('대시보드를 불러오는 중 오류가 발생했습니다.\n\n' + (error instanceof Error ? error.message : '알 수 없는 오류'));
|
||||
alert(
|
||||
"대시보드를 불러오는 중 오류가 발생했습니다.\n\n" +
|
||||
(error instanceof Error ? error.message : "알 수 없는 오류"),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 새로운 요소 생성
|
||||
const createElement = useCallback((
|
||||
type: ElementType,
|
||||
subtype: ElementSubtype,
|
||||
x: number,
|
||||
y: number
|
||||
) => {
|
||||
const newElement: DashboardElement = {
|
||||
id: `element-${elementCounter + 1}`,
|
||||
type,
|
||||
subtype,
|
||||
position: { x, y },
|
||||
size: { width: 250, height: 200 },
|
||||
title: getElementTitle(type, subtype),
|
||||
content: getElementContent(type, subtype)
|
||||
};
|
||||
// 새로운 요소 생성 (고정 그리드 기반 기본 크기)
|
||||
const createElement = useCallback(
|
||||
(type: ElementType, subtype: ElementSubtype, x: number, y: number) => {
|
||||
// 기본 크기: 차트는 4x3 셀, 위젯은 2x2 셀
|
||||
const defaultCells = type === "chart" ? { width: 4, height: 3 } : { width: 2, height: 2 };
|
||||
const cellWithGap = GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP;
|
||||
|
||||
setElements(prev => [...prev, newElement]);
|
||||
setElementCounter(prev => prev + 1);
|
||||
setSelectedElement(newElement.id);
|
||||
}, [elementCounter]);
|
||||
const defaultWidth = defaultCells.width * cellWithGap - GRID_CONFIG.GAP;
|
||||
const defaultHeight = defaultCells.height * cellWithGap - GRID_CONFIG.GAP;
|
||||
|
||||
const newElement: DashboardElement = {
|
||||
id: `element-${elementCounter + 1}`,
|
||||
type,
|
||||
subtype,
|
||||
position: { x, y },
|
||||
size: { width: defaultWidth, height: defaultHeight },
|
||||
title: getElementTitle(type, subtype),
|
||||
content: getElementContent(type, subtype),
|
||||
};
|
||||
|
||||
setElements((prev) => [...prev, newElement]);
|
||||
setElementCounter((prev) => prev + 1);
|
||||
setSelectedElement(newElement.id);
|
||||
},
|
||||
[elementCounter],
|
||||
);
|
||||
|
||||
// 요소 업데이트
|
||||
const updateElement = useCallback((id: string, updates: Partial<DashboardElement>) => {
|
||||
setElements(prev => prev.map(el =>
|
||||
el.id === id ? { ...el, ...updates } : el
|
||||
));
|
||||
setElements((prev) => prev.map((el) => (el.id === id ? { ...el, ...updates } : el)));
|
||||
}, []);
|
||||
|
||||
// 요소 삭제
|
||||
const removeElement = useCallback((id: string) => {
|
||||
setElements(prev => prev.filter(el => el.id !== id));
|
||||
if (selectedElement === id) {
|
||||
setSelectedElement(null);
|
||||
}
|
||||
}, [selectedElement]);
|
||||
const removeElement = useCallback(
|
||||
(id: string) => {
|
||||
setElements((prev) => prev.filter((el) => el.id !== id));
|
||||
if (selectedElement === id) {
|
||||
setSelectedElement(null);
|
||||
}
|
||||
},
|
||||
[selectedElement],
|
||||
);
|
||||
|
||||
// 전체 삭제
|
||||
const clearCanvas = useCallback(() => {
|
||||
if (window.confirm('모든 요소를 삭제하시겠습니까?')) {
|
||||
if (window.confirm("모든 요소를 삭제하시겠습니까?")) {
|
||||
setElements([]);
|
||||
setSelectedElement(null);
|
||||
setElementCounter(0);
|
||||
|
|
@ -129,22 +139,25 @@ export default function DashboardDesigner() {
|
|||
}, []);
|
||||
|
||||
// 요소 설정 저장
|
||||
const saveElementConfig = useCallback((updatedElement: DashboardElement) => {
|
||||
updateElement(updatedElement.id, updatedElement);
|
||||
}, [updateElement]);
|
||||
const saveElementConfig = useCallback(
|
||||
(updatedElement: DashboardElement) => {
|
||||
updateElement(updatedElement.id, updatedElement);
|
||||
},
|
||||
[updateElement],
|
||||
);
|
||||
|
||||
// 레이아웃 저장
|
||||
const saveLayout = useCallback(async () => {
|
||||
if (elements.length === 0) {
|
||||
alert('저장할 요소가 없습니다. 차트나 위젯을 추가해주세요.');
|
||||
alert("저장할 요소가 없습니다. 차트나 위젯을 추가해주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 실제 API 호출
|
||||
const { dashboardApi } = await import('@/lib/api/dashboard');
|
||||
|
||||
const elementsData = elements.map(el => ({
|
||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||
|
||||
const elementsData = elements.map((el) => ({
|
||||
id: el.id,
|
||||
type: el.type,
|
||||
subtype: el.subtype,
|
||||
|
|
@ -153,51 +166,49 @@ export default function DashboardDesigner() {
|
|||
title: el.title,
|
||||
content: el.content,
|
||||
dataSource: el.dataSource,
|
||||
chartConfig: el.chartConfig
|
||||
chartConfig: el.chartConfig,
|
||||
}));
|
||||
|
||||
|
||||
let savedDashboard;
|
||||
|
||||
|
||||
if (dashboardId) {
|
||||
// 기존 대시보드 업데이트
|
||||
// console.log('🔄 대시보드 업데이트:', dashboardId);
|
||||
savedDashboard = await dashboardApi.updateDashboard(dashboardId, {
|
||||
elements: elementsData
|
||||
elements: elementsData,
|
||||
});
|
||||
|
||||
|
||||
alert(`대시보드 "${savedDashboard.title}"이 업데이트되었습니다!`);
|
||||
|
||||
|
||||
// 뷰어 페이지로 이동
|
||||
window.location.href = `/dashboard/${savedDashboard.id}`;
|
||||
|
||||
} else {
|
||||
// 새 대시보드 생성
|
||||
const title = prompt('대시보드 제목을 입력하세요:', '새 대시보드');
|
||||
const title = prompt("대시보드 제목을 입력하세요:", "새 대시보드");
|
||||
if (!title) return;
|
||||
|
||||
const description = prompt('대시보드 설명을 입력하세요 (선택사항):', '');
|
||||
|
||||
const description = prompt("대시보드 설명을 입력하세요 (선택사항):", "");
|
||||
|
||||
const dashboardData = {
|
||||
title,
|
||||
description: description || undefined,
|
||||
isPublic: false,
|
||||
elements: elementsData
|
||||
elements: elementsData,
|
||||
};
|
||||
|
||||
|
||||
savedDashboard = await dashboardApi.createDashboard(dashboardData);
|
||||
|
||||
|
||||
// console.log('✅ 대시보드 생성 완료:', savedDashboard);
|
||||
|
||||
|
||||
const viewDashboard = confirm(`대시보드 "${title}"이 저장되었습니다!\n\n지금 확인해보시겠습니까?`);
|
||||
if (viewDashboard) {
|
||||
window.location.href = `/dashboard/${savedDashboard.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// console.error('❌ 저장 오류:', error);
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류';
|
||||
|
||||
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
|
||||
alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}\n\n관리자에게 문의하세요.`);
|
||||
}
|
||||
}, [elements, dashboardId]);
|
||||
|
|
@ -207,9 +218,9 @@ export default function DashboardDesigner() {
|
|||
return (
|
||||
<div className="flex h-full items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<div className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<div className="border-primary mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-4 border-t-transparent" />
|
||||
<div className="text-lg font-medium text-gray-700">대시보드 로딩 중...</div>
|
||||
<div className="text-sm text-gray-500 mt-1">잠시만 기다려주세요</div>
|
||||
<div className="mt-1 text-sm text-gray-500">잠시만 기다려주세요</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -218,28 +229,29 @@ export default function DashboardDesigner() {
|
|||
return (
|
||||
<div className="flex h-full bg-gray-50">
|
||||
{/* 캔버스 영역 */}
|
||||
<div className="flex-1 relative overflow-auto border-r-2 border-gray-300">
|
||||
<div className="relative flex-1 overflow-auto border-r-2 border-gray-300 bg-gray-100">
|
||||
{/* 편집 중인 대시보드 표시 */}
|
||||
{dashboardTitle && (
|
||||
<div className="absolute top-2 left-2 z-10 bg-accent0 text-white px-3 py-1 rounded-lg text-sm font-medium shadow-lg">
|
||||
<div className="bg-accent0 absolute top-6 left-6 z-10 rounded-lg px-3 py-1 text-sm font-medium text-white shadow-lg">
|
||||
📝 편집 중: {dashboardTitle}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DashboardToolbar
|
||||
onClearCanvas={clearCanvas}
|
||||
onSaveLayout={saveLayout}
|
||||
/>
|
||||
<DashboardCanvas
|
||||
ref={canvasRef}
|
||||
elements={elements}
|
||||
selectedElement={selectedElement}
|
||||
onCreateElement={createElement}
|
||||
onUpdateElement={updateElement}
|
||||
onRemoveElement={removeElement}
|
||||
onSelectElement={setSelectedElement}
|
||||
onConfigureElement={openConfigModal}
|
||||
/>
|
||||
|
||||
<DashboardToolbar onClearCanvas={clearCanvas} onSaveLayout={saveLayout} />
|
||||
|
||||
{/* 캔버스 중앙 정렬 컨테이너 */}
|
||||
<div className="flex justify-center p-4">
|
||||
<DashboardCanvas
|
||||
ref={canvasRef}
|
||||
elements={elements}
|
||||
selectedElement={selectedElement}
|
||||
onCreateElement={createElement}
|
||||
onUpdateElement={updateElement}
|
||||
onRemoveElement={removeElement}
|
||||
onSelectElement={setSelectedElement}
|
||||
onConfigureElement={openConfigModal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 사이드바 */}
|
||||
|
|
@ -260,38 +272,52 @@ export default function DashboardDesigner() {
|
|||
|
||||
// 요소 제목 생성 헬퍼 함수
|
||||
function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
|
||||
if (type === 'chart') {
|
||||
if (type === "chart") {
|
||||
switch (subtype) {
|
||||
case 'bar': return '📊 바 차트';
|
||||
case 'pie': return '🥧 원형 차트';
|
||||
case 'line': return '📈 꺾은선 차트';
|
||||
default: return '📊 차트';
|
||||
case "bar":
|
||||
return "📊 바 차트";
|
||||
case "pie":
|
||||
return "🥧 원형 차트";
|
||||
case "line":
|
||||
return "📈 꺾은선 차트";
|
||||
default:
|
||||
return "📊 차트";
|
||||
}
|
||||
} else if (type === 'widget') {
|
||||
} else if (type === "widget") {
|
||||
switch (subtype) {
|
||||
case 'exchange': return '💱 환율 위젯';
|
||||
case 'weather': return '☁️ 날씨 위젯';
|
||||
default: return '🔧 위젯';
|
||||
case "exchange":
|
||||
return "💱 환율 위젯";
|
||||
case "weather":
|
||||
return "☁️ 날씨 위젯";
|
||||
default:
|
||||
return "🔧 위젯";
|
||||
}
|
||||
}
|
||||
return '요소';
|
||||
return "요소";
|
||||
}
|
||||
|
||||
// 요소 내용 생성 헬퍼 함수
|
||||
function getElementContent(type: ElementType, subtype: ElementSubtype): string {
|
||||
if (type === 'chart') {
|
||||
if (type === "chart") {
|
||||
switch (subtype) {
|
||||
case 'bar': return '바 차트가 여기에 표시됩니다';
|
||||
case 'pie': return '원형 차트가 여기에 표시됩니다';
|
||||
case 'line': return '꺾은선 차트가 여기에 표시됩니다';
|
||||
default: return '차트가 여기에 표시됩니다';
|
||||
case "bar":
|
||||
return "바 차트가 여기에 표시됩니다";
|
||||
case "pie":
|
||||
return "원형 차트가 여기에 표시됩니다";
|
||||
case "line":
|
||||
return "꺾은선 차트가 여기에 표시됩니다";
|
||||
default:
|
||||
return "차트가 여기에 표시됩니다";
|
||||
}
|
||||
} else if (type === 'widget') {
|
||||
} else if (type === "widget") {
|
||||
switch (subtype) {
|
||||
case 'exchange': return 'USD: ₩1,320\nJPY: ₩900\nEUR: ₩1,450';
|
||||
case 'weather': return '서울\n23°C\n구름 많음';
|
||||
default: return '위젯 내용이 여기에 표시됩니다';
|
||||
case "exchange":
|
||||
return "USD: ₩1,320\nJPY: ₩900\nEUR: ₩1,450";
|
||||
case "weather":
|
||||
return "서울\n23°C\n구름 많음";
|
||||
default:
|
||||
return "위젯 내용이 여기에 표시됩니다";
|
||||
}
|
||||
}
|
||||
return '내용이 여기에 표시됩니다';
|
||||
return "내용이 여기에 표시됩니다";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
"use client";
|
||||
|
||||
import React from 'react';
|
||||
import { DragData, ElementType, ElementSubtype } from './types';
|
||||
import React from "react";
|
||||
import { DragData, ElementType, ElementSubtype } from "./types";
|
||||
|
||||
/**
|
||||
* 대시보드 사이드바 컴포넌트
|
||||
|
|
@ -12,18 +12,16 @@ export function DashboardSidebar() {
|
|||
// 드래그 시작 처리
|
||||
const handleDragStart = (e: React.DragEvent, type: ElementType, subtype: ElementSubtype) => {
|
||||
const dragData: DragData = { type, subtype };
|
||||
e.dataTransfer.setData('application/json', JSON.stringify(dragData));
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-80 bg-white border-l border-gray-200 overflow-y-auto p-5">
|
||||
<div className="w-[370px] overflow-y-auto border-l border-gray-200 bg-white p-6">
|
||||
{/* 차트 섹션 */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-gray-800 mb-4 pb-3 border-b-2 border-green-500 font-semibold text-lg">
|
||||
📊 차트 종류
|
||||
</h3>
|
||||
|
||||
<h3 className="mb-4 border-b-2 border-green-500 pb-3 text-lg font-semibold text-gray-800">📊 차트 종류</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<DraggableItem
|
||||
icon="📊"
|
||||
|
|
@ -31,7 +29,7 @@ export function DashboardSidebar() {
|
|||
type="chart"
|
||||
subtype="bar"
|
||||
onDragStart={handleDragStart}
|
||||
className="border-l-4 border-primary"
|
||||
className="border-primary border-l-4"
|
||||
/>
|
||||
<DraggableItem
|
||||
icon="📚"
|
||||
|
|
@ -86,10 +84,8 @@ export function DashboardSidebar() {
|
|||
|
||||
{/* 위젯 섹션 */}
|
||||
<div className="mb-8">
|
||||
<h3 className="text-gray-800 mb-4 pb-3 border-b-2 border-green-500 font-semibold text-lg">
|
||||
🔧 위젯 종류
|
||||
</h3>
|
||||
|
||||
<h3 className="mb-4 border-b-2 border-green-500 pb-3 text-lg font-semibold text-gray-800">🔧 위젯 종류</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<DraggableItem
|
||||
icon="💱"
|
||||
|
|
@ -125,20 +121,14 @@ interface DraggableItemProps {
|
|||
/**
|
||||
* 드래그 가능한 아이템 컴포넌트
|
||||
*/
|
||||
function DraggableItem({ icon, title, type, subtype, className = '', onDragStart }: DraggableItemProps) {
|
||||
function DraggableItem({ icon, title, type, subtype, className = "", onDragStart }: DraggableItemProps) {
|
||||
return (
|
||||
<div
|
||||
draggable
|
||||
className={`
|
||||
p-4 bg-white border-2 border-gray-200 rounded-lg
|
||||
cursor-move transition-all duration-200
|
||||
hover:bg-gray-50 hover:border-green-500 hover:translate-x-1
|
||||
text-center text-sm font-medium
|
||||
${className}
|
||||
`}
|
||||
className={`cursor-move rounded-lg border-2 border-gray-200 bg-white p-4 text-center text-sm font-medium transition-all duration-200 hover:translate-x-1 hover:border-green-500 hover:bg-gray-50 ${className} `}
|
||||
onDragStart={(e) => onDragStart(e, type, subtype)}
|
||||
>
|
||||
<span className="text-lg mr-2">{icon}</span>
|
||||
<span className="mr-2 text-lg">{icon}</span>
|
||||
{title}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,228 @@
|
|||
# 대시보드 그리드 시스템
|
||||
|
||||
## 개요
|
||||
|
||||
대시보드 캔버스는 **12 컬럼 그리드 시스템**을 사용하여 요소를 정렬하고 배치합니다.
|
||||
모든 요소는 드래그 또는 리사이즈 종료 시 자동으로 그리드에 스냅됩니다.
|
||||
|
||||
## 그리드 설정
|
||||
|
||||
### 기본 설정 (`gridUtils.ts`)
|
||||
|
||||
```typescript
|
||||
GRID_CONFIG = {
|
||||
COLUMNS: 12, // 12 컬럼
|
||||
CELL_SIZE: 60, // 60px x 60px 정사각형 셀
|
||||
GAP: 8, // 셀 간격 8px
|
||||
SNAP_THRESHOLD: 15, // 스냅 임계값 15px
|
||||
};
|
||||
```
|
||||
|
||||
### 실제 그리드 크기
|
||||
|
||||
- **셀 크기 (gap 포함)**: 68px (60px + 8px)
|
||||
- **전체 캔버스 너비**: 808px (12 \* 68px - 8px)
|
||||
- **셀 비율**: 1:1 (정사각형)
|
||||
|
||||
## 스냅 기능
|
||||
|
||||
### 1. 위치 스냅
|
||||
|
||||
요소를 드래그하여 이동할 때:
|
||||
|
||||
- **드래그 중**: 자유롭게 이동 (그리드 무시)
|
||||
- **드래그 종료**: 가장 가까운 그리드 포인트에 자동 스냅
|
||||
- **스냅 계산**: `Math.round(value / 68) * 68`
|
||||
|
||||
### 2. 크기 스냅
|
||||
|
||||
요소의 크기를 조절할 때:
|
||||
|
||||
- **리사이즈 중**: 자유롭게 크기 조절
|
||||
- **리사이즈 종료**: 그리드 단위로 스냅
|
||||
- **최소 크기**: 2셀 x 2셀 (136px x 136px)
|
||||
|
||||
### 3. 드롭 스냅
|
||||
|
||||
사이드바에서 새 요소를 드래그 앤 드롭할 때:
|
||||
|
||||
- 드롭 위치가 자동으로 가장 가까운 그리드 포인트에 스냅
|
||||
- 기본 크기:
|
||||
- 차트: 4 x 3 셀 (264px x 196px)
|
||||
- 위젯: 2 x 2 셀 (136px x 136px)
|
||||
|
||||
## 시각적 피드백
|
||||
|
||||
### 그리드 배경
|
||||
|
||||
캔버스 배경에 그리드 라인이 표시됩니다:
|
||||
|
||||
```typescript
|
||||
backgroundImage: `
|
||||
linear-gradient(rgba(59, 130, 246, 0.1) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(59, 130, 246, 0.1) 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '68px 68px'
|
||||
```
|
||||
|
||||
### 요소 테두리
|
||||
|
||||
- **선택 안 됨**: 회색 테두리
|
||||
- **선택됨**: 파란색 테두리 + 리사이즈 핸들 표시
|
||||
- **드래그/리사이즈 중**: 트랜지션 비활성화 (부드러운 움직임)
|
||||
|
||||
## 사용 예시
|
||||
|
||||
### 기본 사용
|
||||
|
||||
```typescript
|
||||
import { snapToGrid, snapSizeToGrid } from "./gridUtils";
|
||||
|
||||
// 위치 스냅
|
||||
const snappedX = snapToGrid(123); // 136 (가장 가까운 그리드)
|
||||
const snappedY = snapToGrid(45); // 68
|
||||
|
||||
// 크기 스냅
|
||||
const snappedWidth = snapSizeToGrid(250); // 264 (4셀)
|
||||
const snappedHeight = snapSizeToGrid(180); // 196 (3셀)
|
||||
```
|
||||
|
||||
### 경계 체크와 함께
|
||||
|
||||
```typescript
|
||||
import { snapBoundsToGrid } from "./gridUtils";
|
||||
|
||||
const snapped = snapBoundsToGrid(
|
||||
{
|
||||
position: { x: 123, y: 45 },
|
||||
size: { width: 250, height: 180 },
|
||||
},
|
||||
canvasWidth,
|
||||
canvasHeight,
|
||||
);
|
||||
|
||||
// 결과:
|
||||
// {
|
||||
// position: { x: 136, y: 68 },
|
||||
// size: { width: 264, height: 196 }
|
||||
// }
|
||||
```
|
||||
|
||||
## 그리드 인덱스
|
||||
|
||||
### 좌표 → 인덱스
|
||||
|
||||
```typescript
|
||||
import { getGridIndex } from "./gridUtils";
|
||||
|
||||
const colIndex = getGridIndex(150); // 2 (3번째 컬럼)
|
||||
const rowIndex = getGridIndex(100); // 1 (2번째 행)
|
||||
```
|
||||
|
||||
### 인덱스 → 좌표
|
||||
|
||||
```typescript
|
||||
import { gridIndexToCoordinate } from "./gridUtils";
|
||||
|
||||
const x = gridIndexToCoordinate(0); // 0 (1번째 컬럼)
|
||||
const y = gridIndexToCoordinate(1); // 68 (2번째 행)
|
||||
const z = gridIndexToCoordinate(11); // 748 (12번째 컬럼)
|
||||
```
|
||||
|
||||
## 레이아웃 권장사항
|
||||
|
||||
### 일반 차트
|
||||
|
||||
- **권장 크기**: 4 x 3 셀 (264px x 196px)
|
||||
- **최소 크기**: 2 x 2 셀 (136px x 136px)
|
||||
- **최대 크기**: 12 x 8 셀 (808px x 536px)
|
||||
|
||||
### 작은 위젯
|
||||
|
||||
- **권장 크기**: 2 x 2 셀 (136px x 136px)
|
||||
- **최소 크기**: 2 x 2 셀
|
||||
- **최대 크기**: 4 x 4 셀 (264px x 264px)
|
||||
|
||||
### 큰 차트/대시보드
|
||||
|
||||
- **권장 크기**: 6 x 4 셀 (400px x 264px)
|
||||
- **풀 너비**: 12 셀 (808px)
|
||||
|
||||
## 커스터마이징
|
||||
|
||||
### 그리드 크기 변경
|
||||
|
||||
`gridUtils.ts`의 `GRID_CONFIG`를 수정:
|
||||
|
||||
```typescript
|
||||
export const GRID_CONFIG = {
|
||||
COLUMNS: 12,
|
||||
CELL_SIZE: 80, // 60 → 80 (셀 크기 증가)
|
||||
GAP: 16, // 8 → 16 (간격 증가)
|
||||
SNAP_THRESHOLD: 20, // 15 → 20 (스냅 범위 증가)
|
||||
} as const;
|
||||
```
|
||||
|
||||
### 스냅 비활성화
|
||||
|
||||
특정 요소에서 스냅을 비활성화하려면:
|
||||
|
||||
```typescript
|
||||
// 드래그 종료 시 스냅하지 않고 그냥 업데이트
|
||||
onUpdate(element.id, {
|
||||
position: { x: rawX, y: rawY }, // snapToGrid 호출 안 함
|
||||
});
|
||||
```
|
||||
|
||||
## 성능 최적화
|
||||
|
||||
### 트랜지션 제어
|
||||
|
||||
드래그/리사이즈 중에는 CSS 트랜지션을 비활성화:
|
||||
|
||||
```typescript
|
||||
className={`
|
||||
${(isDragging || isResizing) ? 'transition-none' : 'transition-all duration-150'}
|
||||
`}
|
||||
```
|
||||
|
||||
### 임시 상태 사용
|
||||
|
||||
마우스 이동 중에는 임시 위치/크기만 업데이트하고,
|
||||
마우스 업 시에만 실제 스냅된 값으로 업데이트:
|
||||
|
||||
```typescript
|
||||
// 드래그 중
|
||||
setTempPosition({ x: rawX, y: rawY });
|
||||
|
||||
// 드래그 종료
|
||||
const snapped = snapToGrid(tempPosition.x);
|
||||
onUpdate(element.id, { position: { x: snapped, y: snapped } });
|
||||
```
|
||||
|
||||
## 문제 해결
|
||||
|
||||
### 요소가 스냅되지 않는 경우
|
||||
|
||||
1. `snapToGrid` 함수가 호출되는지 확인
|
||||
2. `SNAP_THRESHOLD` 값 확인 (너무 작으면 스냅 안 됨)
|
||||
3. 임시 상태가 제대로 초기화되는지 확인
|
||||
|
||||
### 그리드가 보이지 않는 경우
|
||||
|
||||
1. 캔버스의 `backgroundImage` 스타일 확인
|
||||
2. `getCellWithGap()` 반환값 확인
|
||||
3. 브라우저 개발자 도구에서 배경 스타일 검사
|
||||
|
||||
### 성능 문제
|
||||
|
||||
1. 트랜지션이 비활성화되었는지 확인
|
||||
2. 불필요한 리렌더링 방지 (React.memo 사용)
|
||||
3. 마우스 이벤트 리스너가 제대로 제거되는지 확인
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- **그리드 유틸리티**: `gridUtils.ts`
|
||||
- **캔버스 컴포넌트**: `DashboardCanvas.tsx`
|
||||
- **요소 컴포넌트**: `CanvasElement.tsx`
|
||||
- **디자이너**: `DashboardDesigner.tsx`
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
/**
|
||||
* 대시보드 그리드 시스템 유틸리티
|
||||
* - 12 컬럼 그리드 시스템
|
||||
* - 정사각형 셀 (가로 = 세로)
|
||||
* - 스냅 기능
|
||||
*/
|
||||
|
||||
// 그리드 설정 (고정 크기)
|
||||
export const GRID_CONFIG = {
|
||||
COLUMNS: 12,
|
||||
CELL_SIZE: 132, // 고정 셀 크기
|
||||
GAP: 8, // 셀 간격
|
||||
SNAP_THRESHOLD: 15, // 스냅 임계값 (px)
|
||||
ELEMENT_PADDING: 4, // 요소 주위 여백 (px)
|
||||
CANVAS_WIDTH: 1682, // 고정 캔버스 너비 (실제 측정값)
|
||||
// 계산식: (132 + 8) × 12 - 8 = 1672px (그리드)
|
||||
// 추가 여백 10px 포함 = 1682px
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 실제 그리드 셀 크기 계산 (gap 포함)
|
||||
*/
|
||||
export const getCellWithGap = () => {
|
||||
return GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP;
|
||||
};
|
||||
|
||||
/**
|
||||
* 전체 캔버스 너비 계산
|
||||
*/
|
||||
export const getCanvasWidth = () => {
|
||||
const cellWithGap = getCellWithGap();
|
||||
return GRID_CONFIG.COLUMNS * cellWithGap - GRID_CONFIG.GAP;
|
||||
};
|
||||
|
||||
/**
|
||||
* 좌표를 가장 가까운 그리드 포인트로 스냅 (여백 포함)
|
||||
* @param value - 스냅할 좌표값
|
||||
* @param cellSize - 셀 크기 (선택사항, 기본값은 GRID_CONFIG.CELL_SIZE)
|
||||
* @returns 스냅된 좌표값 (여백 포함)
|
||||
*/
|
||||
export const snapToGrid = (value: number, cellSize: number = GRID_CONFIG.CELL_SIZE): number => {
|
||||
const cellWithGap = cellSize + GRID_CONFIG.GAP;
|
||||
const gridIndex = Math.round(value / cellWithGap);
|
||||
return gridIndex * cellWithGap + GRID_CONFIG.ELEMENT_PADDING;
|
||||
};
|
||||
|
||||
/**
|
||||
* 좌표를 그리드에 스냅 (임계값 적용)
|
||||
* @param value - 현재 좌표값
|
||||
* @param cellSize - 셀 크기 (선택사항)
|
||||
* @returns 스냅된 좌표값 (임계값 내에 있으면 스냅, 아니면 원래 값)
|
||||
*/
|
||||
export const snapToGridWithThreshold = (value: number, cellSize: number = GRID_CONFIG.CELL_SIZE): number => {
|
||||
const snapped = snapToGrid(value, cellSize);
|
||||
const distance = Math.abs(value - snapped);
|
||||
|
||||
return distance <= GRID_CONFIG.SNAP_THRESHOLD ? snapped : value;
|
||||
};
|
||||
|
||||
/**
|
||||
* 크기를 그리드 단위로 스냅
|
||||
* @param size - 스냅할 크기
|
||||
* @param minCells - 최소 셀 개수 (기본값: 2)
|
||||
* @param cellSize - 셀 크기 (선택사항)
|
||||
* @returns 스냅된 크기
|
||||
*/
|
||||
export const snapSizeToGrid = (
|
||||
size: number,
|
||||
minCells: number = 2,
|
||||
cellSize: number = GRID_CONFIG.CELL_SIZE,
|
||||
): number => {
|
||||
const cellWithGap = cellSize + GRID_CONFIG.GAP;
|
||||
const cells = Math.max(minCells, Math.round(size / cellWithGap));
|
||||
return cells * cellWithGap - GRID_CONFIG.GAP;
|
||||
};
|
||||
|
||||
/**
|
||||
* 위치와 크기를 모두 그리드에 스냅
|
||||
*/
|
||||
export interface GridPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface GridSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface GridBounds {
|
||||
position: GridPosition;
|
||||
size: GridSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* 요소의 위치와 크기를 그리드에 맞춰 조정
|
||||
* @param bounds - 현재 위치와 크기
|
||||
* @param canvasWidth - 캔버스 너비 (경계 체크용)
|
||||
* @param canvasHeight - 캔버스 높이 (경계 체크용)
|
||||
* @returns 그리드에 스냅된 위치와 크기
|
||||
*/
|
||||
export const snapBoundsToGrid = (bounds: GridBounds, canvasWidth?: number, canvasHeight?: number): GridBounds => {
|
||||
// 위치 스냅
|
||||
let snappedX = snapToGrid(bounds.position.x);
|
||||
let snappedY = snapToGrid(bounds.position.y);
|
||||
|
||||
// 크기 스냅
|
||||
const snappedWidth = snapSizeToGrid(bounds.size.width);
|
||||
const snappedHeight = snapSizeToGrid(bounds.size.height);
|
||||
|
||||
// 캔버스 경계 체크
|
||||
if (canvasWidth) {
|
||||
snappedX = Math.min(snappedX, canvasWidth - snappedWidth);
|
||||
}
|
||||
if (canvasHeight) {
|
||||
snappedY = Math.min(snappedY, canvasHeight - snappedHeight);
|
||||
}
|
||||
|
||||
// 음수 방지
|
||||
snappedX = Math.max(0, snappedX);
|
||||
snappedY = Math.max(0, snappedY);
|
||||
|
||||
return {
|
||||
position: { x: snappedX, y: snappedY },
|
||||
size: { width: snappedWidth, height: snappedHeight },
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 좌표가 어느 그리드 셀에 속하는지 계산
|
||||
* @param value - 좌표값
|
||||
* @returns 그리드 인덱스 (0부터 시작)
|
||||
*/
|
||||
export const getGridIndex = (value: number): number => {
|
||||
const cellWithGap = getCellWithGap();
|
||||
return Math.floor(value / cellWithGap);
|
||||
};
|
||||
|
||||
/**
|
||||
* 그리드 인덱스를 좌표로 변환
|
||||
* @param index - 그리드 인덱스
|
||||
* @returns 좌표값
|
||||
*/
|
||||
export const gridIndexToCoordinate = (index: number): number => {
|
||||
const cellWithGap = getCellWithGap();
|
||||
return index * cellWithGap;
|
||||
};
|
||||
|
||||
/**
|
||||
* 스냅 가이드라인 표시용 좌표 계산
|
||||
* @param value - 현재 좌표
|
||||
* @returns 가장 가까운 그리드 라인들의 좌표 배열
|
||||
*/
|
||||
export const getNearbyGridLines = (value: number): number[] => {
|
||||
const snapped = snapToGrid(value);
|
||||
const cellWithGap = getCellWithGap();
|
||||
|
||||
return [snapped - cellWithGap, snapped, snapped + cellWithGap].filter((line) => line >= 0);
|
||||
};
|
||||
|
||||
/**
|
||||
* 위치가 스냅 임계값 내에 있는지 확인
|
||||
* @param value - 현재 값
|
||||
* @param snapValue - 스냅할 값
|
||||
* @returns 임계값 내에 있으면 true
|
||||
*/
|
||||
export const isWithinSnapThreshold = (value: number, snapValue: number): boolean => {
|
||||
return Math.abs(value - snapValue) <= GRID_CONFIG.SNAP_THRESHOLD;
|
||||
};
|
||||
Loading…
Reference in New Issue