대시보드에 12그리드 스냅 시스템 적용

This commit is contained in:
dohyeons 2025-10-13 17:05:14 +09:00
parent fbb42dd83c
commit cf909cded6
5 changed files with 870 additions and 327 deletions

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,54 @@ 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,
}}
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 +298,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 +308,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 +330,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 +358,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 +368,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 +392,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,53 @@ 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 [cellSize, setCellSize] = useState(60);
// 화면 크기에 따라 셀 크기 동적 계산
useEffect(() => {
const updateCellSize = () => {
if (!ref || typeof ref === "function" || !ref.current) return;
const container = ref.current.parentElement;
if (!container) return;
// 컨테이너 너비에서 여백 제외
const availableWidth = container.clientWidth - 32; // 좌우 패딩
// 12 컬럼 + 11개 gap을 고려한 셀 크기 계산
const calculatedCellSize = Math.floor((availableWidth - 11 * GRID_CONFIG.GAP) / GRID_CONFIG.COLUMNS);
setCellSize(calculatedCellSize);
};
updateCellSize();
window.addEventListener("resize", updateCellSize);
return () => window.removeEventListener("resize", updateCellSize);
}, [ref]);
// 드래그 오버 처리
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
e.dataTransfer.dropEffect = "copy";
setIsDragOver(true);
}, []);
@ -38,51 +75,62 @@ 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, cellSize);
const snappedY = snapToGrid(rawY, cellSize);
onCreateElement(dragData.type, dragData.subtype, snappedX, snappedY);
} catch (error) {
// console.error('드롭 데이터 파싱 오류:', error);
}
},
[ref, onCreateElement, cellSize],
);
// 캔버스 클릭 시 선택 해제
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 = cellSize + GRID_CONFIG.GAP;
const gridSize = `${cellWithGap}px ${cellWithGap}px`;
return (
<div
ref={ref}
className={`
w-full min-h-full relative
bg-gray-100
bg-grid-pattern
${isDragOver ? 'bg-accent' : ''}
`}
className={`relative min-h-[600px] w-full bg-gray-50 ${isDragOver ? "bg-blue-50/50" : ""} `}
style={{
// 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.1) 1px, transparent 1px),
linear-gradient(90deg, rgba(59, 130, 246, 0.1) 1px, transparent 1px)
`,
backgroundSize: '20px 20px'
backgroundSize: gridSize,
backgroundPosition: "0 0",
}}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
@ -95,6 +143,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
key={element.id}
element={element}
isSelected={selectedElement === element.id}
cellSize={cellSize}
onUpdate={onUpdateElement}
onRemove={onRemoveElement}
onSelect={onSelectElement}
@ -103,7 +152,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,39 @@ 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 [cellSize, setCellSize] = useState(60);
const canvasRef = useRef<HTMLDivElement>(null);
// 화면 크기에 따라 셀 크기 동적 계산
useEffect(() => {
const updateCellSize = () => {
if (!canvasRef.current) return;
const container = canvasRef.current.parentElement;
if (!container) return;
// 컨테이너 너비에서 여백 제외
const availableWidth = container.clientWidth - 32;
// 12 컬럼 + 11개 gap을 고려한 셀 크기 계산
const calculatedCellSize = Math.floor((availableWidth - 11 * GRID_CONFIG.GAP) / GRID_CONFIG.COLUMNS);
setCellSize(calculatedCellSize);
};
updateCellSize();
window.addEventListener("resize", updateCellSize);
return () => window.removeEventListener("resize", updateCellSize);
}, []);
// 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 +64,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 +89,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 = cellSize + 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, cellSize],
);
// 요소 업데이트
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 +163,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 +190,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 +242,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,18 +253,15 @@ 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">
{/* 편집 중인 대시보드 표시 */}
{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-2 left-2 z-10 rounded-lg px-3 py-1 text-sm font-medium text-white shadow-lg">
📝 : {dashboardTitle}
</div>
)}
<DashboardToolbar
onClearCanvas={clearCanvas}
onSaveLayout={saveLayout}
/>
<DashboardToolbar onClearCanvas={clearCanvas} onSaveLayout={saveLayout} />
<DashboardCanvas
ref={canvasRef}
elements={elements}
@ -260,38 +292,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

@ -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,165 @@
/**
*
* - 12
* - ( = )
* -
*/
// 그리드 설정
export const GRID_CONFIG = {
COLUMNS: 12,
CELL_SIZE: 60, // 60px 정사각형 셀
GAP: 8, // 셀 간격
SNAP_THRESHOLD: 15, // 스냅 임계값 (px)
} 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;
};
/**
* ( )
* @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;
};