feature/screen-management #95

Merged
kjs merged 5 commits from feature/screen-management into main 2025-10-13 19:18:22 +09:00
7 changed files with 1218 additions and 480 deletions
Showing only changes of commit 289e396406 - Show all commits

View File

@ -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 },
];
}

View File

@ -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";

View File

@ -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 "내용이 여기에 표시됩니다";
}

View File

@ -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>
);

View File

@ -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`

View File

@ -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;
};

View File

@ -1,155 +1,389 @@
import type { ComponentConfig, GridConfig } from "@/types/report";
import { Position, Size } from "@/types/screen";
import { GridSettings } from "@/types/screen-management";
/**
*
*/
export function pixelToGrid(pixel: number, cellSize: number): number {
return Math.round(pixel / cellSize);
export interface GridInfo {
columnWidth: number;
totalWidth: number;
totalHeight: number;
}
/**
*
*
*/
export function gridToPixel(grid: number, cellSize: number): number {
return grid * cellSize;
}
export function calculateGridInfo(
containerWidth: number,
containerHeight: number,
gridSettings: GridSettings,
): GridInfo {
const { columns, gap, padding } = gridSettings;
/**
* /
*/
export function snapComponentToGrid(component: ComponentConfig, gridConfig: GridConfig): ComponentConfig {
if (!gridConfig.snapToGrid) {
return component;
}
// 사용 가능한 너비 계산 (패딩 제외)
const availableWidth = containerWidth - padding * 2;
// 픽셀 좌표를 그리드 좌표로 변환
const gridX = pixelToGrid(component.x, gridConfig.cellWidth);
const gridY = pixelToGrid(component.y, gridConfig.cellHeight);
const gridWidth = Math.max(1, pixelToGrid(component.width, gridConfig.cellWidth));
const gridHeight = Math.max(1, pixelToGrid(component.height, gridConfig.cellHeight));
// 격자 간격을 고려한 컬럼 너비 계산
const totalGaps = (columns - 1) * gap;
const columnWidth = (availableWidth - totalGaps) / columns;
// 그리드 좌표를 다시 픽셀로 변환
return {
...component,
gridX,
gridY,
gridWidth,
gridHeight,
x: gridToPixel(gridX, gridConfig.cellWidth),
y: gridToPixel(gridY, gridConfig.cellHeight),
width: gridToPixel(gridWidth, gridConfig.cellWidth),
height: gridToPixel(gridHeight, gridConfig.cellHeight),
columnWidth: Math.max(columnWidth, 20), // 최소 20px로 줄여서 더 많은 컬럼 표시
totalWidth: containerWidth,
totalHeight: containerHeight,
};
}
/**
*
*
*
*/
export function detectGridCollision(
component: ComponentConfig,
otherComponents: ComponentConfig[],
gridConfig: GridConfig,
): boolean {
const comp1GridX = component.gridX ?? pixelToGrid(component.x, gridConfig.cellWidth);
const comp1GridY = component.gridY ?? pixelToGrid(component.y, gridConfig.cellHeight);
const comp1GridWidth = component.gridWidth ?? pixelToGrid(component.width, gridConfig.cellWidth);
const comp1GridHeight = component.gridHeight ?? pixelToGrid(component.height, gridConfig.cellHeight);
export function snapToGrid(position: Position, gridInfo: GridInfo, gridSettings: GridSettings): Position {
if (!gridSettings.snapToGrid) {
return position;
}
for (const other of otherComponents) {
if (other.id === component.id) continue;
const { columnWidth } = gridInfo;
const { gap, padding } = gridSettings;
const comp2GridX = other.gridX ?? pixelToGrid(other.x, gridConfig.cellWidth);
const comp2GridY = other.gridY ?? pixelToGrid(other.y, gridConfig.cellHeight);
const comp2GridWidth = other.gridWidth ?? pixelToGrid(other.width, gridConfig.cellWidth);
const comp2GridHeight = other.gridHeight ?? pixelToGrid(other.height, gridConfig.cellHeight);
// 격자 셀 크기 (컬럼 너비 + 간격을 하나의 격자 단위로 계산)
const cellWidth = columnWidth + gap;
const cellHeight = Math.max(40, gap * 2); // 행 높이를 더 크게 설정
// AABB (Axis-Aligned Bounding Box) 충돌 감지
const xOverlap = comp1GridX < comp2GridX + comp2GridWidth && comp1GridX + comp1GridWidth > comp2GridX;
const yOverlap = comp1GridY < comp2GridY + comp2GridHeight && comp1GridY + comp1GridHeight > comp2GridY;
// 패딩을 제외한 상대 위치
const relativeX = position.x - padding;
const relativeY = position.y - padding;
if (xOverlap && yOverlap) {
return true;
// 격자 기준으로 위치 계산 (가장 가까운 격자점으로 스냅)
const gridX = Math.round(relativeX / cellWidth);
const gridY = Math.round(relativeY / cellHeight);
// 실제 픽셀 위치로 변환
const snappedX = Math.max(padding, padding + gridX * cellWidth);
const snappedY = Math.max(padding, padding + gridY * cellHeight);
return {
x: snappedX,
y: snappedY,
z: position.z,
};
}
/**
*
*/
export function snapSizeToGrid(size: Size, gridInfo: GridInfo, gridSettings: GridSettings): Size {
if (!gridSettings.snapToGrid) {
return size;
}
const { columnWidth } = gridInfo;
const { gap } = gridSettings;
// 격자 단위로 너비 계산
// 컴포넌트가 차지하는 컬럼 수를 올바르게 계산
let gridColumns = 1;
// 현재 너비에서 가장 가까운 격자 컬럼 수 찾기
for (let cols = 1; cols <= gridSettings.columns; cols++) {
const targetWidth = cols * columnWidth + (cols - 1) * gap;
if (size.width <= targetWidth + (columnWidth + gap) / 2) {
gridColumns = cols;
break;
}
gridColumns = cols;
}
const snappedWidth = gridColumns * columnWidth + (gridColumns - 1) * gap;
// 높이는 동적 행 높이 단위로 스냅
const rowHeight = Math.max(20, gap);
const snappedHeight = Math.max(40, Math.round(size.height / rowHeight) * rowHeight);
console.log(
`📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`,
);
return {
width: Math.max(columnWidth, snappedWidth),
height: snappedHeight,
};
}
/**
*
*/
export function calculateWidthFromColumns(columns: number, gridInfo: GridInfo, gridSettings: GridSettings): number {
const { columnWidth } = gridInfo;
const { gap } = gridSettings;
return columns * columnWidth + (columns - 1) * gap;
}
/**
* gridColumns
*/
export function updateSizeFromGridColumns(
component: { gridColumns?: number; size: Size },
gridInfo: GridInfo,
gridSettings: GridSettings,
): Size {
if (!component.gridColumns || component.gridColumns < 1) {
return component.size;
}
const newWidth = calculateWidthFromColumns(component.gridColumns, gridInfo, gridSettings);
return {
width: newWidth,
height: component.size.height, // 높이는 유지
};
}
/**
* gridColumns를
*/
export function adjustGridColumnsFromSize(
component: { size: Size },
gridInfo: GridInfo,
gridSettings: GridSettings,
): number {
const columns = calculateColumnsFromWidth(component.size.width, gridInfo, gridSettings);
return Math.min(Math.max(1, columns), gridSettings.columns); // 1-12 범위로 제한
}
/**
*
*/
export function calculateColumnsFromWidth(width: number, gridInfo: GridInfo, gridSettings: GridSettings): number {
const { columnWidth } = gridInfo;
const { gap } = gridSettings;
return Math.max(1, Math.round((width + gap) / (columnWidth + gap)));
}
/**
*
*/
export function generateGridLines(
containerWidth: number,
containerHeight: number,
gridSettings: GridSettings,
): {
verticalLines: number[];
horizontalLines: number[];
} {
const { columns, gap, padding } = gridSettings;
const gridInfo = calculateGridInfo(containerWidth, containerHeight, gridSettings);
const { columnWidth } = gridInfo;
// 격자 셀 크기 (스냅 로직과 동일하게)
const cellWidth = columnWidth + gap;
const cellHeight = Math.max(40, gap * 2);
// 세로 격자선
const verticalLines: number[] = [];
for (let i = 0; i <= columns; i++) {
const x = padding + i * cellWidth;
if (x <= containerWidth) {
verticalLines.push(x);
}
}
return false;
}
// 가로 격자선
const horizontalLines: number[] = [];
for (let y = padding; y < containerHeight; y += cellHeight) {
horizontalLines.push(y);
}
/**
* /
*/
export function calculateGridDimensions(
pageWidth: number,
pageHeight: number,
cellWidth: number,
cellHeight: number,
): { rows: number; columns: number } {
return {
columns: Math.floor(pageWidth / cellWidth),
rows: Math.floor(pageHeight / cellHeight),
verticalLines,
horizontalLines,
};
}
/**
*
*
*/
export function createDefaultGridConfig(pageWidth: number, pageHeight: number): GridConfig {
const cellWidth = 20;
const cellHeight = 20;
const { rows, columns } = calculateGridDimensions(pageWidth, pageHeight, cellWidth, cellHeight);
return {
cellWidth,
cellHeight,
rows,
columns,
visible: true,
snapToGrid: true,
gridColor: "#e5e7eb",
gridOpacity: 0.5,
};
}
/**
*
*/
export function isWithinPageBounds(
component: ComponentConfig,
pageWidth: number,
pageHeight: number,
margins: { top: number; bottom: number; left: number; right: number },
export function isOnGridBoundary(
position: Position,
size: Size,
gridInfo: GridInfo,
gridSettings: GridSettings,
tolerance: number = 5,
): boolean {
const minX = margins.left;
const minY = margins.top;
const maxX = pageWidth - margins.right;
const maxY = pageHeight - margins.bottom;
const snappedPos = snapToGrid(position, gridInfo, gridSettings);
const snappedSize = snapSizeToGrid(size, gridInfo, gridSettings);
return (
component.x >= minX &&
component.y >= minY &&
component.x + component.width <= maxX &&
component.y + component.height <= maxY
);
const positionMatch =
Math.abs(position.x - snappedPos.x) <= tolerance && Math.abs(position.y - snappedPos.y) <= tolerance;
const sizeMatch =
Math.abs(size.width - snappedSize.width) <= tolerance && Math.abs(size.height - snappedSize.height) <= tolerance;
return positionMatch && sizeMatch;
}
/**
*
*
*/
export function constrainToPageBounds(
component: ComponentConfig,
pageWidth: number,
pageHeight: number,
margins: { top: number; bottom: number; left: number; right: number },
): ComponentConfig {
const minX = margins.left;
const minY = margins.top;
const maxX = pageWidth - margins.right - component.width;
const maxY = pageHeight - margins.bottom - component.height;
export function alignGroupChildrenToGrid(
children: any[],
groupPosition: Position,
gridInfo: GridInfo,
gridSettings: GridSettings,
): any[] {
if (!gridSettings.snapToGrid || children.length === 0) return children;
return {
...component,
x: Math.max(minX, Math.min(maxX, component.x)),
y: Math.max(minY, Math.min(maxY, component.y)),
};
console.log("🔧 alignGroupChildrenToGrid 시작:", {
childrenCount: children.length,
groupPosition,
gridInfo,
gridSettings,
});
return children.map((child, index) => {
console.log(`📐 자식 ${index + 1} 처리 중:`, {
childId: child.id,
originalPosition: child.position,
originalSize: child.size,
});
const { columnWidth } = gridInfo;
const { gap } = gridSettings;
// 그룹 내부 패딩 고려한 격자 정렬
const padding = 16;
const effectiveX = child.position.x - padding;
const columnIndex = Math.round(effectiveX / (columnWidth + gap));
const snappedX = padding + columnIndex * (columnWidth + gap);
// Y 좌표는 동적 행 높이 단위로 스냅
const rowHeight = Math.max(20, gap);
const effectiveY = child.position.y - padding;
const rowIndex = Math.round(effectiveY / rowHeight);
const snappedY = padding + rowIndex * rowHeight;
// 크기는 외부 격자와 동일하게 스냅 (columnWidth + gap 사용)
const fullColumnWidth = columnWidth + gap; // 외부 격자와 동일한 크기
const widthInColumns = Math.max(1, Math.round(child.size.width / fullColumnWidth));
const snappedWidth = widthInColumns * fullColumnWidth - gap; // gap 제거하여 실제 컴포넌트 크기
const snappedHeight = Math.max(40, Math.round(child.size.height / rowHeight) * rowHeight);
const snappedChild = {
...child,
position: {
x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보
y: Math.max(padding, snappedY),
z: child.position.z || 1,
},
size: {
width: snappedWidth,
height: snappedHeight,
},
};
console.log(`✅ 자식 ${index + 1} 격자 정렬 완료:`, {
childId: child.id,
calculation: {
effectiveX,
effectiveY,
columnIndex,
rowIndex,
widthInColumns,
originalX: child.position.x,
snappedX: snappedChild.position.x,
padding,
},
snappedPosition: snappedChild.position,
snappedSize: snappedChild.size,
deltaX: snappedChild.position.x - child.position.x,
deltaY: snappedChild.position.y - child.position.y,
});
return snappedChild;
});
}
/**
*
*/
export function calculateOptimalGroupSize(
children: Array<{ position: Position; size: Size }>,
gridInfo: GridInfo,
gridSettings: GridSettings,
): Size {
if (children.length === 0) {
return { width: gridInfo.columnWidth * 2, height: 40 * 2 };
}
console.log("📏 calculateOptimalGroupSize 시작:", {
childrenCount: children.length,
children: children.map((c) => ({ pos: c.position, size: c.size })),
});
// 모든 자식 컴포넌트를 포함하는 최소 경계 계산
const bounds = children.reduce(
(acc, child) => ({
minX: Math.min(acc.minX, child.position.x),
minY: Math.min(acc.minY, child.position.y),
maxX: Math.max(acc.maxX, child.position.x + child.size.width),
maxY: Math.max(acc.maxY, child.position.y + child.size.height),
}),
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
);
console.log("📐 경계 계산:", bounds);
const contentWidth = bounds.maxX - bounds.minX;
const contentHeight = bounds.maxY - bounds.minY;
// 그룹은 격자 스냅 없이 컨텐츠에 맞는 자연스러운 크기
const padding = 16; // 그룹 내부 여백
const groupSize = {
width: contentWidth + padding * 2,
height: contentHeight + padding * 2,
};
console.log("✅ 자연스러운 그룹 크기:", {
contentSize: { width: contentWidth, height: contentHeight },
withPadding: groupSize,
strategy: "그룹은 격자 스냅 없이, 내부 컴포넌트만 격자에 맞춤",
});
return groupSize;
}
/**
*
*/
export function normalizeGroupChildPositions(children: any[], gridSettings: GridSettings): any[] {
if (!gridSettings.snapToGrid || children.length === 0) return children;
console.log("🔄 normalizeGroupChildPositions 시작:", {
childrenCount: children.length,
originalPositions: children.map((c) => ({ id: c.id, pos: c.position })),
});
// 모든 자식의 최소 위치 찾기
const minX = Math.min(...children.map((child) => child.position.x));
const minY = Math.min(...children.map((child) => child.position.y));
console.log("📍 최소 위치:", { minX, minY });
// 그룹 내에서 시작점을 패딩만큼 떨어뜨림 (자연스러운 여백)
const padding = 16;
const startX = padding;
const startY = padding;
const normalizedChildren = children.map((child) => ({
...child,
position: {
x: child.position.x - minX + startX,
y: child.position.y - minY + startY,
z: child.position.z || 1,
},
}));
console.log("✅ 정규화 완료:", {
normalizedPositions: normalizedChildren.map((c) => ({ id: c.id, pos: c.position })),
});
return normalizedChildren;
}