Skip to content

[FE] 대시보드 지표 카드 드래그앤드롭 편집 기능 구현#276

Open
lwjmcn wants to merge 16 commits intodevelopfrom
feature/#54-fe-dashboard-edit-draggable
Open

[FE] 대시보드 지표 카드 드래그앤드롭 편집 기능 구현#276
lwjmcn wants to merge 16 commits intodevelopfrom
feature/#54-fe-dashboard-edit-draggable

Conversation

@lwjmcn
Copy link
Collaborator

@lwjmcn lwjmcn commented Feb 15, 2026

#️⃣ 변경 사항

대시보드 카드 편집 기능에 드래그 앤 드롭(Drag & Drop) 시스템을 도입하고, 효율적인 카드 배치를 위한 상태 관리 방식을 리팩터링했습니다. 기존의 2차원 배열 기반 그리드 관리 방식에서 카드 배열 기반의 좌표 관리 방식으로 전환하였으며, 카드 간 충돌 시 자동으로 위치를 조정하는 밀어내기 알고리즘을 구현했습니다.

#️⃣ 작업 상세 내용

  • 드래그 앤 드롭(Drag & Drop) 핵심 로직 구현

    • useDragAndDropCard 커스텀 훅을 신설하여 드래그 시작, 드래그 오버, 드롭 등 전반적인 D&D 이벤트 핸들러 통합 관리
    • dragOver 이벤트에 스로틀링(Throttling)을 적용하여 성능 최적화
    • 카드 리스트에서 그리드로의 추가, 그리드 내 이동, 그리드에서 리스트로 드롭 시 삭제 기능 구현
  • 카드 밀어내기 재귀 알고리즘 구현

    • getPushedLayout: 드래그 중인 카드와 충돌하는 기존 카드들을 진입 방향에 따라 재귀적으로 밀어내는 알고리즘 구현
    • getPushDirectionPriority: 드래그 중인 카드의 중심점과 충돌 카드의 위치를 비교하여 밀어낼 방향의 우선순위 결정
  • 상태 관리 및 데이터 구조 리팩터링

    • EditCardProvider: 2차원 배열((MetricCardCode | null)[][]) 대신 카드 정보 객체 배열(DashboardCard[])로 상태 관리 방식 변경
    • EditCardContext: 드래그 상태(dragState), 고스트 상태(ghost), 임시 레이아웃(tempLayout) 관리를 위한 필드 추가 및 gridRef를 통한 좌표 계산 기반 마련
  • UI/UX 및 스타일링 개선

    • MiniViewGhost: 카드가 놓일 위치를 미리 보여주는 고스트 컴포넌트 추가 및 유효성(배치 가능 여부)에 따른 스타일링
    • useGridCellSize: 컨테이너 크기를 기반으로 각 그리드 셀의 픽셀 좌표와 크기를 계산하는 훅 구현
    • 이미지 드래그 방지 및 카드 드래그 시 텍스트 선택(select-none) 방지 처리
    • 메트릭 카드 이름 오타 수정 및 긴 텍스트 줄바꿈(break-keep) 스타일링 적용
  • 기타 개선 사항

    • PlusIconButton, TrashCanIconButton: 클릭 이벤트 전파 방지(stopPropagation) 처리
    • useEditCard 훅의 카드 추가/삭제 로직을 새로운 데이터 구조에 맞춰 수정

카드 밀어내기 알고리즘 플로우

플로우 차트 확인
    flowchart TD
        %% 최상단 시작점
        Start([시작]) --> DragEvent["'onDragOver' 이벤트 수신"]
    
        subgraph "1. 전처리 단계"
            DragEvent --> Throttle["'Throttle' 적용 (100ms)"]
            Throttle --> CalcPos["'calculateGridPos': 마우스 → 그리드 {row, col} 변환"]
            CalcPos --> GhostCheck{"'ghost' 위치가 변경되었는가?"}
        end
    
        GhostCheck -- "아니오" --> End([종료])
        GhostCheck -- "예" --> InitSim["시뮬레이션 레이아웃 구성<br/>(리스트 추가 시 새 카드 포함)"]
    
        subgraph "2. 재귀적 밀어내기 (getPushedLayout)"
            InitSim --> SetMoved["현재 카드를 'movedCards'에 추가"]
            SetMoved --> FindConflict["'getConflictingCards': 충돌 카드 검색"]
            FindConflict --> HasConflict{충돌 발생?}
            
            HasConflict -- "예" --> GetPriority["진입방향별 밀어낼 방향<br/>우선순위 결정<br/>({dx,dy}[]) getPushDirectionPriority"]
            GetPriority --> DirLoop["우선순위 방향 순회 시도"]
    
            %% 좌표 계산 로직 강조 섹션
            subgraph "좌표 계산 상세 (Next Position)"
                DirLoop --> CalcNext["'conflictNextX/Y' 계산 시작"]
                CalcNext --> XDir{dx 방향 확인}
                XDir -- "dx > 0 (우측으로 밀어내기)" --> SetNextX_R["'conflictNextX' = current.colNo + current.sizeX"]
                XDir -- "dx < 0 (좌측으로 밀어내기)" --> SetNextX_L["'conflictNextX' = current.colNo - conflict.sizeX"]
                XDir -- "dx = 0" --> YDir
                
                SetNextX_R --> YDir{dy 방향 확인}
                SetNextX_L --> YDir
                
                YDir -- "dy > 0 (하단으로 밀어내기)" --> SetNextY_D["'conflictNextY' = current.rowNo + current.sizeY"]
                YDir -- "dy < 0 (상단으로 밀어내기)" --> SetNextY_U["'conflictNextY' = current.rowNo - conflict.sizeY"]
                YDir -- "dy = 0" --> BoundaryCheck
                
                SetNextY_D --> BoundaryCheck
                SetNextY_U --> BoundaryCheck
            end
    
            BoundaryCheck{"그리드 경계 내에 있는가?<br/>(1 <= X < COL / 1 <= Y < ROW)"}
            
            BoundaryCheck -- "예" --> RecursiveCall["'getPushedLayout' 재귀 호출<br/>(변경된 좌표의 conflictCard 기준)"]
            
            RecursiveCall --> Success{결과 유효?}
            Success -- "예" --> FinalConflict["최종 충돌 여부 재확인"]
            
            %% 실패 시 루프 백
            Success -- "아니오" --> DirLoop
            BoundaryCheck -- "아니오" --> DirLoop
        end
    
        HasConflict -- "아니오" --> FinalConflict
        
        subgraph "3. 최종 반영"
            FinalConflict --> ApplyLayout["'setTempLayout(cards)' 반영"]
            ApplyLayout --> SetGhost["'setGhost(isValid)' 업데이트"]
        end
    
        SetGhost --> End
    
        %% 스타일링
        style Start fill:#f3f4f6,stroke:#374151,stroke-width:2px
        style End fill:#f3f4f6,stroke:#374151,stroke-width:2px
        style RecursiveCall fill:#eff6ff,stroke:#1d4ed8,stroke-dasharray: 5 5
        style CalcNext fill:#fff7ed,stroke:#c2410c,stroke-width:2px
Loading

드래그 이벤트 핸들러 동작 시퀀스

sequenceDiagram
    title "드래그앤드랍 & 밀어내기 상세 시퀀스"

    participant User as "사용자"
    participant CardList as "CardEditView (리스트)"
    participant MiniView as "MiniView (그리드)"
    participant Hook as "useDragAndDropCard (훅)"
    participant Context as "EditCardContext (상태)"
    participant PushAlgo as "getPushedLayout (알고리즘)"

    rect rgb(240, 255, 240)
    Note over User, CardList: [시작] 리스트 또는 그리드에서 드래그 시작
    alt "리스트에서 시작"
        User->>CardList: "'dragstart' (새 카드 선택)"
        CardList->>Hook: "'handleDragStart(e, LIST, card)'"
    else "그리드에서 시작"
        User->>MiniView: "'dragstart' (기존 카드 선택)"
        MiniView->>Hook: "'handleDragStart(e, GRID, card)'"
        Hook->>Context: "해당 카드의 'z-index' 낮춤 (숨김 처리)"
    end
    Hook->>Context: "'setDragState({ sourceArea, draggingCard, centerOffset })'"
    end

    loop "그리드 위에서 드래그 ('throttledHandleGridDragOver')"
        User->>MiniView: "'dragover' 이벤트 지속 발생"
        MiniView->>Hook: "'handleGridDragOver(e)'"
        Hook->>Hook: "'calculateGridPos' (마우스-셀 센터 비교)"
        
        Hook->>PushAlgo: "'getPushedLayout(currentLayout, ghostCandidate, ...)'"
        PushAlgo->>PushAlgo: "재귀적 충돌 계산 및 'PushPriority' 적용"
        PushAlgo-->>Hook: "결과: { cards, isValid }"
        
        Hook->>Context: "'setTempLayout(cards)', 'setGhost({row, col, isValid})'"
        Context-->>MiniView: "임시 레이아웃 및 'ghost' 시각화"
    end

    rect rgb(255, 245, 245)
    Note over User, CardList: [삭제 환경] 그리드 카드를 리스트로 드래그
    User->>CardList: "'dragenter' (리스트 영역 진입)"
    CardList->>Hook: "'handleListDragEnter()'"
    Hook->>Context: "'setIsOverList(true)' -> '삭제하기' 오버레이 표시"
    end

    alt "그리드에 드롭 (이동 또는 추가)"
        User->>MiniView: "'drop' 이벤트 발생"
        MiniView->>Hook: "'handleGridDrop(e)'"
        Hook->>Context: "조건: 'ghost.isValid' 이면 'setPlacedCards(tempLayout)'"
    else "리스트에 드롭 (삭제)"
        User->>CardList: "'drop' 이벤트 발생"
        CardList->>Hook: "'handleListDrop(e)'"
        Hook->>Context: "조건: 'sourceArea === GRID' 이면 'setPlacedCards'에서 필터링 삭제"
    end

    Note over Hook, Context: [종료] 모든 드래그 상태 초기화
    Hook->>Context: "'handleDragEnd()': dragState/ghost/tempLayout/isOverList 초기화"
Loading

리뷰 참고사항

  • 지난 PR과 비교해 크게 변경된 부분이 있어 따로 남깁니다.
  • 트랜지션 애니메이션을 위해 그리드 셀을 absolute로 두는 것으로 바꾸었습니다.
  • 2차원 행렬에서 충돌되는 카드를 확인하고 이를 재귀적으로 옮기는 데에 어려움이 있어, context에서 grid: MetricCardCode[][] 대신 placedCards: DashboardCard[]를 관리하는 방식으로 변경하였습니다.
  • 마우스 위치와 그리드 셀 위치 간 상관관계를 구하는 데 있어서, 그리드 셀 사이즈가 반응형이면 코드 복잡도가 너무 증가할 것 같아 그리드 셀 사이즈를 고정했습니다.

#️⃣ 관련 이슈

📸 스크린샷 (선택)

2026-02-16.3.58.09.mov

📎 참고할만한 자료 (선택)

위키 작성 중에 있습니다. 아주 러프하게 써두었는데 트러블슈팅 목록을 정리해두었으니 궁금하시면 참고해주세요.

- EditCardProvider에 드래그앤드랍 관련 상태 추가
- 대시보드 편집 영역 상수 및 타입 정의
- 대시보드 카드 타입에 드래그 상태 및 고스트 상태 추가
- 드래그앤드랍에서 충돌 로직을 계산할 때 grid 배열 시 코드가 복잡해지기 때문에 수정함
- 대시보드에서 카드의 위치와 크기를 계산하는 훅을 추가함
- 카드를 absolute 포지션으로 배치하고, 마우스 위치를 통해 그리드 셀을 도출해내는 로직을 위해 필요
- PlusIconButton과 TrashCanIconButton에서 클릭 이벤트가 부모 요소로 전파되지 않도록 수정
- 사용자 경험 개선을 위해 버튼 클릭 시 의도한 동작만 수행하도록 함
- 카드 밀어내기 알고리즘 구현
- 그리드 셀에 카드 위치 계산 기능 추가
- 드래그 이벤트 핸들러 구현
- dx, dy로 된 방향 객체 리터럴 선언
- 카드 편집 뷰에 드래그 앤 드롭 이벤트 핸들러 추가
- 미니 뷰에 드래그 앤 드롭 관련 상태 및 핸들러 통합
- 드래그 중인 카드의 시각적 피드백을 위한 Ghost 컴포넌트 추가
- 스로틀링을 통해 불필요한 함수 호출 방지
- 새로운 스로틀 유틸리티 함수 추가
@lwjmcn lwjmcn added the ✨ feat 새로운 기능이나 서비스 로직을 추가합니다. label Feb 15, 2026
Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

대시보드 카드 편집을 위한 드래그앤드롭 기능 구현을 확인했습니다. 2차원 배열로 관리되던 그리드 상태를 카드 객체 배열(placedCards)로 변경한 점은 데이터 모델링 관점에서 매우 훌륭한 리팩토링입니다. 이를 통해 상태 관리가 더 명확해지고 확장성이 개선되었습니다. 카드 밀어내기(push) 로직을 포함한 드래그앤드롭 구현은 복잡하지만, 관련 상태와 로직을 커스텀 훅(useDragAndDropCard, useGridCellSize)으로 분리하여 구조적으로 잘 설계되었습니다. 다만, useGridCellSize 훅에서 그리드의 크기를 하드코딩한 부분은 잠재적인 문제를 야기할 수 있어 개선이 필요합니다. 해당 부분에 대한 구체적인 피드백을 리뷰 코멘트로 남겼습니다.

Comment on lines +4 to +6
const GRID_HEIGHT_SIZE = 724;
const GRID_WIDTH_SIZE = 550;
const GRID_GAP_SIZE = 20;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

그리드 크기가 픽셀 단위로 하드코딩되어 있어 유지보수가 어렵고 잠재적인 버그를 유발할 수 있습니다. 예를 들어, MiniView.tsx 의 스타일이 변경되면 이 값들도 수동으로 업데이트해야 합니다.

EditCardContextgridRef 를 사용하여 실제 DOM 요소의 크기를 동적으로 읽어오는 것이 좋습니다. useEffectResizeObserver 를 사용하면 그리드 크기가 변경될 때마다 값을 업데이트하여 항상 정확한 크기를 유지할 수 있습니다.

// 제안 예시
const [gridPxSize, setGridPxSize] = useState({ width: 0, height: 0 });

useEffect(() => {
  const gridEl = gridRef.current;
  if (!gridEl) return;

  const resizeObserver = new ResizeObserver(([entry]) => {
    if (entry) {
      setGridPxSize({
        width: entry.contentRect.width,
        height: entry.contentRect.height,
      });
    }
  });

  resizeObserver.observe(gridEl);
  return () => resizeObserver.disconnect();
}, [gridRef]);

// 이후 계산에서 gridPxSize.width, gridPxSize.height 사용

@lwjmcn
Copy link
Collaborator Author

lwjmcn commented Feb 15, 2026

마우스 위치와 그리드 셀 위치 간 상관관계를 구하는 데 있어서, 그리드 셀 사이즈가 반응형이면 코드 복잡도가 너무 증가할 것 같아 그리드 셀 사이즈를 고정했습니다. 이 부분에 대한 의견 부탁 드립니다.

@lwjmcn
Copy link
Collaborator Author

lwjmcn commented Feb 15, 2026

백엔드 카드 위치 DTO가 rowNo, colNo인데 프론트엔드 메트릭 카드 상수에서 사이즈는 sizeX, sizeY여서 로직 상에 row/col과 x/y가 혼용되어 있습니다. 보기에 불편하지 않는지 의견 부탁 드립니다.

@lwjmcn
Copy link
Collaborator Author

lwjmcn commented Feb 15, 2026

메모이제이션 최적화가 적용되지 않은 상태입니다. 따라서 ghost 렌더링마다 전체 목록이 리렌더링 되고 있습니다. 이후에 이슈를 새로 파서 작업할 생각인데 지금 작업이 필요한지에 대해 의견 부탁 드립니다.

@lwjmcn lwjmcn self-assigned this Feb 15, 2026
Copy link
Collaborator

@lee0jae330 lee0jae330 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

복잡한 기능 구현하시느라 고생하셨습니다 !! 군데군데 주석처리된 부분 지워주세요 ! (디버깅용 console등)

Comment on lines +47 to +51
{isOverList && (
<div className="bg-others-negative text-grey-0 absolute top-0 right-0 bottom-0 left-0 flex items-center justify-center opacity-50">
DROP TO DELETE
</div>
)}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3: 카드 영역으로 드래그하면 삭제 처리 되는거 좋네요. UI가 아직은 빨간 배경에 DROP TO DELETE 문구만 있어서 따로 정해지면 좋을거 같아요

type MetricCardCode,
} from '@/constants/dashboard';
import { useEditCard } from '@/hooks/dashboard';
import { useDragAndDropCard } from '@/hooks/dashboard/useDragAndDropCard';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3: import 경로 단축해주세요 !

Comment on lines +23 to +38
<EditCardContext.Provider
value={{
initPlacedCards,
placedCards,
setPlacedCards,
gridRef,
dragState,
setDragState,
ghost,
setGhost,
tempLayout,
setTempLayout,
isOverList,
setIsOverList,
}}
>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3: 이젠 진짜 state Provider, action Provider로 나누어야 할 것 같네요..
useReducer를 사용해서 state provider랑 dispatch provider를 분리하면 좋을 것 같습니다
아니면 context를 쪼갠다거나...

Comment on lines +68 to +69
left: colPx,
top: rowPx,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3: transform: translate 속성으로 변경할 수 있으면 좋을것같네용

Comment on lines +29 to +30
left: colPx,
top: rowPx,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3: 여기도 translate 고려헤보시면 좋을것같습니다

const centerY = rowPx + heightPx / 2;

const dist = Math.sqrt(
Math.pow(draggingCenterX - centerX, 2) +
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p5: (draggingCenterX - centerX) ** 2 이렇게도 가능합니다..!

Comment on lines +283 to +290
const throttledHandleGridDragOver = throttle(handleGridDragOverFn, 100);
const handleGridDragOver = (e: React.DragEvent) => {
e.preventDefault();
const clientX = e.clientX;
const clientY = e.clientY;

throttledHandleGridDragOver(clientX, clientY);
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p3: 이렇게 되면 throttle를 매 렌더링마다 재실행해서 매번 새로운 throttled 인스턴스가 생길 것 같네요..!
useRef로 함수를 감싸거나 useCallback, useMemo를 사용하는게 좋을 것 같아요 !

개선해본 코드
import { useCallback, useEffect, useRef } from 'react';

import {
  DASHBOARD_EDIT_AREA,
  DASHBOARD_METRIC_CARDS,
  type DashboardEditArea,
  DIRECTIONS,
  GRID_COL_SIZE,
  GRID_ROW_SIZE,
} from '@/constants/dashboard';
import type { DashboardCard } from '@/types/dashboard';
import { getConflictingCards } from '@/utils/dashboard';
import { throttle } from '@/utils/shared';

import { useEditCardContext } from './useEditCardContext';
import { useGridCellSize } from './useGridCellSize';

export const useDragAndDropCard = () => {
  const {
    placedCards,
    setPlacedCards,
    gridRef,
    dragState,
    setDragState,
    ghost,
    setGhost,
    tempLayout,
    setTempLayout,
    setIsOverList,
  } = useEditCardContext();
  const { getGridPosition, getGridCardSize } = useGridCellSize();

  /**
   * 중심점 기준 진입 방향 계산 함수
   * @param draggingCenterX 드래그 중인 카드의 중심 X 좌표 (픽셀 단위)
   * @param draggingCenterY 드래그 중인 카드의 중심 Y 좌표 (픽셀 단위)
   * @param conflictCard 충돌한 카드 정보
   * @returns 진입 방향에 따른 밀어내기 우선순위 배열 [{dx, dy}, ...]
   */
  const getPushDirectionPriority = useCallback(
    (
      draggingCenterX: number,
      draggingCenterY: number,
      conflictCard: DashboardCard,
    ) => {
      // 충돌한 요소의 중심
      const conflictCardDef = DASHBOARD_METRIC_CARDS[conflictCard.cardCode];
      const { rowPx, colPx } = getGridPosition(
        conflictCard.rowNo,
        conflictCard.colNo,
      );
      const { widthPx, heightPx } = getGridCardSize(
        conflictCardDef.sizeX,
        conflictCardDef.sizeY,
      );
      const conflictCenterX = colPx + widthPx / 2;
      const conflictCenterY = rowPx + heightPx / 2;

      // 드래그 중인 카드의 중심과 충돌 카드의 중심을 비교하여 진입 방향 계산
      const dx = draggingCenterX - conflictCenterX;
      const dy = draggingCenterY - conflictCenterY;

      const absDx = Math.abs(dx) / conflictCardDef.sizeX;
      const absDy = Math.abs(dy) / conflictCardDef.sizeY;

      // 진입방향 반대를 밀어내기 우선순위로 설정
      const { LEFT, RIGHT, UP, DOWN } = DIRECTIONS;
      if (absDx > absDy) {
        if (dx > 0) {
          // 우측 진입 -> 좌로 밀기 우선
          return [LEFT, DOWN, UP, RIGHT];
        } else {
          // 좌측 진입 -> 우로 밀기 우선
          return [RIGHT, DOWN, UP, LEFT];
        }
      } else {
        if (dy > 0) {
          // 하단 진입 -> 위로 밀기 우선
          return [UP, RIGHT, LEFT, DOWN];
        } else {
          // 상단 진입 -> 아래로 밀기 우선
          return [DOWN, RIGHT, LEFT, UP];
        }
      }
    },
    [getGridPosition, getGridCardSize],
  );

  /**
   * 카드 밀어내기 재귀 알고리즘
   * @param currentLayout 현재까지 계산된 전체 카드 배치
   * @param currentCard 방금 이동시켜서 주변을 밀어내야 할 주체 카드
   * @param movedCards 한 번 이동한 적이 있는 카드의 코드 Set (무한 루프 방지)
   * @param draggingCenterX 드래그 중인 카드의 중심 X 좌표 (픽셀 단위)
   * @param draggingCenterY 드래그 중인 카드의 중심 Y 좌표 (픽셀 단위)
   * @returns 밀어내기 결과 레이아웃과 유효 여부 {cards, isValid}
   */
  const getPushedLayout = useCallback(
    (
      currentLayout: DashboardCard[],
      currentCard: DashboardCard,
      movedCards: Set<string> = new Set(),
      draggingCenterX: number,
      draggingCenterY: number,
    ): { cards: DashboardCard[]; isValid: boolean } => {
      // 현재 조작 중인 카드를 이동 완료 목록에 추가
      movedCards.add(currentCard.cardCode);

      // 현재 레이아웃에서 movedCard 위치 업데이트
      let nextLayout = currentLayout.map((card) =>
        card.cardCode === currentCard.cardCode ? currentCard : card,
      );

      // 충돌되는 카드 찾기
      const conflicts = getConflictingCards(currentCard, nextLayout);

      for (const conflictCard of conflicts) {
        // 진입방향에 따른 밀어냄 방향 우선순위
        const pushDirections = getPushDirectionPriority(
          draggingCenterX,
          draggingCenterY,
          conflictCard,
        );

        for (const { dx, dy } of pushDirections) {
          if (movedCards.has(conflictCard.cardCode)) {
            continue; // 이미 이동한 카드면 패스
          }

          // 충돌 카드의 다음 좌표 계산
          let conflictNextX = conflictCard.colNo;
          let conflictNextY = conflictCard.rowNo;

          const conflictCardDef = DASHBOARD_METRIC_CARDS[conflictCard.cardCode];
          const currentCardDef = DASHBOARD_METRIC_CARDS[currentCard.cardCode];

          if (dx > 0) {
            // 현재 카드의 우측으로 밀어냄
            conflictNextX = currentCard.colNo + currentCardDef.sizeX;
          } else if (dx < 0) {
            // 현재 카드의 좌측으로 밀어냄
            conflictNextX = currentCard.colNo - conflictCardDef.sizeX;
          }

          if (dy > 0) {
            // 현재 카드의 하단으로 밀어냄
            conflictNextY = currentCard.rowNo + currentCardDef.sizeY;
          } else if (dy < 0) {
            // 현재 카드의 상단으로 밀어냄
            conflictNextY = currentCard.rowNo - conflictCardDef.sizeY;
          }

          // 그리드 밖으로 나간다면 무효
          if (
            conflictNextX < 1 ||
            conflictNextX + conflictCardDef.sizeX - 1 > GRID_COL_SIZE ||
            conflictNextY < 1 ||
            conflictNextY + conflictCardDef.sizeY - 1 > GRID_ROW_SIZE
          ) {
            continue;
          }

          const movedConflictCard: DashboardCard = {
            cardCode: conflictCard.cardCode,
            colNo: conflictNextX,
            rowNo: conflictNextY,
          };

          // 충돌된 카드 위치가 변했을 때 재귀적으로 밀어내기
          const pushedResult = getPushedLayout(
            nextLayout,
            movedConflictCard,
            new Set(movedCards),
            draggingCenterX,
            draggingCenterY,
          );

          if (pushedResult.isValid) {
            nextLayout = pushedResult.cards;
            break; // 유효한 방향을 찾았으므로 다른 방향 시도 중단
          }
        }
      }

      // 최종 충돌 검사
      const isFinalConflict =
        getConflictingCards(currentCard, nextLayout).length > 0;

      return { cards: nextLayout, isValid: !isFinalConflict };
    },
    [getPushDirectionPriority],
  );

  /**
   * 현재 마우스 위치를 기반으로 카드가 가장 가까운 그리드 셀에 붙도록 좌표 계산
   * 카드의 크기를 고려하여, 카드의 중심이 가장 가까운 셀의 중심에 오도록 함
   * @param clientX 마우스의 X 좌표 (픽셀 단위)
   * @param clientY 마우스의 Y 좌표 (픽셀 단위)
   * @returns 계산된 그리드 좌표 {row, col}
   */
  const calculateGridPos = useCallback(
    (clientX: number, clientY: number) => {
      if (!gridRef.current || !dragState) {
        return { row: 0, col: 0 };
      }

      // 드래그 중인 카드의 크기 계산 (픽셀단위)
      const draggingCardDef =
        DASHBOARD_METRIC_CARDS[dragState.draggingCard.cardCode];
      const cardSizeX = draggingCardDef.sizeX;
      const cardSizeY = draggingCardDef.sizeY;
      const { widthPx, heightPx } = getGridCardSize(cardSizeX, cardSizeY);

      // 드래그 중인 카드의 중심점 (픽셀단위)
      const cardRect = gridRef.current.getBoundingClientRect();
      const draggingCenterX =
        clientX - cardRect.left - dragState.centerOffset.x;
      const draggingCenterY = clientY - cardRect.top - dragState.centerOffset.y;

      // 카드의 중심이 가장 가까운 셀의 중심에 오도록
      let minDistance = Infinity;
      let closest = { row: 0, col: 0 };
      // 각 그리드 셀마다 유클리드 거리 계산
      for (let r = 1; r <= GRID_ROW_SIZE - cardSizeY + 1; r++) {
        for (let c = 1; c <= GRID_COL_SIZE - cardSizeX + 1; c++) {
          // 그리드 셀의 중심 좌표
          const { rowPx, colPx } = getGridPosition(r, c);
          const centerX = colPx + widthPx / 2;
          const centerY = rowPx + heightPx / 2;

          const dist = Math.sqrt(
            Math.pow(draggingCenterX - centerX, 2) +
              Math.pow(draggingCenterY - centerY, 2),
          );
          if (dist < minDistance) {
            minDistance = dist;
            closest = { row: r, col: c };
          }
        }
      }
      return closest;
    },
    [dragState, gridRef, getGridPosition, getGridCardSize],
  );

  /*************** 이벤트 핸들러 ****************/

  const handleGridDragOverFn = useCallback(
    (clientX: number, clientY: number) => {
      // console.log('handleGridDragOver');

      if (!gridRef.current || !dragState) {
        return;
      }

      const { row, col } = calculateGridPos(clientX, clientY);

      // Ghost 위치가 변했을 때만 계산
      if (ghost?.colNo === col && ghost?.rowNo === row) {
        return;
      }

      // console.log('handleGridDragOver - new ghost');

      // 시뮬레이션을 위한 레이아웃 구성
      const ghostCard: DashboardCard = {
        cardCode: dragState.draggingCard.cardCode,
        colNo: col,
        rowNo: row,
      };
      const currentLayout =
        dragState.sourceArea === DASHBOARD_EDIT_AREA.LIST
          ? [...placedCards, ghostCard] // 리스트에서 새로 추가하는 경우
          : placedCards;

      // 드래그 중인 카드의 중심점 (픽셀단위)
      const rect = gridRef.current?.getBoundingClientRect();
      const draggingCenterX = clientX - rect.left - dragState.centerOffset.x;
      const draggingCenterY = clientY - rect.top - dragState.centerOffset.y;

      // 밀어내기 시뮬레이션 수행
      const pushedResult = getPushedLayout(
        currentLayout,
        ghostCard,
        new Set(),
        draggingCenterX,
        draggingCenterY,
      );

      // 밀어내기 결과 레이아웃 반영
      setTempLayout(pushedResult.cards);

      // ghost 유효 여부 결정
      setGhost({ rowNo: row, colNo: col, isValid: pushedResult.isValid });
    },
    [
      dragState,
      ghost,
      placedCards,
      setGhost,
      setTempLayout,
      calculateGridPos,
      getPushedLayout,
      gridRef,
    ],
  );

  const latestGridDragOver = useRef(handleGridDragOverFn);

  useEffect(() => {
    latestGridDragOver.current = handleGridDragOverFn;
  }, [handleGridDragOverFn]);

  const throttledHandleGridDragOver = useRef(
    throttle((x: number, y: number) => latestGridDragOver.current(x, y), 100),
  );

  const handleGridDragOver = (e: React.DragEvent) => {
    e.preventDefault();
    const clientX = e.clientX;
    const clientY = e.clientY;

    throttledHandleGridDragOver.current(clientX, clientY);
  };

  const handleGridDrop = (e: React.DragEvent) => {
    // console.log('handleGridDrop');
    e.preventDefault();

    if (ghost?.isValid && tempLayout) {
      // console.log('handleGridDrop - update grid');
      setPlacedCards(tempLayout);
    }
    handleDragEnd();
  };

  const handleGridDragLeave = (e: React.DragEvent) => {
    // console.log('handleGridDragLeave');
    if (!e.currentTarget.contains(e.relatedTarget as Node)) {
      // 영역 외부로 나갔을 때만 처리 (자식 요소로 이동할 때는 무시)
      // console.log('handleGridDragLeave - really');
      setGhost(null);
    }
  };

  const handleListDragEnter = () => {
    // console.log('handleListDragEnter');
    if (dragState?.sourceArea === DASHBOARD_EDIT_AREA.GRID) {
      // console.log('handleListDragEnter - really');
      setIsOverList(true);
    }
  };

  const handleListDragLeave = (e: React.DragEvent) => {
    // console.log('handleListDragLeave');
    if (!e.currentTarget.contains(e.relatedTarget as Node)) {
      // 영역 외부로 나갔을 때만 처리 (자식 요소로 이동할 때는 무시)
      // console.log('handleListDragLeave - really');
      setIsOverList(false);
    }
  };

  const handleListDrop = (e: React.DragEvent) => {
    // console.log('handleListDrop', { dragState });
    e.preventDefault();

    // 카드 삭제
    if (dragState?.sourceArea === DASHBOARD_EDIT_AREA.GRID) {
      setPlacedCards((prev) =>
        prev.filter((c) => c.cardCode !== dragState.draggingCard.cardCode),
      );
      // console.log('handleListDrop - remove card');
    }
    handleDragEnd();
  };

  const handleDragStart = (
    e: React.DragEvent,
    sourceArea: DashboardEditArea,
    draggingCard: DashboardCard,
  ) => {
    // console.log('handleDragStart');
    e.dataTransfer.effectAllowed = 'move';

    const cardRect = e.currentTarget.getBoundingClientRect();
    setDragState({
      sourceArea,
      draggingCard,
      centerOffset: {
        // 카드 중심에서 마우스 포인터까지의 상대 좌표 (픽셀 단위)
        x: e.clientX - cardRect.left - cardRect.width / 2,
        y: e.clientY - cardRect.top - cardRect.height / 2,
      },
    });
  };

  const handleDragEnd = () => {
    // console.log('handleDragEnd');
    // 드래그 상태 초기화
    setDragState(null);
    setGhost(null);
    setTempLayout(null);
    setIsOverList(false);
  };

  return {
    handleGridDragOver,
    handleGridDrop,
    handleGridDragLeave,
    handleListDragEnter,
    handleListDragLeave,
    handleListDrop,
    handleDragStart,
    handleDragEnd,
  };
};

Comment on lines +305 to +310
if (!e.currentTarget.contains(e.relatedTarget as Node)) {
// 영역 외부로 나갔을 때만 처리 (자식 요소로 이동할 때는 무시)
// console.log('handleGridDragLeave - really');
setGhost(null);
}
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p4: 사파리에서는 relatedTarget이 undefined로 정의되어 있는 문제가 있습니다..!
나중에 크로스브라우징 이슈가 있다면 참고하면 좋을 것 같습니다 !
(참고)

Comment on lines +36 to +54
export const isSameGrid = (
placedCards1: DashboardCard[],
placedCards2: DashboardCard[],
): boolean => {
if (placedCards1.length !== placedCards2.length) {
return false;
}

const cardMap1 = new Map<string, DashboardCard>();
const cardMap2 = new Map<string, DashboardCard>();

placedCards1.forEach((card) => cardMap1.set(card.cardCode, card));
placedCards2.forEach((card) => cardMap2.set(card.cardCode, card));

for (const [cardCode, card1] of cardMap1.entries()) {
const card2 = cardMap2.get(cardCode);
if (!card2 || card1.rowNo !== card2.rowNo || card1.colNo !== card2.colNo) {
return false;
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p4: 변수명이.... 구체적으로 바꿔주시면 감사하겠습니다..!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

p4: throttle 함수 좋네요 !
Q. 디바운스랑 쓰로틀 차이점은 !?

+중간에 fn.apply(this, args)는 왜 해주는지 궁금합니다 !!

@lee0jae330
Copy link
Collaborator

마우스 위치와 그리드 셀 위치 간 상관관계를 구하는 데 있어서, 그리드 셀 사이즈가 반응형이면 코드 복잡도가 너무 증가할 것 같아 그리드 셀 사이즈를 고정했습니다. 이 부분에 대한 의견 부탁 드립니다.

이 부분은 좋은데, 브라우저 너비가 작아지면, overflow-hidden처리는 문제가 있어서 해상도 가드를 적용하거나 overflow-scroll이 되어야할 것 같네요

@lee0jae330
Copy link
Collaborator

메모이제이션 최적화가 적용되지 않은 상태입니다. 따라서 ghost 렌더링마다 전체 목록이 리렌더링 되고 있습니다. 이후에 이슈를 새로 파서 작업할 생각인데 지금 작업이 필요한지에 대해 의견 부탁 드립니다.

최적화가 되면 좋을 것 같긴 하네요...
심각한 성능저하가 있을까요 ??

@lee0jae330
Copy link
Collaborator

백엔드 카드 위치 DTO가 rowNo, colNo인데 프론트엔드 메트릭 카드 상수에서 사이즈는 sizeX, sizeY여서 로직 상에 row/col과 x/y가 혼용되어 있습니다. 보기에 불편하지 않는지 의견 부탁 드립니다.

이건 추후 맞추는것으로 할까요 ?? 기능 구현부터 해야할 것 같네요....

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ feat 새로운 기능이나 서비스 로직을 추가합니다.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FE] 3-3-5. 대시보드 미니뷰 편집

2 participants