12컬럼 그리드로 변경

This commit is contained in:
dohyeons 2025-10-22 16:37:14 +09:00
parent f1d74cfd0e
commit 9dca73f4c4
3 changed files with 157 additions and 66 deletions

View File

@ -4,7 +4,7 @@ import React, { useState, useCallback, useRef, useEffect } from "react";
import dynamic from "next/dynamic";
import { DashboardElement, QueryResult, Position } from "./types";
import { ChartRenderer } from "./charts/ChartRenderer";
import { GRID_CONFIG } from "./gridUtils";
import { GRID_CONFIG, magneticSnap } from "./gridUtils";
// 위젯 동적 임포트
const WeatherWidget = dynamic(() => import("@/components/dashboard/widgets/WeatherWidget"), {
@ -135,6 +135,8 @@ interface CanvasElementProps {
cellSize: number;
subGridSize: number;
canvasWidth?: number;
verticalGuidelines: number[];
horizontalGuidelines: number[];
onUpdate: (id: string, updates: Partial<DashboardElement>) => void;
onUpdateMultiple?: (updates: { id: string; updates: Partial<DashboardElement> }[]) => void; // 🔥 다중 업데이트
onMultiDragStart?: (draggedId: string, otherOffsets: Record<string, { x: number; y: number }>) => void;
@ -159,6 +161,8 @@ export function CanvasElement({
cellSize,
subGridSize,
canvasWidth = 1560,
verticalGuidelines,
horizontalGuidelines,
onUpdate,
onUpdateMultiple,
onMultiDragStart,
@ -315,15 +319,18 @@ export function CanvasElement({
const maxX = canvasWidth - element.size.width;
rawX = Math.min(rawX, maxX);
// 드래그 중 실시간 스냅 (서브그리드만 사용)
const snappedX = Math.round(rawX / subGridSize) * subGridSize;
const snappedY = Math.round(rawY / subGridSize) * subGridSize;
// 자석 스냅으로 변경
const snappedX = magneticSnap(rawX, verticalGuidelines);
const snappedY = magneticSnap(rawY, horizontalGuidelines);
setTempPosition({ x: snappedX, y: snappedY });
// 스냅 후 X 좌표 다시 체크
const finalSnappedX = Math.min(snappedX, maxX);
setTempPosition({ x: finalSnappedX, y: snappedY });
// 🔥 다중 드래그 중 - 다른 위젯들의 위치 업데이트
if (selectedElements.length > 1 && selectedElements.includes(element.id) && onMultiDragMove) {
onMultiDragMove(element, { x: snappedX, y: snappedY });
onMultiDragMove(element, { x: finalSnappedX, y: snappedY });
}
} else if (isResizing) {
const deltaX = e.clientX - resizeStart.x;
@ -367,14 +374,20 @@ export function CanvasElement({
const maxWidth = canvasWidth - newX;
newWidth = Math.min(newWidth, maxWidth);
// 리사이즈 중 실시간 스냅 (서브그리드만 사용)
const snappedX = Math.round(newX / subGridSize) * subGridSize;
const snappedY = Math.round(newY / subGridSize) * subGridSize;
const snappedWidth = Math.round(newWidth / subGridSize) * subGridSize;
const snappedHeight = Math.round(newHeight / subGridSize) * subGridSize;
// 자석 스냅으로 변경
const snappedX = magneticSnap(newX, verticalGuidelines);
const snappedY = magneticSnap(newY, horizontalGuidelines);
// 크기는 12px 단위로 스냅
const snappedWidth = Math.round(newWidth / 12) * 12;
const snappedHeight = Math.round(newHeight / 12) * 12;
// 스냅 후 경계 체크
const finalSnappedX = Math.max(0, Math.min(snappedX, canvasWidth - snappedWidth));
const finalSnappedY = Math.max(0, snappedY);
// 임시 크기/위치 저장 (스냅됨)
setTempPosition({ x: Math.max(0, snappedX), y: Math.max(0, snappedY) });
setTempPosition({ x: finalSnappedX, y: finalSnappedY });
setTempSize({ width: snappedWidth, height: snappedHeight });
}
},
@ -386,7 +399,8 @@ export function CanvasElement({
element,
canvasWidth,
cellSize,
subGridSize,
verticalGuidelines,
horizontalGuidelines,
selectedElements,
allElements,
onUpdateMultiple,
@ -891,12 +905,7 @@ export function CanvasElement({
) : element.type === "widget" && element.subtype === "list" ? (
// 리스트 위젯 렌더링
<div className="h-full w-full">
<ListWidget
element={element}
onConfigUpdate={(newConfig) => {
onUpdate(element.id, { listConfig: newConfig as any });
}}
/>
<ListWidget element={element} />
</div>
) : element.type === "widget" && element.subtype === "yard-management-3d" ? (
// 야드 관리 3D 위젯 렌더링

View File

@ -3,7 +3,15 @@
import React, { forwardRef, useState, useCallback, useMemo, useEffect } from "react";
import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types";
import { CanvasElement } from "./CanvasElement";
import { GRID_CONFIG, snapToGrid, calculateGridConfig } from "./gridUtils";
import {
GRID_CONFIG,
snapToGrid,
calculateGridConfig,
calculateVerticalGuidelines,
calculateHorizontalGuidelines,
calculateBoxSize,
magneticSnap,
} from "./gridUtils";
import { resolveAllCollisions } from "./collisionUtils";
interface DashboardCanvasProps {
@ -47,7 +55,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
ref,
) => {
const [isDragOver, setIsDragOver] = useState(false);
// 🔥 선택 박스 상태
const [selectionBox, setSelectionBox] = useState<{
startX: number;
@ -58,10 +66,10 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
const [isSelecting, setIsSelecting] = useState(false);
const [justSelected, setJustSelected] = useState(false); // 🔥 방금 선택했는지 플래그
const [isDraggingAny, setIsDraggingAny] = useState(false); // 🔥 현재 드래그 중인지 플래그
// 🔥 다중 선택된 위젯들의 임시 위치 (드래그 중 시각적 피드백)
const [multiDragOffsets, setMultiDragOffsets] = useState<Record<string, { x: number; y: number }>>({});
// 🔥 선택 박스 드래그 중 자동 스크롤
const lastMouseYForSelectionRef = React.useRef<number>(window.innerHeight / 2);
const selectionAutoScrollFrameRef = React.useRef<number | null>(null);
@ -70,6 +78,14 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
const gridConfig = useMemo(() => calculateGridConfig(canvasWidth), [canvasWidth]);
const cellSize = gridConfig.CELL_SIZE;
// 🔥 그리드 박스 시스템 - 12개 박스가 캔버스 너비에 꽉 차게
const verticalGuidelines = useMemo(() => calculateVerticalGuidelines(canvasWidth), [canvasWidth]);
const horizontalGuidelines = useMemo(
() => calculateHorizontalGuidelines(canvasHeight, canvasWidth),
[canvasHeight, canvasWidth],
);
const boxSize = useMemo(() => calculateBoxSize(canvasWidth), [canvasWidth]);
// 충돌 방지 기능이 포함된 업데이트 핸들러
const handleUpdateWithCollisionDetection = useCallback(
(id: string, updates: Partial<DashboardElement>) => {
@ -177,23 +193,13 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
const rawX = e.clientX - rect.left + (ref.current?.scrollLeft || 0);
const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0);
// 마그네틱 스냅 (큰 그리드 우선, 없으면 서브그리드)
const gridSize = cellSize + GRID_CONFIG.GAP; // GAP 포함한 실제 그리드 크기
const magneticThreshold = 15;
// 자석 스냅 적용
let snappedX = magneticSnap(rawX, verticalGuidelines);
let snappedY = magneticSnap(rawY, horizontalGuidelines);
// X 좌표 스냅
const nearestGridX = Math.round(rawX / gridSize) * gridSize;
const distToGridX = Math.abs(rawX - nearestGridX);
let snappedX = distToGridX <= magneticThreshold ? nearestGridX : Math.round(rawX / subGridSize) * subGridSize;
// Y 좌표 스냅
const nearestGridY = Math.round(rawY / gridSize) * gridSize;
const distToGridY = Math.abs(rawY - nearestGridY);
const snappedY =
distToGridY <= magneticThreshold ? nearestGridY : Math.round(rawY / subGridSize) * subGridSize;
// X 좌표가 캔버스 너비를 벗어나지 않도록 제한
const maxX = canvasWidth - cellSize * 2; // 최소 2칸 너비 보장
// X 좌표가 캔버스 너비를 벗어나지 않도록 제한 (최소 2칸 너비 보장)
const minElementWidth = cellSize * 2 + GRID_CONFIG.GAP;
const maxX = canvasWidth - minElementWidth;
snappedX = Math.max(0, Math.min(snappedX, maxX));
onCreateElement(dragData.type, dragData.subtype, snappedX, snappedY);
@ -201,7 +207,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
// 드롭 데이터 파싱 오류 무시
}
},
[ref, onCreateElement, canvasWidth, cellSize],
[ref, onCreateElement, canvasWidth, cellSize, verticalGuidelines, horizontalGuidelines],
);
// 🔥 선택 박스 드래그 시작
@ -210,14 +216,14 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
// 🔥 위젯 내부 클릭이 아닌 경우만 (data-element-id가 없는 경우)
const target = e.target as HTMLElement;
const isWidget = target.closest("[data-element-id]");
if (isWidget) {
// console.log("🚫 위젯 내부 클릭 - 선택 박스 시작 안함");
return;
}
// console.log("✅ 빈 공간 클릭 - 선택 박스 시작");
if (!ref || typeof ref === "function") return;
const rect = ref.current?.getBoundingClientRect();
if (!rect) return;
@ -274,20 +280,20 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
// 겹치는 영역의 넓이
const overlapArea = (overlapRight - overlapLeft) * (overlapBottom - overlapTop);
// 요소의 전체 넓이
const elementArea = el.size.width * el.size.height;
// 70% 이상 겹치면 선택
const overlapPercentage = overlapArea / elementArea;
// console.log(`📦 요소 ${el.id}:`, {
// position: el.position,
// size: el.size,
// overlapPercentage: (overlapPercentage * 100).toFixed(1) + "%",
// selected: overlapPercentage >= 0.7,
// });
return overlapPercentage >= 0.7;
})
.map((el) => el.id);
@ -327,9 +333,9 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
if (!isSelecting) {
const deltaX = Math.abs(x - selectionBox.startX);
const deltaY = Math.abs(y - selectionBox.startY);
// console.log("📏 이동 거리:", { deltaX, deltaY });
// 🔥 5px 이상 움직이면 선택 박스 활성화 (위젯 드래그와 구분)
if (deltaX > 5 || deltaY > 5) {
// console.log("🎯 선택 박스 활성화 (5px 이상 이동)");
@ -374,10 +380,10 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
const autoScrollLoop = (currentTime: number) => {
const viewportHeight = window.innerHeight;
const lastMouseY = lastMouseYForSelectionRef.current;
let shouldScroll = false;
let scrollDirection = 0;
if (lastMouseY < scrollThreshold) {
shouldScroll = true;
scrollDirection = -scrollSpeed;
@ -387,9 +393,9 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
scrollDirection = scrollSpeed;
// console.log("⬇️ 아래로 스크롤 (선택 박스):", { lastMouseY, boundary: viewportHeight - scrollThreshold });
}
const deltaTime = currentTime - lastTime;
if (shouldScroll && deltaTime >= 10) {
window.scrollBy(0, scrollDirection);
// console.log("✅ 스크롤 실행 (선택 박스):", { scrollDirection, deltaTime });
@ -418,7 +424,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
// console.log("🚫 방금 선택했거나 드래그 중이므로 클릭 이벤트 무시");
return;
}
if (e.target === e.currentTarget) {
// console.log("✅ 빈 공간 클릭 - 선택 해제");
onSelectElement(null);
@ -433,7 +439,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
// 동적 그리드 크기 계산
const cellWithGap = cellSize + GRID_CONFIG.GAP;
const gridSize = `${cellWithGap}px ${cellWithGap}px`;
// 서브그리드 크기 계산 (gridConfig에서 정확하게 계산된 값 사용)
const subGridSize = gridConfig.SUB_GRID_SIZE;
@ -443,7 +449,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
// 🔥 선택 박스 스타일 계산
const selectionBoxStyle = useMemo(() => {
if (!selectionBox) return null;
const minX = Math.min(selectionBox.startX, selectionBox.endX);
const maxX = Math.max(selectionBox.startX, selectionBox.endX);
const minY = Math.min(selectionBox.startY, selectionBox.endY);
@ -465,14 +471,6 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
backgroundColor,
height: `${canvasHeight}px`,
minHeight: `${canvasHeight}px`,
// 서브그리드 배경 (세밀한 점선)
backgroundImage: `
linear-gradient(rgba(59, 130, 246, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(59, 130, 246, 0.05) 1px, transparent 1px)
`,
backgroundSize: `${subGridSize}px ${subGridSize}px`,
backgroundPosition: "0 0",
backgroundRepeat: "repeat",
cursor: isSelecting ? "crosshair" : "default",
}}
onDragOver={handleDragOver}
@ -494,6 +492,23 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
}}
/>
))} */}
{/* 그리드 박스들 (12px 간격, 캔버스 너비에 꽉 차게) */}
{verticalGuidelines.map((x, xIdx) =>
horizontalGuidelines.map((y, yIdx) => (
<div
key={`grid-box-${xIdx}-${yIdx}`}
className="pointer-events-none absolute border"
style={{
left: `${x}px`,
top: `${y}px`,
width: `${boxSize}px`,
height: `${boxSize}px`,
borderColor: GRID_CONFIG.GUIDELINE_COLOR,
zIndex: 0,
}}
/>
)),
)}
{/* 배치된 요소들 렌더링 */}
{elements.length === 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center text-gray-400">
@ -513,6 +528,8 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
cellSize={cellSize}
subGridSize={subGridSize}
canvasWidth={canvasWidth}
verticalGuidelines={verticalGuidelines}
horizontalGuidelines={horizontalGuidelines}
onUpdate={handleUpdateWithCollisionDetection}
onUpdateMultiple={(updates) => {
// 🔥 여러 요소 동시 업데이트 (충돌 감지 건너뛰기)
@ -552,10 +569,9 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
}}
onRemove={onRemoveElement}
onSelect={onSelectElement}
onConfigure={onConfigureElement}
/>
))}
{/* 🔥 선택 박스 렌더링 */}
{selectionBox && selectionBoxStyle && (
<div

View File

@ -12,6 +12,13 @@ export const GRID_CONFIG = {
SNAP_THRESHOLD: 10, // 스냅 임계값 (px)
ELEMENT_PADDING: 4, // 요소 주위 여백 (px)
SUB_GRID_DIVISIONS: 5, // 각 그리드 칸을 5x5로 세분화 (세밀한 조정용)
// 가이드라인 시스템
GUIDELINE_SPACING: 12, // 가이드라인 간격 (px)
SNAP_DISTANCE: 10, // 자석 스냅 거리 (px)
GUIDELINE_COLOR: "rgba(59, 130, 246, 0.3)", // 가이드라인 색상
ROW_HEIGHT: 96, // 각 행의 높이 (12px * 8 = 96px)
GRID_BOX_SIZE: 40, // 그리드 박스 크기 (px) - [ ] 한 칸의 크기
GRID_BOX_GAP: 12, // 그리드 박스 간 간격 (px)
// CELL_SIZE와 CANVAS_WIDTH는 해상도에 따라 동적 계산
} as const;
@ -69,7 +76,7 @@ export const getCanvasWidth = () => {
export const snapToGrid = (value: number, subGridSize?: number): number => {
// 서브 그리드 크기가 지정되지 않으면 기본 그리드 크기의 1/3 사용 (3x3 서브그리드)
const snapSize = subGridSize ?? Math.floor(GRID_CONFIG.CELL_SIZE / 3);
// 서브 그리드 단위로 스냅
const gridIndex = Math.round(value / snapSize);
return gridIndex * snapSize;
@ -198,3 +205,62 @@ export const getNearbyGridLines = (value: number): number[] => {
export const isWithinSnapThreshold = (value: number, snapValue: number): boolean => {
return Math.abs(value - snapValue) <= GRID_CONFIG.SNAP_THRESHOLD;
};
// 박스 크기 계산 (캔버스 너비에 맞게)
export function calculateBoxSize(canvasWidth: number): number {
const totalGaps = 11 * GRID_CONFIG.GRID_BOX_GAP; // 12개 박스 사이 간격 11개
const availableWidth = canvasWidth - totalGaps;
return availableWidth / 12;
}
// 수직 그리드 박스 좌표 계산 (12개, 너비에 꽉 차게)
export function calculateVerticalGuidelines(canvasWidth: number): number[] {
const lines: number[] = [];
const boxSize = calculateBoxSize(canvasWidth);
for (let i = 0; i < 12; i++) {
const x = i * (boxSize + GRID_CONFIG.GRID_BOX_GAP);
lines.push(x);
}
return lines;
}
// 수평 그리드 박스 좌표 계산 (캔버스 너비 기준으로 정사각형 유지)
export function calculateHorizontalGuidelines(canvasHeight: number, canvasWidth: number): number[] {
const lines: number[] = [];
const boxSize = calculateBoxSize(canvasWidth); // 수직과 동일한 박스 크기 사용
const cellSize = boxSize + GRID_CONFIG.GRID_BOX_GAP;
for (let y = 0; y <= canvasHeight; y += cellSize) {
lines.push(y);
}
return lines;
}
// 가장 가까운 가이드라인 찾기
export function findNearestGuideline(
value: number,
guidelines: number[],
): {
nearest: number;
distance: number;
} {
let nearest = guidelines[0];
let minDistance = Math.abs(value - guidelines[0]);
for (const guideline of guidelines) {
const distance = Math.abs(value - guideline);
if (distance < minDistance) {
minDistance = distance;
nearest = guideline;
}
}
return { nearest, distance: minDistance };
}
// 자석 스냅 (10px 이내면 스냅)
export function magneticSnap(value: number, guidelines: number[]): number {
const { nearest, distance } = findNearestGuideline(value, guidelines);
return distance <= GRID_CONFIG.SNAP_DISTANCE ? nearest : value;
}