diff --git a/frontend/src/constants/mouse.ts b/frontend/src/constants/mouse.ts index b2658e903..51a904190 100644 --- a/frontend/src/constants/mouse.ts +++ b/frontend/src/constants/mouse.ts @@ -1,4 +1,5 @@ export const MOUSE_DOWN = { left: 0, + wheel: 1, right: 2, }; diff --git a/frontend/src/features/mindmap/components/DragGhostStyle.tsx b/frontend/src/features/mindmap/components/DragGhostStyle.tsx new file mode 100644 index 000000000..a39a62962 --- /dev/null +++ b/frontend/src/features/mindmap/components/DragGhostStyle.tsx @@ -0,0 +1,22 @@ +import { useMindMapDragSession } from "@/features/mindmap/hooks/useMindmapContext"; +/** + * 드래그 세션(start/end)에서만 style 텍스트가 바뀌도록 분리 + * - 원본(static-graph)만 흐리게 처리 + * - moving-fragment는 영향 받지 않음 + */ +export default function DragGhostStyle() { + const { isDragging, dragSubtreeIds } = useMindMapDragSession(); + + if (!isDragging || !dragSubtreeIds || dragSubtreeIds.size === 0) return null; + + const escapeAttr = (v: string) => v.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); + + const rules: string[] = []; + dragSubtreeIds.forEach((id) => { + const safe = escapeAttr(String(id)); + rules.push(`.static-graph [data-node-id="${safe}"] { opacity: 0.2; }`); + rules.push(`.static-graph path[data-edge-to="${safe}"] { opacity: 0.2; }`); + }); + + return ; +} diff --git a/frontend/src/features/mindmap/components/DropIndicator.tsx b/frontend/src/features/mindmap/components/DropIndicator.tsx new file mode 100644 index 000000000..0810c363b --- /dev/null +++ b/frontend/src/features/mindmap/components/DropIndicator.tsx @@ -0,0 +1,129 @@ +import { edgeVariants } from "@/features/mindmap/components/EdgeLayer"; +import TempNode, { TEMP_NODE_SIZE } from "@/features/mindmap/node/components/temp_node/TempNode"; +import { NodeDirection, NodeElement, NodeId } from "@/features/mindmap/types/node"; +import { getContentBounds } from "@/features/mindmap/utils/node_geometry"; +import { getBezierPath } from "@/features/mindmap/utils/path"; + +type DropIndicatorProps = { + targetId: NodeId; + direction: NodeDirection; + nodeMap: Map; +}; + +const GHOST_GAP_X = 100; +const SIBLING_GAP_Y = 16; +const DEFAULT_NODE_WIDTH = 200; +const DEFAULT_NODE_HEIGHT = 60; + +export default function DropIndicator({ targetId, direction, nodeMap }: DropIndicatorProps) { + const targetNode = nodeMap.get(targetId); + if (!targetNode || !direction) return null; + + const targetWidth = targetNode.width || DEFAULT_NODE_WIDTH; + const targetHeight = targetNode.height || DEFAULT_NODE_HEIGHT; + + const ghostWidth = TEMP_NODE_SIZE.ghost.width; + const ghostHeight = TEMP_NODE_SIZE.ghost.height; + + let ghostX = targetNode.x; + let ghostY = targetNode.y; + + /** + * 엣지 출발은 "부모" + * - child: 부모 = targetNode + * - prev/next: 부모 = targetNode.parent + */ + const parentNode = + direction === "child" ? targetNode : targetNode.parentId ? nodeMap.get(targetNode.parentId) : undefined; + + // 브랜치 방향(좌/우)은 target의 addNodeDirection 기준이 안전(루트 자식도 포함) + const branchSide = targetNode.type === "root" ? "right" : targetNode.addNodeDirection; + + switch (direction) { + case "child": { + // NOTE: root 위 드롭 시 좌/우는 현재 로직상 무조건 right. + // 요구사항대로면 InteractionManager가 mouseX로 left/right 결정해서 내려줘야 함. + const side = targetNode.type === "root" ? "right" : targetNode.addNodeDirection; + + ghostX = + side === "right" + ? targetNode.x + targetWidth / 2 + GHOST_GAP_X + ghostWidth / 2 + : targetNode.x - targetWidth / 2 - GHOST_GAP_X - ghostWidth / 2; + + ghostY = targetNode.y; + break; + } + + case "prev": { + /** + * "형제 사이 중앙" 계산 + * prev면: prevSibling.bottom ~ target.top 사이 중앙에 ghost center 배치 + */ + const prevSibling = targetNode.prevId ? nodeMap.get(targetNode.prevId) : undefined; + + if (prevSibling) { + const prevH = prevSibling.height || 60; + const prevBottom = prevSibling.y + prevH / 2; + + const targetTop = targetNode.y - targetHeight / 2; + + ghostY = (prevBottom + targetTop) / 2; + } else { + // 첫 번째 자식의 prev: 위로 yGap만큼 띄우는 규칙 + ghostY = targetNode.y - targetHeight / 2 - SIBLING_GAP_Y - ghostHeight / 2; + } + break; + } + + case "next": { + /** + * next면: target.bottom ~ nextSibling.top 사이 중앙 + */ + const nextSibling = targetNode.nextId ? nodeMap.get(targetNode.nextId) : undefined; + + if (nextSibling) { + const nextH = nextSibling.height || 60; + const nextTop = nextSibling.y - nextH / 2; + + const targetBottom = targetNode.y + targetHeight / 2; + + ghostY = (targetBottom + nextTop) / 2; + } else { + // 마지막 자식의 next + ghostY = targetNode.y + targetHeight / 2 + SIBLING_GAP_Y + ghostHeight / 2; + } + break; + } + } + + /** + * ghost edge도 content wall 기준으로 + * - start: parent.content wall + * - end: ghost box에서 parent를 향하는 벽 + */ + if (!parentNode) return null; + + const parentBounds = getContentBounds(parentNode); + const isRightBranch = branchSide === "right"; + + const startX = isRightBranch ? parentBounds.right : parentBounds.left; + const startY = parentNode.y; + + // ghost의 "부모 방향" 벽 + const endX = isRightBranch ? ghostX - ghostWidth / 2 : ghostX + ghostWidth / 2; + const endY = ghostY; + + const pathData = getBezierPath(startX, startY, endX, endY); + + return ( + + + + + + + + + + ); +} diff --git a/frontend/src/features/mindmap/components/EdgeLayer.tsx b/frontend/src/features/mindmap/components/EdgeLayer.tsx new file mode 100644 index 000000000..05e602c25 --- /dev/null +++ b/frontend/src/features/mindmap/components/EdgeLayer.tsx @@ -0,0 +1,61 @@ +import { cva, VariantProps } from "class-variance-authority"; + +import { NodeColor } from "@/features/mindmap/node/constants/colors"; +import { NodeElement, NodeId } from "@/features/mindmap/types/node"; +import { getParentChildEdgeAnchors } from "@/features/mindmap/utils/node_geometry"; +import { getBezierPath } from "@/features/mindmap/utils/path"; +import { cn } from "@/utils/cn"; + +type EdgeLayerProps = { + nodeMap: Map; + filterNode: NodeElement[]; //이번 레이어에서 그릴 노드 ID(선명하게, 투명하게 그려야 하는 경우가 나뉨) + color?: NodeColor; + type?: "active" | "ghost"; +} & VariantProps; + +export const edgeVariants = cva("fill-none", { + variants: { + type: { + active: "stroke", + ghost: "stroke-2 stroke-node-blue-op-100 [stroke-dasharray:8_4]", + }, + color: { + violet: "stroke-node-violet-op-100", + blue: "stroke-node-blue-op-100", + skyblue: "stroke-node-skyblue-op-100", + mint: "stroke-node-mint-op-100", + cyan: "stroke-node-cyan-op-100", + purple: "stroke-node-purple-op-100", + magenta: "stroke-node-magenta-op-100", + navy: "stroke-node-navy-op-100", + }, + }, +}); + +export default function EdgeLayer({ nodeMap, color, type = "active", filterNode }: EdgeLayerProps) { + return ( + + {filterNode.map((node) => { + if (!node.parentId || node.parentId === "empty") return null; + + const parent = nodeMap.get(node.parentId); + if (!parent) return null; + + const { start, end } = getParentChildEdgeAnchors(parent, node); + + const pathD = getBezierPath(start.x, start.y, end.x, end.y); + + return ( + + + + ); + })} + + ); +} diff --git a/frontend/src/features/mindmap/components/InteractionLayer.tsx b/frontend/src/features/mindmap/components/InteractionLayer.tsx new file mode 100644 index 000000000..968731f75 --- /dev/null +++ b/frontend/src/features/mindmap/components/InteractionLayer.tsx @@ -0,0 +1,24 @@ +import DropIndicator from "@/features/mindmap/components/DropIndicator"; +import MovingNodeFragment from "@/features/mindmap/components/MovingNodeFragment"; +import { InteractionSnapshot } from "@/features/mindmap/types/interaction"; +import { NodeElement, NodeId } from "@/features/mindmap/types/node"; + +type InteractionLayerProps = { + nodeMap: Map; + status: InteractionSnapshot; +}; +export default function InteractionLayer({ nodeMap, status }: InteractionLayerProps) { + const { mode, draggingNodeId, dragDelta, dragSubtreeIds, baseNode } = status; + + if (mode !== "dragging" || !draggingNodeId || !dragSubtreeIds) return null; + + return ( + + {baseNode.targetId && baseNode.direction && ( + + )} + + + + ); +} diff --git a/frontend/src/features/mindmap/components/MindMapRenderer.tsx b/frontend/src/features/mindmap/components/MindMapRenderer.tsx new file mode 100644 index 000000000..e919b6acf --- /dev/null +++ b/frontend/src/features/mindmap/components/MindMapRenderer.tsx @@ -0,0 +1,61 @@ +import { useEffect, useRef } from "react"; + +import DragGhostStyle from "@/features/mindmap/components/DragGhostStyle"; +import InteractionLayer from "@/features/mindmap/components/InteractionLayer"; +import StaticLayer from "@/features/mindmap/components/StaticLayer"; +import { + useMindMapCore, + useMindMapInteractionFrame, + useMindMapVersion, +} from "@/features/mindmap/hooks/useMindmapContext"; +import { useViewportEvents } from "@/features/mindmap/hooks/useViewportEvents"; +import { NodeElement, NodeId } from "@/features/mindmap/types/node"; + +/** + * interaction 프레임은 이 컴포넌트만 구독해서, movingFragment만 리렌더되도록 분리 + */ +function InteractionOverlay({ + nodeMap, + rootRef, +}: { + nodeMap: Map; + rootRef: React.RefObject; +}) { + const status = useMindMapInteractionFrame(); + + // 부모로부터 받은 ref를 사용하여 해당 인스턴스의 DOM만 조작 + useEffect(() => { + const root = rootRef.current; + if (root) { + root.setAttribute("data-dragging", status.mode === "dragging" ? "true" : "false"); + } + }, [status.mode, rootRef]); + + return ; +} + +function MindMapInnerRenderer() { + const mindmap = useMindMapCore(); + const version = useMindMapVersion(); + const rootRef = useRef(null); + + if (!mindmap) return null; + useViewportEvents(); + + const nodeMap = mindmap.tree.nodes; + + return ( + + + + + + ); +} + +export default function MindMapRenderer() { + const mindmap = useMindMapCore(); + + if (!mindmap || !mindmap.getIsReady()) return null; + return ; +} diff --git a/frontend/src/features/mindmap/components/MindmapNavigationBar.tsx b/frontend/src/features/mindmap/components/MindmapNavigationBar.tsx new file mode 100644 index 000000000..6ff0e1740 --- /dev/null +++ b/frontend/src/features/mindmap/components/MindmapNavigationBar.tsx @@ -0,0 +1,3 @@ +export default function MindmapNavigationBar() { + return
MindmapNavigationBar
; +} diff --git a/frontend/src/features/mindmap/components/MovingNodeFragment.tsx b/frontend/src/features/mindmap/components/MovingNodeFragment.tsx new file mode 100644 index 000000000..4df0d0404 --- /dev/null +++ b/frontend/src/features/mindmap/components/MovingNodeFragment.tsx @@ -0,0 +1,47 @@ +import { useMemo } from "react"; + +import EdgeLayer from "@/features/mindmap/components/EdgeLayer"; +import NodeItem from "@/features/mindmap/node/components/node/NodeItem"; +import { NodeElement, NodeId } from "@/features/mindmap/types/node"; + +/** + * nodeMap: 전체 맵 + * filterIds : 마우스에 붙어 움직일 노드 Ids + * delta : 이동량 + */ +type MovingNodeFragmentProps = { + nodeMap: Map; + filterIds: Set; // + delta: { x: number; y: number }; +}; + +/** 마우스로 잡고 노드를 움직일때 마우스에 따라다니는 덩어리 노드들 */ +export default function MovingNodeFragment({ filterIds, nodeMap, delta }: MovingNodeFragmentProps) { + // 전체 nodeMap 중 movingHead ~ 자식 노드를 하나의 덩어리화, 맵 내용이 변경될때만 fragment 다시 그림 + const { fragmentNodes, fragmentMap } = useMemo(() => { + const map = new Map(); + + // 드래그 중인 노드들만 맵에 담기 + filterIds.forEach((id) => { + const node = nodeMap.get(id); + if (node) { + map.set(id, node); + } + }); + + // 엣지가 그릴 대상을 '부모가 같은 드래그 그룹 안에 있는 경우'로 제한 + const nodesForEdge = Array.from(map.values()).filter((node) => map.has(node.parentId)); + + return { fragmentNodes: nodesForEdge, fragmentMap: map }; + }, [filterIds, nodeMap]); + + return ( + + + + {Array.from(filterIds).map((id) => ( + + ))} + + ); +} diff --git a/frontend/src/features/mindmap/components/StaticLayer.tsx b/frontend/src/features/mindmap/components/StaticLayer.tsx new file mode 100644 index 000000000..bf5b9aa7f --- /dev/null +++ b/frontend/src/features/mindmap/components/StaticLayer.tsx @@ -0,0 +1,20 @@ +import EdgeLayer from "@/features/mindmap/components/EdgeLayer"; +import NodeItem from "@/features/mindmap/node/components/node/NodeItem"; +import { NodeElement, NodeId } from "@/features/mindmap/types/node"; + +type StaticLayerProps = { + nodeMap: Map; +}; + +export default function StaticLayer({ nodeMap }: StaticLayerProps) { + const allNodes = Array.from(nodeMap.values()); + + return ( + + + {allNodes.map((node) => ( + + ))} + + ); +} diff --git a/frontend/src/features/mindmap/core/InteractionManager.ts b/frontend/src/features/mindmap/core/InteractionManager.ts new file mode 100644 index 000000000..42f3d5152 --- /dev/null +++ b/frontend/src/features/mindmap/core/InteractionManager.ts @@ -0,0 +1,462 @@ +import { MOUSE_DOWN } from "@/constants/mouse"; +import QuadTree from "@/features/mindmap/core/QuadTree"; +import TreeContainer from "@/features/mindmap/core/TreeContainer"; +import { MindMapEvents } from "@/features/mindmap/types/events"; +import { + BaseNodeInfo, + DragSessionSnapshot, + EMPTY_DRAG_SESSION_SNAPSHOT, + EMPTY_INTERACTION_SNAPSHOT, + InteractionMode, + InteractionSnapshot, +} from "@/features/mindmap/types/interaction"; +import { NodeDirection, NodeElement, NodeId } from "@/features/mindmap/types/node"; +import { calcDistance } from "@/utils/calc_distance"; +import { EventBroker } from "@/utils/EventBroker"; + +const DRAG_THRESHOLD = 5; + +export class MindmapInteractionManager { + // 상태 변수 + private mode: InteractionMode = "idle"; + private startMousePos = { x: 0, y: 0 }; + private lastMousePos = { x: 0, y: 0 }; + private draggingNodeId: NodeId | null = null; + private dragDelta = { x: 0, y: 0 }; + private mousePos = { x: 0, y: 0 }; + private dragSubtreeIds: Set | null = null; + private baseNode: BaseNodeInfo = { + targetId: null, + direction: null, + }; + private selectedNodeId: NodeId | null = null; + + // 프레임 스냅샷을 캐시하고, emit 시 교체 + private interactionSnapshot: InteractionSnapshot = EMPTY_INTERACTION_SNAPSHOT; + private dragSessionSnapshot: DragSessionSnapshot = EMPTY_DRAG_SESSION_SNAPSHOT; + + constructor( + private broker: EventBroker, + private container: TreeContainer, + private quadTree: QuadTree, + private onPan: (dx: number, dy: number) => void, + private onMoveNode: (targetId: NodeId, movingId: NodeId, direction: NodeDirection) => void, + private screenToWorld: (x: number, y: number) => { x: number; y: number }, + private deleteNode: (id: NodeId) => void, + ) { + this.commitInteractionSnapshot(); + this.commitDragSessionSnapshot(); + this.setupEventListeners(); + } + + private setupEventListeners() { + this.broker.subscribe({ + key: "NODE_CLICK", + callback: ({ nodeId, event }) => { + this.selectedNodeId = this.selectedNodeId === nodeId ? null : nodeId; + if (this.selectedNodeId !== null) { + this.handleNodeClick(nodeId, event as React.MouseEvent); + } + }, + }); + + this.broker.subscribe({ + key: "RAW_MOUSE_DOWN", + callback: (e) => { + this.selectedNodeId = null; + this.handleMouseDown(e as React.MouseEvent); + }, + }); + this.broker.subscribe({ key: "RAW_MOUSE_MOVE", callback: (e) => this.handleMouseMove(e as React.MouseEvent) }); + this.broker.subscribe({ key: "RAW_MOUSE_UP", callback: (e) => this.handleMouseUp(e as React.MouseEvent) }); + + this.broker.subscribe({ + key: "NODE_DELETE", + callback: () => { + if (this.selectedNodeId) { + this.deleteNode(this.selectedNodeId); + this.selectedNodeId = null; + } + }, + }); + } + + private projectScreenToWorld(clientX: number, clientY: number) { + return this.screenToWorld(clientX, clientY); + } + + /** + * 드롭 타겟 탐색 + * 기준점: 현재 마우스의 World 좌표(mouseX/mouseY) + * 탐색 범위: World 단위 threshold (줌이 바뀌어도 월드 반경은 동일) + */ + private updateDropTarget(e: React.MouseEvent) { + // 드래그 중이거나 노드 생성 중일 때만 타겟 계산 수행 + if (this.mode !== "dragging" && this.mode !== "pending_creation") { + return; + } + + // 1) 월드 좌표 계산 + const { x: mouseX, y: mouseY } = this.projectScreenToWorld(e.clientX, e.clientY); + + // 2) movingFragment(드래그 서브트리) 제외 헬퍼 + const isDragging = this.mode === "dragging"; + const isExcluded = (id: NodeId) => { + return isDragging && !!this.dragSubtreeIds && this.dragSubtreeIds.has(id); + }; + + const scopeSteps: Array<{ + depth: number; + parentId: NodeId; + side: "left" | "right"; + parentWallX: number; + outerWallX: number; + isBeyondOuterWall: boolean; + childrenCount: number; + chosenNextParentId?: NodeId; + }> = []; + + // 3) parent 스코프 결정 (X 밴드 기반 재귀 하강) + let parentNode: NodeElement = this.container.getRootNode(); + + // root는 좌/우 branch 먼저 결정 + let side: "left" | "right" = mouseX < parentNode.x ? "left" : "right"; + + // 무한 루프 방지 + let depthGuard = 0; + + while (depthGuard++ < 20) { + // 현재 parent의 children + let childrenForBand = this.container.getChildNodes(parentNode.id); + + // root면 side에 해당하는 그룹만 + if (parentNode.type === "root") { + childrenForBand = childrenForBand.filter((c) => c.addNodeDirection === side); + } + + // movingFragment(드래그 서브트리) 제외 + childrenForBand = childrenForBand.filter((c) => !isExcluded(c.id)); + + // 자식이 없으면 더 내려갈 수 없음 → parent 확정 + if (childrenForBand.length === 0) { + const parentWallX = + side === "right" ? parentNode.x + parentNode.width / 2 : parentNode.x - parentNode.width / 2; + + scopeSteps.push({ + depth: depthGuard, + parentId: parentNode.id, + side, + parentWallX, + outerWallX: parentWallX, + isBeyondOuterWall: false, + childrenCount: 0, + }); + break; + } + + // parent wall (자식이 붙는 방향의 벽) + const parentWallX = + side === "right" ? parentNode.x + parentNode.width / 2 : parentNode.x - parentNode.width / 2; + + // outer wall (자식 컬럼 바깥쪽 벽) = 자식들의 "바깥쪽" x 경계 중 최외곽 + let outerWallX = parentWallX; + + for (const child of childrenForBand) { + const childEdgeX = side === "right" ? child.x + child.width / 2 : child.x - child.width / 2; + + outerWallX = side === "right" ? Math.max(outerWallX, childEdgeX) : Math.min(outerWallX, childEdgeX); + } + + // band 밖(더 바깥)인지 판단 + const isBeyondOuterWall = side === "right" ? mouseX > outerWallX : mouseX < outerWallX; + + scopeSteps.push({ + depth: depthGuard, + parentId: parentNode.id, + side, + parentWallX, + outerWallX, + isBeyondOuterWall, + childrenCount: childrenForBand.length, + }); + + // band 안이면 parent 확정 + if (!isBeyondOuterWall) { + break; + } + + // band 밖이면 → Y 기준으로 가장 가까운 child로 하강 (O(k)) + let nextParent: NodeElement = childrenForBand[0]!; + let minYDist = Math.abs(mouseY - nextParent.y); + + for (let i = 1; i < childrenForBand.length; i++) { + const c = childrenForBand[i]!; + const d = Math.abs(mouseY - c.y); + + // tie면 아래(below, y가 큰 쪽) 선택 = 네 규칙(기아 선택) + if (d < minYDist || (d === minYDist && c.y > nextParent.y)) { + nextParent = c; + minYDist = d; + } + } + + // 스텝에 선택 결과 기록 + scopeSteps[scopeSteps.length - 1]!.chosenNextParentId = nextParent.id; + + // 안전장치: 자기 자신/자손으로 하강 방지 (이미 childrenForBand에서 제외했지만 2중 안전) + if (isExcluded(nextParent.id)) { + break; + } + + parentNode = nextParent; + + // 다음 레벨 side 갱신 + if (parentNode.type === "root") { + side = mouseX < parentNode.x ? "left" : "right"; + } else { + side = parentNode.addNodeDirection; + } + } + + // 최종 parentNode의 children을 기준으로 "삽입 위치(prev/next/child)" 결정 + let children = this.container.getChildNodes(parentNode.id); + + if (parentNode.type === "root") { + children = children.filter((c) => c.addNodeDirection === side); + } + + children = children.filter((c) => !isExcluded(c.id)); + + // leaf면 무조건 child + if (children.length === 0) { + this.baseNode = { + targetId: parentNode.id, + direction: "child", + }; + return; + } + + const ordered = [...children].sort((a, b) => a.y - b.y); + + // insertIndex = mouseY보다 y가 큰 첫 원소의 index + // (mouseY === child.y면 "next로 보자" 규칙에 맞게, equal은 건너뛰도록 mouseY < 로 비교) + let insertIndex = -1; + for (let i = 0; i < ordered.length; i++) { + if (mouseY < ordered[i]!.y) { + insertIndex = i; + break; + } + } + if (insertIndex === -1) insertIndex = ordered.length; + + // 맨 위: first.prev + if (insertIndex <= 0) { + const first = ordered[0]!; + this.baseNode = { + targetId: first.id, + direction: "prev", + }; + return; + } + + // 맨 아래: last.next + if (insertIndex >= ordered.length) { + const last = ordered[ordered.length - 1]!; + this.baseNode = { + targetId: last.id, + direction: "next", + }; + return; + } + + // 중간: ref.prev (ref 앞) + const ref = ordered[insertIndex]!; + this.baseNode = { + targetId: ref.id, + direction: "prev", + }; + } + + /** Interaction 상태 리셋 */ + private clearStatus() { + this.mode = "idle"; + this.draggingNodeId = null; + this.dragDelta = { x: 0, y: 0 }; + this.dragSubtreeIds = null; + this.baseNode = { targetId: null, direction: null }; + } + + /** 노드 클릭 */ + // TODO: 노드 리액트 컴포넌트에서 마우스 누를때 e.stopPropabation 추가 필수 + private handleNodeClick = (nodeId: NodeId, e: React.MouseEvent) => { + const node = this.container.safeGetNode(nodeId); + if (!node || node.type === "root") return; + + this.draggingNodeId = nodeId; + this.mode = "potential_drag"; + this.startMousePos = { x: e.clientX, y: e.clientY }; + this.lastMousePos = { x: e.clientX, y: e.clientY }; + this.dragDelta = { x: 0, y: 0 }; + }; + + /** 배경 클릭 */ + private handleMouseDown = (e: React.MouseEvent) => { + const isPanningButton = + e.button === MOUSE_DOWN.left || e.button === MOUSE_DOWN.wheel || e.button === MOUSE_DOWN.right; + + if (!isPanningButton) return; + + this.startMousePos = { x: e.clientX, y: e.clientY }; + this.lastMousePos = { x: e.clientX, y: e.clientY }; + this.mode = "panning"; + }; + + private handleMouseMove(e: React.MouseEvent): { dx: number; dy: number } { + const clientX = e.clientX; + const clientY = e.clientY; + + // 이전 프레임 좌표와 현재 좌표의 차이 + const dx = clientX - this.lastMousePos.x; + const dy = clientY - this.lastMousePos.y; + + // 현재 마우스 위치 실시간 반영 + this.mousePos = this.projectScreenToWorld(clientX, clientY); + + switch (this.mode) { + case "idle": + break; + + case "panning": + if (e.buttons > 0) { + this.onPan(dx, dy); + } + break; + + // 일정 거리 이상 움직여야 dragging 모드로 전환 + case "potential_drag": { + const dist = calcDistance(clientX, clientY, this.startMousePos.x, this.startMousePos.y); + if (dist > DRAG_THRESHOLD) { + this.mode = "dragging"; + + // 드래그 시작 시, 이동 중인 노드의 모든 자식 노드 ID 미리 저장 + if (this.draggingNodeId) { + this.dragSubtreeIds = this.container.getAllDescendantIds(this.draggingNodeId); + } + // ghost 1회 + 첫 프레임 + this.emitDragSession(); + this.emitInteractionFrame(); + } + break; + } + + case "dragging": { + // 이전 프레임 마우스 → 월드 좌표 + const prevWorld = this.projectScreenToWorld(this.lastMousePos.x, this.lastMousePos.y); + + // 현재 프레임 마우스 → 월드 좌표 + const nextWorld = this.projectScreenToWorld(clientX, clientY); + + // 이전 프레임 -> 현재 프레임 월드 좌표 차이 + const worldDx = nextWorld.x - prevWorld.x; + const worldDy = nextWorld.y - prevWorld.y; + + this.dragDelta = { + x: this.dragDelta.x + worldDx, + y: this.dragDelta.y + worldDy, + }; + + this.updateDropTarget(e); + // dragging movingFragment만 리렌더 + this.emitInteractionFrame(); + break; + } + + case "pending_creation": { + this.updateDropTarget(e); + this.emitInteractionFrame(); + break; + } + } + this.lastMousePos = { x: clientX, y: clientY }; + return { dx, dy }; + } + + private handleMouseUp = (_e: React.MouseEvent) => { + if (this.mode === "dragging" && this.draggingNodeId && this.baseNode.targetId) { + const targetNodeId = this.baseNode.targetId; + const movingNodeId = this.draggingNodeId; + const direction = this.baseNode.direction; + + const isDroppingOnItseltOrDescendant = this.dragSubtreeIds?.has(targetNodeId); + + if (!isDroppingOnItseltOrDescendant && direction) { + const targetNode = this.container.safeGetNode(targetNodeId); + + if (targetNode) { + this.onMoveNode(targetNodeId, movingNodeId, direction); + } + } + } + + // 리렌더링이 필요한 모드인지 + const shouldUpdateReact = this.mode === "dragging"; + + this.clearStatus(); + + if (shouldUpdateReact) { + this.emitDragSession(); + this.emitInteractionFrame(); + } + }; + + /** 스냅샷 생성은 여기에서만 (캐시 갱신 시점 통제) */ + private commitInteractionSnapshot() { + this.interactionSnapshot = { + mode: this.mode, + draggingNodeId: this.draggingNodeId, + dragDelta: this.dragDelta, + mousePos: this.mousePos, + dragSubtreeIds: this.dragSubtreeIds, + baseNode: this.baseNode, + }; + } + + private commitDragSessionSnapshot() { + this.dragSessionSnapshot = { + isDragging: this.mode === "dragging", + draggingNodeId: this.draggingNodeId, + dragSubtreeIds: this.dragSubtreeIds, + }; + } + + /** 드래그 프레임(마우스 move 등)용 업데이트: InteractionLayer만 리렌더되도록 */ + private emitInteractionFrame() { + this.commitInteractionSnapshot(); + this.broker.publish("INTERACTION_FRAME", undefined); + } + + /** 드래그 세션 시작/종료(1회)용 업데이트: 원본 노드 ghost 처리 등에 사용 */ + private emitDragSession() { + this.commitDragSessionSnapshot(); + this.broker.publish("DRAG_SESSION", undefined); + } + getInteractionSnapshot(): InteractionSnapshot { + return this.interactionSnapshot; + } + + getDragSessionSnapshot(): DragSessionSnapshot { + return this.dragSessionSnapshot; + } + + getSelectedNodeId() { + return this.selectedNodeId; + } + // 새로운 노드 추가 TODO: 외부에서 버튼 클릭 시 이 모드가 되어야 함 + startCreating() { + this.mode = "pending_creation"; + } + + getInteractionMode() { + return this.mode; + } +} diff --git a/frontend/src/features/mindmap/utils/MindmapLayoutManager.ts b/frontend/src/features/mindmap/core/LayoutManager.ts similarity index 57% rename from frontend/src/features/mindmap/utils/MindmapLayoutManager.ts rename to frontend/src/features/mindmap/core/LayoutManager.ts index 152a49367..9af6ec019 100644 --- a/frontend/src/features/mindmap/utils/MindmapLayoutManager.ts +++ b/frontend/src/features/mindmap/core/LayoutManager.ts @@ -1,7 +1,6 @@ -import { NodeElement, NodeId } from "@/features/mindmap/types/mindmapType"; -import TreeContainer from "@/features/mindmap/utils/TreeContainer"; +import TreeContainer from "@/features/mindmap/core/TreeContainer"; +import { NodeElement, NodeId } from "@/features/mindmap/types/node"; import { CacheMap } from "@/utils/CacheMap"; -import { calcPartitionIndex } from "@/utils/calc_partition"; import { isSame } from "@/utils/is_same"; type LayoutConfig = { @@ -26,81 +25,22 @@ export default class MindmapLayoutManager { this.config = { ...defaultConfig, ...config }; this.subtreeHeightCache = new CacheMap(); } - - public invalidate(nodeId: NodeId) { - let currentId: NodeId | undefined = nodeId; - - while (currentId) { - this.subtreeHeightCache.delete(currentId); - - const parentNode = this.treeContainer.safeGetParentNode(currentId); - if (!parentNode) { - break; - } - - currentId = parentNode.id; - } - } - - public updateLayout({ - rootId, - rootCenterX = 0, - rootCenterY = 0, - }: { - rootId: NodeId; - rootCenterX?: number; - rootCenterY?: number; - }) { - const rootNode = this.treeContainer.safeGetNode(rootId); - if (!rootNode) { - return; - } - - const realX = rootCenterX - rootNode.width / 2; - const realY = rootCenterY - rootNode.height / 2; - - this.treeContainer.update({ - nodeId: rootId, - newNodeData: { x: realX, y: realY }, - }); - - const childNodes = this.treeContainer.getChildNodes(rootId); - if (childNodes.length === 0) { - return; - } - - const { leftGroup, rightGroup } = this.getPartition(childNodes); - - this.layoutPartition({ - parentNode: rootNode, - partition: rightGroup, - parentRealX: realX, - parentRealY: realY, - direction: "right", - }); - - this.layoutPartition({ - parentNode: rootNode, - partition: leftGroup, - parentRealX: realX, - parentRealY: realY, - direction: "left", - }); - } - + // TODO: 좌우 균형 일단 적용 X, 사용자 조작대로만 위치 private getPartition(childNodes: NodeElement[]) { - const heightArr = childNodes.map((node) => this.getSubTreeHeight(node)); - const partitionIndex = calcPartitionIndex(heightArr); + const currentLeft = childNodes.filter((n) => n.addNodeDirection === "left"); + const currentRight = childNodes.filter((n) => n.addNodeDirection === "right"); return { - rightGroup: childNodes.slice(0, partitionIndex), - leftGroup: childNodes.slice(partitionIndex), + rightGroup: currentRight, + leftGroup: currentLeft, }; } private getSubTreeHeight(node: NodeElement): number { + // 1. 실제 측정값이 0이라면, 기본 노드 높이(예: 40)를 사용하도록 보정합니다. + const effectiveNodeHeight = node.height > 0 ? node.height : 60; + if (this.subtreeHeightCache.has(node.id)) { - // node.id가 있음이 확실하므로 !로 단언했습니다. return this.subtreeHeightCache.get(node.id)!; } @@ -108,12 +48,14 @@ export default class MindmapLayoutManager { let calculatedHeight = 0; if (childNodes.length === 0) { - calculatedHeight = node.height; + // 2. 자식이 없는 노드도 0이 아닌 기본 높이를 반환합니다. + calculatedHeight = effectiveNodeHeight; } else { const gapHeight = (childNodes.length - 1) * this.config.yGap; - const childrenHeightSum = childNodes.reduce((acc, child) => acc + this.getSubTreeHeight(child), gapHeight); + // 3. 재귀적으로 자식들의 높이를 합산할 때도 0이 나오지 않도록 합니다. + const childrenHeightSum = childNodes.reduce((acc, child) => acc + this.getSubTreeHeight(child), 0); - calculatedHeight = Math.max(node.height, childrenHeightSum); + calculatedHeight = Math.max(effectiveNodeHeight, childrenHeightSum + gapHeight); } this.subtreeHeightCache.set(node.id, calculatedHeight); @@ -123,14 +65,10 @@ export default class MindmapLayoutManager { private layoutPartition({ parentNode, partition, - parentRealX, - parentRealY, direction, }: { parentNode: NodeElement; partition: NodeElement[]; - parentRealX: number; - parentRealY: number; direction: PartitionDirection; }) { if (partition.length === 0) { @@ -138,38 +76,49 @@ export default class MindmapLayoutManager { } const partitionHeight = this.calcPartitionHeightWithGap(partition); - let currentY = parentRealY + parentNode.height / 2 - partitionHeight / 2; + let currentStartY = parentNode.y - partitionHeight / 2; partition.forEach((childNode) => { - const realX = - direction === "right" - ? parentRealX + parentNode.width + this.config.xGap - : parentRealX - childNode.width - this.config.xGap; + const childWidth = childNode.width || 200; - this.layoutSubtree({ curNode: childNode, x: realX, startY: currentY, direction }); + const childCenterX = + direction === "right" + ? parentNode.x + parentNode.width / 2 + this.config.xGap + childWidth / 2 + : parentNode.x - parentNode.width / 2 - this.config.xGap - childWidth / 2; + + this.layoutSubtree({ + curNode: childNode, + centerX: childCenterX, + startY: currentStartY, + direction, + }); - currentY += this.getSubTreeHeight(childNode) + this.config.yGap; + currentStartY += this.getSubTreeHeight(childNode) + this.config.yGap; }); } private layoutSubtree({ curNode, - x, + centerX, startY, direction, }: { curNode: NodeElement; - x: number; + centerX: number; startY: number; direction: PartitionDirection; }) { const subtreeHeight = this.getSubTreeHeight(curNode); - const newNodeY = startY - curNode.height / 2 + subtreeHeight / 2; + const centerY = startY + subtreeHeight / 2; - if (!isSame(curNode.x, x) || !isSame(curNode.y, newNodeY)) { + if (!isSame(curNode.x, centerX) || !isSame(curNode.y, centerY) || curNode.addNodeDirection !== direction) { this.treeContainer.update({ nodeId: curNode.id, - newNodeData: { x, y: newNodeY }, + newNodeData: { + x: centerX, + y: centerY, + // addNodeDirection: direction //좌우 균형 정렬 적용 x + }, }); } @@ -179,15 +128,24 @@ export default class MindmapLayoutManager { } const childGroupHeight = this.calcPartitionHeightWithGap(childNodes); - let currentChildY = newNodeY + curNode.height / 2 - childGroupHeight / 2; + let currentChildStartY = centerY - childGroupHeight / 2; childNodes.forEach((childNode) => { - const nextX = - direction === "right" ? x + curNode.width + this.config.xGap : x - childNode.width - this.config.xGap; + const childWidth = childNode.width || 200; - this.layoutSubtree({ curNode: childNode, x: nextX, startY: currentChildY, direction }); + const nextCenterX = + direction === "right" + ? centerX + curNode.width / 2 + this.config.xGap + childWidth / 2 + : centerX - curNode.width / 2 - this.config.xGap - childWidth / 2; + + this.layoutSubtree({ + curNode: childNode, + centerX: nextCenterX, + startY: currentChildStartY, + direction, + }); - currentChildY += this.getSubTreeHeight(childNode) + this.config.yGap; + currentChildStartY += this.getSubTreeHeight(childNode) + this.config.yGap; }); } @@ -201,4 +159,49 @@ export default class MindmapLayoutManager { return totalNodesHeight; } + + /** 캐시 무효화 : 특정 노드 변경 시 상위 부모들의 높이 계산 초기화 */ + invalidate(nodeId: NodeId) { + let currentId: NodeId | undefined = nodeId; + + while (currentId) { + this.subtreeHeightCache.delete(currentId); + + const parentNode = this.treeContainer.safeGetParentNode(currentId); + if (!parentNode) { + break; + } + + currentId = parentNode.id; + } + } + + updateLayout({ rootId }: { rootId: NodeId }) { + const rootNode = this.treeContainer.safeGetNode(rootId); + if (!rootNode) { + return; + } + + rootNode.x = 0; + rootNode.y = 0; + + const childNodes = this.treeContainer.getChildNodes(rootId); + if (childNodes.length === 0) { + return; + } + + const { leftGroup, rightGroup } = this.getPartition(childNodes); + + this.layoutPartition({ + parentNode: rootNode, + partition: rightGroup, + direction: "right", + }); + + this.layoutPartition({ + parentNode: rootNode, + partition: leftGroup, + direction: "left", + }); + } } diff --git a/frontend/src/features/mindmap/core/MindMapCore.ts b/frontend/src/features/mindmap/core/MindMapCore.ts new file mode 100644 index 000000000..57c9a2701 --- /dev/null +++ b/frontend/src/features/mindmap/core/MindMapCore.ts @@ -0,0 +1,220 @@ +import { MindmapInteractionManager } from "@/features/mindmap/core/InteractionManager"; +import MindmapLayoutManager from "@/features/mindmap/core/LayoutManager"; +import QuadTree from "@/features/mindmap/core/QuadTree"; +import TreeContainer from "@/features/mindmap/core/TreeContainer"; +import ViewportManager from "@/features/mindmap/core/ViewportManager"; +import { MindMapEvents } from "@/features/mindmap/types/events"; +import { EMPTY_DRAG_SESSION_SNAPSHOT, EMPTY_INTERACTION_SNAPSHOT } from "@/features/mindmap/types/interaction"; +import { AddNodeDirection, NodeDirection, NodeId } from "@/features/mindmap/types/node"; +import { EventBroker } from "@/utils/EventBroker"; + +/** + * tree : 마인드맵의 계급도 + * layout : 트리 layout 배치 + * quadTree : 마우스 주변 노드 후보군 탐색 + * viewport : 실제 svg 화면 그리기 + * interaction : 마우스/키보드 조작 + */ +export default class MindMapCore { + public tree: TreeContainer; + private broker = new EventBroker(); + private layout: MindmapLayoutManager; + + private canvas: SVGSVGElement | null = null; + private viewport: ViewportManager | null = null; + private quadTree: QuadTree | null = null; + private interaction: MindmapInteractionManager | null = null; + + private _isInitialized = false; + + constructor(private onGlobalUpdate: () => void) { + this.tree = new TreeContainer(); + this.layout = new MindmapLayoutManager({ treeContainer: this.tree }); + } + + /** 쿼드트리 초기 영역 계산 */ + private calculateInitialBounds() { + return { + minX: -10000, + maxX: 10000, + minY: -10000, + maxY: 10000, + }; + } + + private sync(affectedIds?: NodeId[]) { + const { quadTree, viewport, _isInitialized } = this; + + if (!_isInitialized || !quadTree || !viewport) return; + + // 영향을 받는 노드들의 레이아웃 캐시 무효화 + if (affectedIds) { + affectedIds.forEach((id) => { + this.layout.invalidate(id); + }); + } + + // 1. 레이아웃 업데이트 + const rootId = this.tree.getRootId(); + if (rootId) { + this.layout.updateLayout({ rootId }); + } + + // 2. 쿼드 트리 갱신 + quadTree.clear(); + this.tree.nodes.forEach((node) => { + quadTree.insert(node); + }); + + // 3. broker 알림 + if (affectedIds) { + affectedIds.forEach((id) => { + this.broker.publish(id, undefined); + }); + } + // 전체 알림 + else { + this.broker.publish("RENDER_UPDATE", undefined); + } + + // 4. 전역 UI 업데이트 + this.onGlobalUpdate(); + } + + /** 2단계 결합을 위한 초기화 메서드 */ + initialize(canvas: SVGSVGElement) { + if (this._isInitialized) return; + + const rootNode = this.tree.getRootNode(); + const initialBounds = this.calculateInitialBounds(); + + // 여기서 할당이 완료됨 + this.canvas = canvas; + this.quadTree = new QuadTree(initialBounds); + this.viewport = new ViewportManager(this.broker, canvas, rootNode, () => this.quadTree!.getBounds()); + + this.interaction = new MindmapInteractionManager( + this.broker, + this.tree, + this.quadTree, + (dx, dy) => { + if (this.viewport) this.viewport.panningHandler(dx, dy); + }, + (target, moving, direction) => this.moveNode(target, moving, direction), + (x, y) => this.viewport!.screenToWorld(x, y), + (id) => this.deleteNode(id), + ); + + this._isInitialized = true; + this.sync(); + } + + getCanvas(): SVGSVGElement | null { + return this.canvas; + } + + moveNode(targetId: NodeId, movingId: NodeId, direction: NodeDirection) { + this.tree.moveTo({ + baseNodeId: targetId, + movingNodeId: movingId, + direction, + }); + this.sync([targetId, movingId]); + } + + addNode(baseNodeId: NodeId, direction: NodeDirection, addNodeDirection: AddNodeDirection) { + const newNodeId = this.tree.attachTo({ baseNodeId, direction: direction, addNodeDirection: addNodeDirection }); + + if (newNodeId) { + this.sync([baseNodeId, newNodeId]); + } + } + + deleteNode(nodeId: NodeId) { + try { + const parentId = this.tree.getParentId(nodeId); + this.tree.delete({ nodeId }); + this.sync(parentId ? [parentId] : undefined); + } catch (error) { + if (error instanceof Error) { + console.error(error.message); + this.broker.publish("NODE_DELETE_ERROR", error.message); + } else { + console.error("알 수 없는 삭제 에러 발생"); + } + } + } + getInteractionSnapshot() { + if (!this._isInitialized || !this.interaction) return EMPTY_INTERACTION_SNAPSHOT; + return this.interaction.getInteractionSnapshot(); + } + + getDragSessionSnapshot() { + if (!this._isInitialized || !this.interaction) return EMPTY_DRAG_SESSION_SNAPSHOT; + return this.interaction.getDragSessionSnapshot(); + } + + updateNodeSize(nodeId: NodeId, width: number, height: number) { + // 동일 사이즈면 early return + const cur = this.tree.safeGetNode(nodeId); + if (cur && cur.width === width && cur.height === height) return; + + this.tree.update({ nodeId, newNodeData: { width, height } }); + this.sync([nodeId]); + } + + /** ========== Interaction ========== */ + handleMouseMove(e: React.MouseEvent) { + this.broker.publish("RAW_MOUSE_MOVE", e); + } + + handleWheel(e: React.WheelEvent) { + this.broker.publish("RAW_WHEEL", e); + } + + handleMouseDown(e: React.MouseEvent) { + const target = e.target as HTMLElement; + const nodeEl = target.closest("[data-node-id]"); + + if (nodeEl) { + const nodeId = nodeEl.getAttribute("data-node-id")!; + const actionEl = target.closest("[data-action]"); + + if (actionEl && actionEl.getAttribute("data-action") === "add-child") { + const direction = actionEl.getAttribute("data-direction") as AddNodeDirection; + this.addNode(nodeId, "child", direction); + return; + } + + // 노드 본체 클릭 시 (드래그 준비) + this.broker.publish("NODE_CLICK", { nodeId, event: e }); + } else { + // 배경 클릭 시 (패닝) + this.broker.publish("RAW_MOUSE_DOWN", e); + } + } + + handleMouseUp(e: React.MouseEvent) { + this.broker.publish("RAW_MOUSE_UP", e); + } + + getBroker() { + return this.broker; + } + + getTree() { + return this.tree; + } + + getViewport() { + return this.viewport; + } + + getLayout() { + return this.layout; + } + + getIsReady(): boolean { + return this._isInitialized; + } +} diff --git a/frontend/src/features/quad_tree/utils/QuadTree.ts b/frontend/src/features/mindmap/core/QuadTree.ts similarity index 87% rename from frontend/src/features/quad_tree/utils/QuadTree.ts rename to frontend/src/features/mindmap/core/QuadTree.ts index 2bea056fa..b12e82278 100644 --- a/frontend/src/features/quad_tree/utils/QuadTree.ts +++ b/frontend/src/features/mindmap/core/QuadTree.ts @@ -1,5 +1,4 @@ -import { Point } from "@/features/quad_tree/types/point"; -import { Rect } from "@/features/quad_tree/types/rect"; +import { Point, Rect } from "@/features/mindmap/types/spatial"; import { isIntersected, isPointInRect } from "@/shared/utils/rect_helper"; /** @@ -24,23 +23,6 @@ export default class QuadTree { this.bounds = bounds; this.limit = limit; } - - /** 현재 트리의 전체 경계 반환 (Renderer 참조용) */ - getBounds(): Rect { - return this.bounds; - } - - /** [Add/Drop] 점 삽입: 외부 전용 관문으로, 필요 시 영역을 확장하고 삽입을 실행 */ - public insert(point: Point): boolean { - // 삽입 전, 점의 위치가 현재 전체 영역의 90%를 벗어나는지 확인하여 확장 - if (this.isNearBoundary(point)) { - this.rebuild(); - } - - // 실제 삽입 로직 수행 - return this.executeInsert(point); - } - /** [Internal] 실제 삽입 로직: 개수 > limit 이면, 하위 영역으로 분할하고 자식 노드로 전달 */ private executeInsert(point: Point): boolean { // 삽입하려는 점이 현재 Quad 영역에 속하지 않으면 삽입 거부 @@ -72,36 +54,6 @@ export default class QuadTree { return this.delegateInsert(point); } - /** [Remove/DragStart] 점 삭제 : 삭제 후 데이터 밀도가 낮아지면 tryMerge */ - remove(point: Point): boolean { - if (!this.isPointInBounds(point)) { - console.error(`[QuadTree 삭제 실패] 점 (${point.x}, ${point.y})이 경계 영역 밖에 있습니다.`); - return false; - } - - let removed = false; - - if (this.children) { - removed = this.delegateRemove(point); - - if (removed) { - this.tryMerge(); - } else { - // 삭제 대상이 영역 안에는 있어야 하는데 못 찾은 경우 - console.error( - `[QuadTree 삭제 실패] 하위 자식 노드에서 점 (${point.x}, ${point.y})을 찾을 수 없습니다.`, - ); - } - } else { - // 리프 노드인 경우 직접 점 삭제 - removed = this.points.delete(point); - if (!removed) { - console.error(`[QuadTree 삭제 실패] 리프 노드에 삭제하려는 점 (${point.x}, ${point.y})이 없습니다.`); - } - } - return removed; - } - /** [Rebuild] 경계를 중심점 기준 2배로 확장하고 트리를 재구축 */ private rebuild() { // 1. 기존의 모든 점 수집 @@ -148,27 +100,6 @@ export default class QuadTree { return !isPointInRect(point, innerBounds); } - /** [DragMove] 범위 탐색 : 마우스 주변의 스냅 가능한 노드 확보 */ - getPointsInRange(range: Rect, found: Point[] = []): Point[] { - if (!isIntersected(this.bounds, range)) { - return found; - } - - this.points.forEach((point) => { - if (isPointInRect(point, range)) { - found.push(point); - } - }); - - if (this.children) { - this.children.NW.getPointsInRange(range, found); - this.children.NE.getPointsInRange(range, found); - this.children.SW.getPointsInRange(range, found); - this.children.SE.getPointsInRange(range, found); - } - return found; - } - /** 현재 영역을 4개의 하위 영역으로 분할 */ private split() { const { minX, maxX, minY, maxY } = this.bounds; @@ -240,23 +171,103 @@ export default class QuadTree { /** 삽입 작업을 자식 노드에게 위임 */ private delegateInsert(point: Point): boolean { - if (!this.children) { - console.error("[QuadTree 위임 실패] 자식 노드가 생성되지 않은 상태입니다."); - return false; - } + if (!this.children) return false; + const { NW, NE, SW, SE } = this.children; - return NW.executeInsert(point) || NE.executeInsert(point) || SW.executeInsert(point) || SE.executeInsert(point); + if (isPointInRect(point, NW.getBounds())) return NW.executeInsert(point); + if (isPointInRect(point, NE.getBounds())) return NE.executeInsert(point); + if (isPointInRect(point, SW.getBounds())) return SW.executeInsert(point); + if (isPointInRect(point, SE.getBounds())) return SE.executeInsert(point); + + console.error("[QuadTree] 어떤 자식 영역에도 속하지 않는 좌표입니다.", point); + return false; } /** 삭제 작업을 자식 노드에게 위임 */ private delegateRemove(point: Point): boolean { - if (!this.children) { - console.error("[QuadTree 위임 실패] 삭제를 위임할 자식 노드가 존재하지 않습니다."); + if (!this.children) return false; + + const { NW, NE, SW, SE } = this.children; + + if (isPointInRect(point, NW.getBounds())) return NW.remove(point); + if (isPointInRect(point, NE.getBounds())) return NE.remove(point); + if (isPointInRect(point, SW.getBounds())) return SW.remove(point); + if (isPointInRect(point, SE.getBounds())) return SE.remove(point); + + return false; + } + + /** 현재 트리의 전체 경계 반환 (Renderer 참조용) */ + getBounds(): Rect { + return this.bounds; + } + + /** [Add/Drop] 점 삽입: 외부 전용 관문으로, 필요 시 영역을 확장하고 삽입을 실행 */ + insert(point: Point): boolean { + while (!this.isPointInBounds(point)) { + this.rebuild(); + } + if (this.isNearBoundary(point)) { + this.rebuild(); + } + return this.executeInsert(point); + } + + /** [DragMove] 범위 탐색 : 마우스 주변의 스냅 가능한 노드 확보 */ + getPointsInRange(range: Rect, found: Point[] = []): Point[] { + if (!isIntersected(this.bounds, range)) { + return found; + } + + this.points.forEach((point) => { + if (isPointInRect(point, range)) { + found.push(point); + } + }); + + if (this.children) { + this.children.NW.getPointsInRange(range, found); + this.children.NE.getPointsInRange(range, found); + this.children.SW.getPointsInRange(range, found); + this.children.SE.getPointsInRange(range, found); + } + return found; + } + + /** [Remove/DragStart] 점 삭제 : 삭제 후 데이터 밀도가 낮아지면 tryMerge */ + remove(point: Point): boolean { + if (!this.isPointInBounds(point)) { + console.error(`[QuadTree 삭제 실패] 점 (${point.x}, ${point.y})이 경계 영역 밖에 있습니다.`); return false; } - const { NW, NE, SW, SE } = this.children; - return NW.remove(point) || NE.remove(point) || SW.remove(point) || SE.remove(point); + let removed = false; + + if (this.children) { + removed = this.delegateRemove(point); + + if (removed) { + this.tryMerge(); + } else { + // 삭제 대상이 영역 안에는 있어야 하는데 못 찾은 경우 + console.error( + `[QuadTree 삭제 실패] 하위 자식 노드에서 점 (${point.x}, ${point.y})을 찾을 수 없습니다.`, + ); + } + } else { + // 리프 노드인 경우 직접 점 삭제 + removed = this.points.delete(point); + if (!removed) { + console.error(`[QuadTree 삭제 실패] 리프 노드에 삭제하려는 점 (${point.x}, ${point.y})이 없습니다.`); + } + } + return removed; + } + + /** 트리 초기화 */ + clear(): void { + this.points.clear(); + this.children = null; } } diff --git a/frontend/src/features/mindmap/utils/TreeContainer.ts b/frontend/src/features/mindmap/core/TreeContainer.ts similarity index 80% rename from frontend/src/features/mindmap/utils/TreeContainer.ts rename to frontend/src/features/mindmap/core/TreeContainer.ts index 457b0eaec..c42e5480f 100644 --- a/frontend/src/features/mindmap/utils/TreeContainer.ts +++ b/frontend/src/features/mindmap/core/TreeContainer.ts @@ -1,36 +1,24 @@ -import { NodeData, NodeElement, NodeId, NodeType } from "@/features/mindmap/types/mindmapType"; -import { EventBroker } from "@/utils/EventBroker"; +import { + AddNodeDirection, + NodeData, + NodeDirection, + NodeElement, + NodeId, + NodeType, +} from "@/features/mindmap/types/node"; import { exhaustiveCheck } from "@/utils/exhaustive_check"; import generateId from "@/utils/generate_id"; -// TODO: quadtree 준비되면 의존성 주입 -type QuadTreeManager = undefined; - const ROOT_NODE_PARENT_ID = "empty"; -const ROOT_NODE_CONTENTS = "김현대의 마인드맵"; +const ROOT_NODE_CONTENTS = "김현대"; const DETACHED_NODE_PARENT_ID = "detached"; export default class TreeContainer { public nodes: Map; - private quadTreeManager: QuadTreeManager; - private broker: EventBroker; private isThrowError: boolean; private rootNodeId: NodeId; - constructor({ - quadTreeManager, - broker, - name = ROOT_NODE_CONTENTS, - - // TODO: 개발 단계에서는 error boundary로 대체되면 디버깅이 어려우므로 해당 옵션을 제공. - isThrowError = true, - }: { - quadTreeManager: QuadTreeManager; - broker: EventBroker; - name?: string; - isThrowError?: boolean; - }) { - // initialization + constructor({ name = ROOT_NODE_CONTENTS, isThrowError = true }: { name?: string; isThrowError?: boolean } = {}) { this.nodes = new Map(); const rootNodeElement = this.generateNewNodeElement({ nodeData: { @@ -40,95 +28,50 @@ export default class TreeContainer { }); this.rootNodeId = rootNodeElement.id; this.addNodeToContainer(rootNodeElement); - - // inject dependency - this.quadTreeManager = quadTreeManager; - this.broker = broker; this.isThrowError = isThrowError; } - private notify(nodeId: NodeId) { + private _getNode(nodeId: NodeId): NodeElement { const node = this.nodes.get(nodeId); - if (node) { - this.nodes.set(nodeId, { ...node }); + + if (!node) { + throw new Error(`일치하는 Node가 없습니다. (node_id: ${nodeId})`); } - this.broker.publish(nodeId); + return node; } - appendChild({ parentNodeId, childNodeId: cId }: { parentNodeId: NodeId; childNodeId?: NodeId }) { - try { - const childNode = cId ? this._getNode(cId) : this.generateNewNodeElement(); - - const parentNode = this._getNode(parentNodeId); - childNode.parentId = parentNodeId; + private generateNewNodeElement({ + nodeData = { contents: "" }, + type = "normal", + addNodeDirection = "left", + }: { nodeData?: NodeData; type?: NodeType; addNodeDirection?: AddNodeDirection } = {}) { + const node: NodeElement = { + id: generateId(), + x: 0, + y: 0, + width: 0, + height: 0, + addNodeDirection, - if (parentNode.lastChildId) { - // 자식 자연수 - const lastNode = this._getNode(parentNode.lastChildId); + parentId: ROOT_NODE_PARENT_ID, + firstChildId: null, + lastChildId: null, - lastNode.nextId = childNode.id; - childNode.prevId = lastNode.id; - childNode.nextId = null; // 필요없긴함 + nextId: null, + prevId: null, - parentNode.lastChildId = childNode.id; - } else { - // 자식 0 - parentNode.firstChildId = childNode.id; - parentNode.lastChildId = childNode.id; - } + data: nodeData, + type, + }; - this.notify(parentNode.id); - } catch (e) { - if (e instanceof Error) { - console.error(e.message); - } else { - console.error(String(e)); - } + this.addNodeToContainer(node); - if (this.isThrowError) { - throw e; - } - } + return node; } - attachTo({ baseNodeId, direction }: { baseNodeId: NodeId; direction: "prev" | "next" | "child" }) { - try { - const baseNode = this._getNode(baseNodeId); - - // Root 노드 옆에는 추가할 수 없음 - if (baseNode.type === "root") { - throw new Error("루트 노드의 형제로는 노드를 추가할 수 없습니다."); - } - - const newNode = this.generateNewNodeElement(); - - switch (direction) { - case "next": - this.attachNext({ baseNode, movingNode: newNode }); - break; - case "prev": - this.attachPrev({ baseNode, movingNode: newNode }); - break; - case "child": - this.appendChild({ parentNodeId: baseNode.id, childNodeId: newNode.id }); - break; - default: - exhaustiveCheck(`${direction} 방향은 불가능합니다.`); - } - - this.notify(baseNode.id); - } catch (e) { - if (e instanceof Error) { - console.error(e.message); - } else { - console.error(String(e)); - } - - if (this.isThrowError) { - throw e; - } - } + private addNodeToContainer(node: NodeElement) { + this.nodes.set(node.id, node); } private attachNext({ baseNode, movingNode }: { baseNode: NodeElement; movingNode: NodeElement }) { @@ -149,7 +92,6 @@ export default class TreeContainer { if (parentNode.lastChildId === baseNode.id) { parentNode.lastChildId = movingNode.id; } - this.notify(parentNode.id); } private attachPrev({ baseNode, movingNode }: { baseNode: NodeElement; movingNode: NodeElement }) { @@ -171,83 +113,54 @@ export default class TreeContainer { if (parentNode.firstChildId === baseNode.id) { parentNode.firstChildId = movingNode.id; } - - this.notify(parentNode.id); } - delete({ nodeId }: { nodeId: NodeId }) { - try { - const node = this._getNode(nodeId); - if (node.type === "root") { - throw new Error("루트 노드는 삭제할 수 없습니다."); - } - - const parentNode = this._getNode(node.parentId); - - if (parentNode.firstChildId === node.id) { - parentNode.firstChildId = node.nextId; - } - - if (parentNode.lastChildId === node.id) { - parentNode.lastChildId = node.prevId; - } + private detach({ node }: { node: NodeElement }) { + if (node.type === "root") { + throw new Error("루트 노드는 뗄 수 없습니다."); + } - if (node.prevId) { - const prevNode = this._getNode(node.prevId); - prevNode.nextId = node.nextId; - } + const parentNode = this._getNode(node.parentId); - if (node.nextId) { - const nextNode = this._getNode(node.nextId); - nextNode.prevId = node.prevId; - } + // 1. 부모 포인터 갱신 + if (parentNode.firstChildId === node.id) { + parentNode.firstChildId = node.nextId; + } - this._deleteTraverse({ nodeId }); + if (parentNode.lastChildId === node.id) { + parentNode.lastChildId = node.prevId; + } - this.notify(parentNode.id); - } catch (e) { - if (e instanceof Error) { - console.error(e.message); - } else { - console.error(String(e)); - } + if (node.prevId) { + this._getNode(node.prevId).nextId = node.nextId; + } - if (this.isThrowError) { - throw e; - } + if (node.nextId) { + this._getNode(node.nextId).prevId = node.prevId; } + node.prevId = null; + node.nextId = null; + node.parentId = DETACHED_NODE_PARENT_ID; // 임시 상태. + } + + private deleteNodeFromContainer(nodeId: NodeId) { + this.nodes.delete(nodeId); } private _deleteTraverse({ nodeId }: { nodeId: NodeId }) { const node = this._getNode(nodeId); - let childId = node.firstChildId; while (childId) { const child = this.safeGetNode(childId); if (!child) break; - + const nextId = child.nextId; this._deleteTraverse({ nodeId: childId }); - - childId = child.nextId; + childId = nextId; } - this.deleteNodeFromContainer(nodeId); } - private _getNode(nodeId: NodeId): NodeElement { - const node = this.nodes.get(nodeId); - - if (!node) { - throw new Error(`일치하는 Node가 없습니다. (node_id: ${nodeId})`); - } - - return node; - } - - /** - * 아직 인자를 어떻게 받아야 좋을 지 확신 못함. - */ moveTo({ baseNodeId, movingNodeId, @@ -255,7 +168,7 @@ export default class TreeContainer { }: { baseNodeId: NodeId; movingNodeId: NodeId; - direction: "prev" | "next" | "child"; + direction: NodeDirection; }) { // 제자리 if (direction === "child" && baseNodeId === movingNodeId) { @@ -304,8 +217,6 @@ export default class TreeContainer { default: exhaustiveCheck(`${direction} 방향은 불가능합니다.`); } - - this.notify(baseNode.id); } catch (e) { if (e instanceof Error) { console.error(e.message); @@ -319,106 +230,151 @@ export default class TreeContainer { } } - private detach({ node }: { node: NodeElement }) { - if (node.type === "root") { - throw new Error("루트 노드는 뗄 수 없습니다."); - } - - const parentNode = this._getNode(node.parentId); + attachTo({ + baseNodeId, + direction, + addNodeDirection, + }: { + baseNodeId: NodeId; + direction: NodeDirection; + addNodeDirection: AddNodeDirection; + }) { + try { + const baseNode = this._getNode(baseNodeId); - // 1. 부모 포인터 갱신 - if (parentNode.firstChildId === node.id) { - parentNode.firstChildId = node.nextId; - } + const newNode = this.generateNewNodeElement({ + addNodeDirection: addNodeDirection, + }); - if (parentNode.lastChildId === node.id) { - parentNode.lastChildId = node.prevId; - } + switch (direction) { + case "next": + this.attachNext({ baseNode, movingNode: newNode }); + break; + case "prev": + this.attachPrev({ baseNode, movingNode: newNode }); + break; + case "child": + this.appendChild({ + parentNodeId: baseNode.id, + childNodeId: newNode.id, + addNodeDirection: addNodeDirection, + }); + break; + default: + exhaustiveCheck(`${direction} 방향은 불가능합니다.`); + } - if (node.prevId) { - const prevNode = this._getNode(node.prevId); - prevNode.nextId = node.nextId; - } + return newNode.id; + } catch (e) { + if (e instanceof Error) { + console.error(e.message); + } else { + console.error(String(e)); + } - if (node.nextId) { - const nextNode = this._getNode(node.nextId); - nextNode.prevId = node.prevId; + if (this.isThrowError) { + throw e; + } } - - this.notify(parentNode.id); - - node.prevId = null; - node.nextId = null; - node.parentId = DETACHED_NODE_PARENT_ID; // 임시 상태. } - getChildIds(nodeId: NodeId): NodeId[] { - const node = this.safeGetNode(nodeId); - if (!node) { - return []; - } + appendChild({ + parentNodeId, + childNodeId: cId, + addNodeDirection, + }: { + parentNodeId: NodeId; + childNodeId?: NodeId; + addNodeDirection?: AddNodeDirection; + }) { + try { + const parentNode = this._getNode(parentNodeId); + const finalDirection = + parentNode.type === "root" ? addNodeDirection || "right" : parentNode.addNodeDirection; - const childIds: NodeId[] = []; - let currentChildId = node.firstChildId; + // 노드 생성 (또는 기존 노드 가져오기) + const childNode = cId + ? this._getNode(cId) + : this.generateNewNodeElement({ addNodeDirection: finalDirection }); - while (currentChildId) { - childIds.push(currentChildId); + childNode.parentId = parentNodeId; + childNode.addNodeDirection = finalDirection; - const childNode = this.safeGetNode(currentChildId); - if (!childNode) { - break; - } + // [Double Linked List 연결: 항상 맨 뒤에 추가] + if (parentNode.lastChildId) { + // 이미 자식이 있는 경우: 기존 막내의 뒤에 붙임 + const lastNode = this._getNode(parentNode.lastChildId); + lastNode.nextId = childNode.id; + childNode.prevId = lastNode.id; - currentChildId = childNode.nextId; + // 부모의 막내 정보를 새 노드로 갱신 + parentNode.lastChildId = childNode.id; + childNode.nextId = null; // 막내이므로 다음은 없음 + } else { + // 첫 번째 자식인 경우: 첫째이자 막내가 됨 + parentNode.firstChildId = childNode.id; + parentNode.lastChildId = childNode.id; + childNode.prevId = null; + childNode.nextId = null; + } + } catch (e) { + if (e instanceof Error) { + console.error(e.message); + } else { + console.error(String(e)); + } + if (this.isThrowError) { + throw e; + } } - - return childIds; } - safeGetParentNode(nodeId: NodeId) { - const node = this.safeGetNode(nodeId); + delete({ nodeId }: { nodeId: NodeId }) { + try { + const node = this._getNode(nodeId); + if (node.type === "root") { + throw new Error("루트 노드는 삭제할 수 없습니다."); + } - if (!node) { - return undefined; - } + const parentNode = this._getNode(node.parentId); - const parentNode = this.safeGetNode(node.parentId); + if (parentNode.firstChildId === node.id) { + parentNode.firstChildId = node.nextId; + } - if (!parentNode) { - return undefined; - } + if (parentNode.lastChildId === node.id) { + parentNode.lastChildId = node.prevId; + } - return parentNode; - } + if (node.prevId) { + const prevNode = this._getNode(node.prevId); + prevNode.nextId = node.nextId; + } - getRootId() { - return this.rootNodeId; - } + if (node.nextId) { + const nextNode = this._getNode(node.nextId); + nextNode.prevId = node.prevId; + } - getParentId(nodeId: NodeId): NodeId | undefined { - const node = this.safeGetNode(nodeId); - if (!node) { - return undefined; - } + this._deleteTraverse({ nodeId }); + } catch (e) { + if (e instanceof Error) { + console.error(e.message); + } else { + console.error(String(e)); + } - const parentNode = this.safeGetNode(node.parentId); - if (!parentNode) { - return undefined; + if (this.isThrowError) { + throw e; + } } - - return parentNode.id; } update({ nodeId, newNodeData }: { nodeId: NodeId; newNodeData: Partial> }) { - // TODO: newNodeData의 형을 다르게 해야할 수 있습니다. 일단은 Element로 뚫었는데 Node만 뚫어도될지도. 아직은 구현체가 확실하지 않아서 모르겠음. try { - const { id, ...rest } = this._getNode(nodeId); - - const newNodeElement: NodeElement = { ...rest, ...newNodeData, id }; - + const oldNode = this._getNode(nodeId); + const newNodeElement: NodeElement = { ...oldNode, ...newNodeData, id: nodeId }; this.addNodeToContainer(newNodeElement); - - this.notify(id); } catch (e) { if (e instanceof Error) { console.error(e.message); @@ -432,6 +388,27 @@ export default class TreeContainer { } } + // ===== 조회 / 탐색 ====== // + getChildIds(nodeId: NodeId): NodeId[] { + const node = this.safeGetNode(nodeId); + if (!node) { + return []; + } + + const childIds: NodeId[] = []; + let currentChildId = node.firstChildId; + + while (currentChildId) { + childIds.push(currentChildId); + const childNode = this.safeGetNode(currentChildId); + if (!childNode) { + break; + } + currentChildId = childNode.nextId; + } + return childIds; + } + getAllDescendantIds(nodeId: NodeId): Set { const descendants = new Set(); descendants.add(nodeId); // 자기 자신 포함 @@ -473,52 +450,48 @@ export default class TreeContainer { return childNodes; } - private generateNewNodeElement({ - nodeData = { contents: "" }, - type = "normal", - }: { nodeData?: NodeData; type?: NodeType } = {}) { - const node: NodeElement = { - id: generateId(), - - x: 0, - y: 0, - - width: 0, - height: 0, - - parentId: ROOT_NODE_PARENT_ID, + safeGetNode(nodeId: NodeId) { + const node = this.nodes.get(nodeId); - firstChildId: null, - lastChildId: null, + if (!node) { + return undefined; + } - nextId: null, - prevId: null, + return node; + } - data: nodeData, - type, - }; + safeGetParentNode(nodeId: NodeId) { + const node = this.safeGetNode(nodeId); - this.addNodeToContainer(node); + if (!node) { + return undefined; + } - return node; - } + const parentNode = this.safeGetNode(node.parentId); - private addNodeToContainer(node: NodeElement) { - this.nodes.set(node.id, node); - } + if (!parentNode) { + return undefined; + } - private deleteNodeFromContainer(nodeId: NodeId) { - this.nodes.delete(nodeId); + return parentNode; } - safeGetNode(nodeId: NodeId) { - const node = this.nodes.get(nodeId); - + getParentId(nodeId: NodeId): NodeId | undefined { + const node = this.safeGetNode(nodeId); if (!node) { return undefined; } - return node; + const parentNode = this.safeGetNode(node.parentId); + if (!parentNode) { + return undefined; + } + + return parentNode.id; + } + + getRootId() { + return this.rootNodeId; } getRootNode() { diff --git a/frontend/src/features/mindmap/core/ViewportManager.ts b/frontend/src/features/mindmap/core/ViewportManager.ts new file mode 100644 index 000000000..0dacd77e2 --- /dev/null +++ b/frontend/src/features/mindmap/core/ViewportManager.ts @@ -0,0 +1,152 @@ +import { MindMapEvents } from "@/features/mindmap/types/events"; +import { NodeElement } from "@/features/mindmap/types/node"; +import { Rect } from "@/features/mindmap/types/spatial"; +import { EventBroker } from "@/utils/EventBroker"; + +/** + * 카메라 + * canvas: 화면을 그리는 실제 SVG 엘리먼트 + * bounds: 이동 및 확장이 제한되는 전체 쿼드 트리 영역 + * viewBox: 현재 사용자 화면에 보이는 가상 좌표 영역 + */ +export default class ViewportManager { + private canvas: SVGSVGElement; + private broker: EventBroker; + private getWorldBounds: () => Rect; + + private panX = 0; + private panY = 0; + private zoom = 1; + + private readonly INITIAL_VIEW_FACTOR = 6; // 초기 뷰포트 크기: 루트 노드의 n배 + + /** [Init] 루트 노드를 중앙에 배치하고 쿼드 트리와 줌인된 초기 뷰포트를 설정 */ + constructor( + broker: EventBroker, + canvas: SVGSVGElement, + rootNode: NodeElement, + getWorldBounds: () => Rect, + ) { + this.broker = broker; + this.canvas = canvas; + this.getWorldBounds = getWorldBounds; + + // 초기 상태: 루트 노드가 (0,0)에 있으므로 카메라 중심도 (0,0) + this.panX = 0; + this.panY = 0; + this.zoom = 1; + + this.setupEventListeners(); + this.applyViewBox(); + } + + /** broker 통한 명령 구독 */ + private setupEventListeners() { + // Panning 명령 수신 + this.broker.subscribe({ + key: "VIEWPORT_PAN", + callback: ({ dx, dy }) => this.panningHandler(dx, dy), + }); + + // Zoom 명령 수신 + this.broker.subscribe({ + key: "VIEWPORT_ZOOM", + callback: ({ delta, clientX, clientY }) => this.zoomHandler(delta, { clientX, clientY }), + }); + + // 브라우저 휠 이벤트 직접 수신 (Core에서 쏘는 경우) + this.broker.subscribe({ + key: "RAW_WHEEL", + callback: (e) => { + const wheelEvent = e as WheelEvent; + this.zoomHandler(wheelEvent.deltaY, { clientX: wheelEvent.clientX, clientY: wheelEvent.clientY }); + }, + }); + } + + /** 항상 카메라 중심(panX, panY)을 기준으로 계산 -> svg 반영 */ + applyViewBox(): void { + const rect = this.canvas.getBoundingClientRect(); + if (rect.width === 0) return; + + // 1. 현재 줌 배율에 따라 화면에 보여줄 World 단위의 너비/높이 계산 + const viewWidth = rect.width / this.zoom; + const viewHeight = rect.height / this.zoom; + + // 2. 카메라 중심(panX, panY)에서 시야의 절반만큼 이동하여 왼쪽 상단(minX, minY) 결정 + const minX = this.panX - viewWidth / 2; + const minY = this.panY - viewHeight / 2; + + this.canvas.setAttribute("viewBox", `${minX} ${minY} ${viewWidth} ${viewHeight}`); + + // 리액트 등 외부 레이어에 변경 알림 + // this.broker.publish("VIEWPORT_CHANGED", this.getCurrentTransform()); + } + + /** 마우스 드래그: 카메라의 중심점(panX, panY)을 이동 */ + panningHandler(dx: number, dy: number): void { + // 현재 줌 배율에 맞춰 마우스 픽셀 이동량을 World 좌표 이동량으로 변환 + this.panX -= dx / this.zoom; + this.panY -= dy / this.zoom; + + this.applyViewBox(); + } + + /** 줌 핸들러: 마우스 포인터 지점을 고정하며 줌 인/아웃 */ + zoomHandler(delta: number, e: { clientX: number; clientY: number }): void { + const rect = this.canvas.getBoundingClientRect(); + + // 1. 줌 전의 마우스 월드 좌표 계산 + const beforeZoomMouseX = this.panX + (e.clientX - rect.left - rect.width / 2) / this.zoom; + const beforeZoomMouseY = this.panY + (e.clientY - rect.top - rect.height / 2) / this.zoom; + + // 2. 줌 배율 변경 + const zoomSpeed = 0.001; + const scaleChange = Math.exp(-delta * zoomSpeed); // 휠 방향 보정 + const nextZoom = Math.min(Math.max(this.zoom * scaleChange, 0.1), 5); // 0.1배 ~ 5배 제한 + + // 3. 마우스 지점 고정을 위한 새로운 panX, panY 역계산 + // 줌 후에도 마우스 아래의 월드 좌표가 동일하게 유지되도록 중심점을 이동 + this.zoom = nextZoom; + this.panX = beforeZoomMouseX - (e.clientX - rect.left - rect.width / 2) / this.zoom; + this.panY = beforeZoomMouseY - (e.clientY - rect.top - rect.height / 2) / this.zoom; + + this.applyViewBox(); + } + + /** 현재 카메라 상태 반환 (NodeItem 등에 전달될 값) */ + getCurrentTransform() { + const rect = this.canvas.getBoundingClientRect(); + const viewWidth = rect.width / this.zoom; + const viewHeight = rect.height / this.zoom; + + return { + x: -(this.panX - viewWidth / 2), + y: -(this.panY - viewHeight / 2), + scale: this.zoom, + }; + } + + /** 외부(ResizeObserver)에서 호출할 수 있도록 제공 */ + handleResize() { + this.applyViewBox(); + } + + /** + * Screen(clientX/clientY) → World(viewBox 좌표) 변환. + * node.x/y, QuadTree 탐색 범위는 모두 "World 좌표"를 사용 + * viewBox를 기준으로 변환하므로 pan/zoom 상태가 자동 반영 + */ + screenToWorld(clientX: number, clientY: number) { + const rect = this.canvas.getBoundingClientRect(); + const vb = this.canvas.viewBox.baseVal; + + const scaleX = rect.width / vb.width; + const scaleY = rect.height / vb.height; + + return { + x: vb.x + (clientX - rect.left) / scaleX, + y: vb.y + (clientY - rect.top) / scaleY, + }; + } +} diff --git a/frontend/src/features/mindmap/hooks/useMindmapContext.ts b/frontend/src/features/mindmap/hooks/useMindmapContext.ts new file mode 100644 index 000000000..423c315a1 --- /dev/null +++ b/frontend/src/features/mindmap/hooks/useMindmapContext.ts @@ -0,0 +1,63 @@ +import { useCallback, useContext, useSyncExternalStore } from "react"; + +import { MindMapRefContext, MindMapStateContext } from "@/features/mindmap/providers/MindmapContext"; +import { EMPTY_DRAG_SESSION_SNAPSHOT, EMPTY_INTERACTION_SNAPSHOT } from "@/features/mindmap/types/interaction"; + +export const useMindMapActions = () => { + const context = useContext(MindMapRefContext); + if (!context) throw new Error("Provider missing!"); + return context.actions; +}; + +export const useMindMapCore = () => { + const context = useContext(MindMapRefContext); + + if (context === undefined) { + throw new Error("useMindMapCore must be used within a MindMapProvider"); + } + + return context?.core; +}; + +export const useMindMapVersion = () => { + const context = useContext(MindMapStateContext); + if (!context) throw new Error("Provider missing!"); + return context.version; +}; + +/** + * 드래그/상호작용 프레임 전용 (마우스 move마다 발생) + * - Static 노드/Edge 렌더 트리와 분리된 컴포넌트(InteractionLayer 등)에서만 사용해야 함 + */ +export const useMindMapInteractionFrame = () => { + const context = useContext(MindMapRefContext); + if (!context) throw new Error("Provider missing!"); + + const { core } = context; + + const subscribe = useCallback( + (onStoreChange: () => void) => + core.getBroker().subscribe({ key: "INTERACTION_FRAME", callback: onStoreChange }), + [core], + ); + + return useSyncExternalStore(subscribe, () => core.getInteractionSnapshot() ?? EMPTY_INTERACTION_SNAPSHOT); +}; + +/** + * 드래그 세션 전용 (start/end에만 발생) + * - 원본 노드를 흐리게(ghost) 처리하는 스타일 주입 등에 사용 + */ +export const useMindMapDragSession = () => { + const context = useContext(MindMapRefContext); + if (!context) throw new Error("Provider missing!"); + + const { core } = context; + + const subscribe = useCallback( + (onStoreChange: () => void) => core.getBroker().subscribe({ key: "DRAG_SESSION", callback: onStoreChange }), + [core], + ); + + return useSyncExternalStore(subscribe, () => core.getDragSessionSnapshot() ?? EMPTY_DRAG_SESSION_SNAPSHOT); +}; diff --git a/frontend/src/features/mindmap/hooks/useMindmapNode.ts b/frontend/src/features/mindmap/hooks/useMindmapNode.ts new file mode 100644 index 000000000..460c4aae1 --- /dev/null +++ b/frontend/src/features/mindmap/hooks/useMindmapNode.ts @@ -0,0 +1,17 @@ +import { useCallback, useContext, useSyncExternalStore } from "react"; + +import { MindMapRefContext } from "@/features/mindmap/providers/MindmapContext"; +import { NodeId } from "@/features/mindmap/types/node"; + +export const useMindMapNode = (nodeId: NodeId) => { + const context = useContext(MindMapRefContext); + if (!context) throw new Error("MindMapProvider missing!"); + + const { core } = context; + const subscribe = useCallback( + (onStoreChange: () => void) => core.getBroker().subscribe({ key: nodeId, callback: onStoreChange }), + [core, nodeId], + ); + + return useSyncExternalStore(subscribe, () => core.getTree().safeGetNode(nodeId)); +}; diff --git a/frontend/src/features/mindmap/hooks/useViewportEvents.ts b/frontend/src/features/mindmap/hooks/useViewportEvents.ts index d441d52f5..8e0f0149b 100644 --- a/frontend/src/features/mindmap/hooks/useViewportEvents.ts +++ b/frontend/src/features/mindmap/hooks/useViewportEvents.ts @@ -1,74 +1,55 @@ -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; -import { useViewportRef } from "@/features/mindmap/hooks/useViewportRef"; +import { useMindMapCore } from "@/features/mindmap/hooks/useMindmapContext"; -/** 사용자의 입력 이벤트를 감지, 바인딩하고, Renderer에 명령을 내리는 로직 */ -export function useViewportEvents(canvasRef: React.RefObject) { - // Provider에서 rendererRef 가져오기 - const rendererRef = useViewportRef(); - const dragRef = useRef({ isDragging: false, lastX: 0, lastY: 0 }); +/** 브라우저 외부 이벤트를 감지하고 mindmap 내부 broker로 전달 */ +export function useViewportEvents() { + const mindmap = useMindMapCore(); // 코어에서 broker를 가져오기 위함 useEffect(() => { - const svg = canvasRef.current; + if (!mindmap || !mindmap.getIsReady()) return; + + const svg = mindmap.getCanvas(); if (!svg) return; - //[zoom] 마우스 휠 + const broker = mindmap.getBroker(); + // 1. 휠 이벤트 전달 const handleWheel = (e: WheelEvent) => { - e.preventDefault(); // 브라우저 기본 스크롤 방지 - rendererRef.current?.zoomHandler(e.deltaY, { - clientX: e.clientX, - clientY: e.clientY, - }); + e.preventDefault(); + broker.publish("RAW_WHEEL", e); }; - //[zoom] 키보드 (Ctrl + / -) + // 2. 키보드 이벤트 전달 const handleKeyDown = (e: KeyboardEvent) => { - if (!(e.ctrlKey || e.metaKey)) return; - if (["+", "=", "-"].includes(e.key)) { - e.preventDefault(); // 브라우저 기본 확대 방지 - - const delta = e.key === "-" ? 100 : -100; - const rect = svg.getBoundingClientRect(); - - // 키보드 줌은 마우스 위치가 없으므로 화면 중앙 좌표를 전달 - rendererRef.current?.zoomHandler(delta, { - clientX: rect.left + rect.width / 2, - clientY: rect.top + rect.height / 2, - }); + if (e.key === "Delete" || e.key === "Backspace") { + broker.publish("NODE_DELETE", e); + return; } + if (!(e.ctrlKey || e.metaKey)) return; + broker.publish("RAW_KEYDOWN", e); }; - //[panning] 마우스 드래그 + // 3. 마우스 이벤트 전달 const handleMouseDown = (e: MouseEvent) => { - dragRef.current = { isDragging: true, lastX: e.clientX, lastY: e.clientY }; - }; - - const handleMouseMove = (e: MouseEvent) => { - if (!dragRef.current.isDragging || !rendererRef.current) return; - - const dx = e.clientX - dragRef.current.lastX; - const dy = e.clientY - dragRef.current.lastY; - - // Renderer에 이동량 전달 - rendererRef.current.panningHandler(dx, dy); - - dragRef.current.lastX = e.clientX; - dragRef.current.lastY = e.clientY; + mindmap.handleMouseDown(e as unknown as React.MouseEvent); }; - const handleMouseUp = () => { - dragRef.current.isDragging = false; + // 우클릭 기본 동작 차단, 패닝으로 사용 + const handleContextMenu = (e: MouseEvent) => { + e.preventDefault(); }; + const handleMouseMove = (e: MouseEvent) => broker.publish("RAW_MOUSE_MOVE", e); + const handleMouseUp = (e: MouseEvent) => broker.publish("RAW_MOUSE_UP", e); - // 이벤트 리스너 등록 + // 이벤트 등록 svg.addEventListener("wheel", handleWheel, { passive: false }); + svg.addEventListener("contextmenu", handleContextMenu); window.addEventListener("keydown", handleKeyDown); svg.addEventListener("mousedown", handleMouseDown); window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mouseup", handleMouseUp); window.addEventListener("mouseleave", handleMouseUp); - // cleanUp: 컴포넌트가 사라질 때 리스너 제거 return () => { svg.removeEventListener("wheel", handleWheel); window.removeEventListener("keydown", handleKeyDown); @@ -77,5 +58,5 @@ export function useViewportEvents(canvasRef: React.RefObject { - const context = useContext(ViewportContext); - - if (!context) { - throw new Error("useViewport는 반드시 ViewportProvider 내부에서 사용해야 합니다."); - } - - return context; -}; diff --git a/frontend/src/features/mindmap/node/components/add_node/AddNode.tsx b/frontend/src/features/mindmap/node/components/add_node/AddNode.tsx index bdb16de07..beda98033 100644 --- a/frontend/src/features/mindmap/node/components/add_node/AddNode.tsx +++ b/frontend/src/features/mindmap/node/components/add_node/AddNode.tsx @@ -4,6 +4,7 @@ import { ComponentPropsWithoutRef } from "react"; import AddNodeArrow from "@/features/mindmap/node/components/add_node/AddNodeArrow"; import AddNodeDot from "@/features/mindmap/node/components/add_node/AddNodeDot"; import { NodeColor } from "@/features/mindmap/node/constants/colors"; +import { AddNodeDirection } from "@/features/mindmap/types/node"; import { cn } from "@/utils/cn"; const addNodeVariants = cva( @@ -21,7 +22,7 @@ const addNodeVariants = cva( type Props = ComponentPropsWithoutRef<"div"> & VariantProps & { color: NodeColor; - direction: "left" | "right"; + direction: AddNodeDirection; }; export default function AddNode({ color, direction, className, ...rest }: Props) { diff --git a/frontend/src/features/mindmap/node/components/add_node/AddNodeArrow.tsx b/frontend/src/features/mindmap/node/components/add_node/AddNodeArrow.tsx index 3927e5a29..9d4a7193d 100644 --- a/frontend/src/features/mindmap/node/components/add_node/AddNodeArrow.tsx +++ b/frontend/src/features/mindmap/node/components/add_node/AddNodeArrow.tsx @@ -1,11 +1,12 @@ import { ComponentPropsWithoutRef } from "react"; import { COLOR_CLASS_MAP, NodeColor } from "@/features/mindmap/node/constants/colors"; +import { AddNodeDirection } from "@/features/mindmap/types/node"; import Icon from "@/shared/components/icon/Icon"; import { cn } from "@/utils/cn"; type DirectionVariantProps = { - direction: "left" | "right"; + direction: AddNodeDirection; }; type Props = Omit, "color"> & diff --git a/frontend/src/features/mindmap/node/components/new_node/NewNode.tsx b/frontend/src/features/mindmap/node/components/new_node/NewNode.tsx deleted file mode 100644 index 88bcb7bed..000000000 --- a/frontend/src/features/mindmap/node/components/new_node/NewNode.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { ComponentPropsWithoutRef, type ReactNode } from "react"; - -import { cn } from "@/utils/cn"; - -type NewNodeProps = ComponentPropsWithoutRef<"div"> & { - children?: ReactNode; -}; - -export default function NewNode({ className, children, ...rest }: NewNodeProps) { - const content = children ?? "새로운 노드"; - return ( -
- {content} -
- ); -} diff --git a/frontend/src/features/mindmap/node/components/node/NodeItem.tsx b/frontend/src/features/mindmap/node/components/node/NodeItem.tsx new file mode 100644 index 000000000..a1ff518e5 --- /dev/null +++ b/frontend/src/features/mindmap/node/components/node/NodeItem.tsx @@ -0,0 +1,77 @@ +import { useEffect, useRef } from "react"; + +import { useMindMapActions } from "@/features/mindmap/hooks/useMindmapContext"; +import { useMindMapNode } from "@/features/mindmap/hooks/useMindmapNode"; +import { Node } from "@/features/mindmap/node/components/node/Node"; +import NodeCenter from "@/features/mindmap/node/components/node_center/NodeCenter"; +import { NodeId } from "@/features/mindmap/types/node"; + +export default function NodeItem({ nodeId, measure = true }: { nodeId: NodeId; measure?: boolean }) { + const nodeData = useMindMapNode(nodeId); + const { updateNodeSize } = useMindMapActions(); + + const contentRef = useRef(null); + + if (!nodeData) return null; + + const { x, y, width, height, data } = nodeData; + const isRoot = nodeData.type === "root"; + const w = width || 200; + const h = height || 200; + + const { addNodeDirection } = nodeData; + + useEffect(() => { + if (!measure) return; + if (!contentRef.current || !nodeData) return; + + const rect = contentRef.current.getBoundingClientRect(); + const svg = contentRef.current.closest("svg") as SVGSVGElement; + const svgRect = svg.getBoundingClientRect(); + const viewBox = svg.viewBox.baseVal; + + const scaleX = svgRect.width / viewBox.width; + const scaleY = svgRect.height / viewBox.height; + + const worldWidth = rect.width / scaleX; + const worldHeight = rect.height / scaleY; + + updateNodeSize(nodeId, worldWidth, worldHeight); + }, [measure, nodeData?.data.contents, nodeId]); + + return ( + +
+ {isRoot ? ( + /* 루트 노드일 때 */ + + ) : ( + /* 일반 노드일 때 */ + + + console.log(`클릭: ${data.contents}`)} + > + {data.contents || "하위 내용"} + + + )} +
+
+ ); +} diff --git a/frontend/src/features/mindmap/node/components/node_center/NodeCenter.tsx b/frontend/src/features/mindmap/node/components/node_center/NodeCenter.tsx index 790f41e46..921710906 100644 --- a/frontend/src/features/mindmap/node/components/node_center/NodeCenter.tsx +++ b/frontend/src/features/mindmap/node/components/node_center/NodeCenter.tsx @@ -1,27 +1,39 @@ -import { ComponentPropsWithoutRef } from "react"; - +// NodeCenter.tsx import AddNode from "@/features/mindmap/node/components/add_node/AddNode"; +import { cn } from "@/utils/cn"; -type Props = ComponentPropsWithoutRef<"div"> & { +type NodeCenterProps = { username?: string; + className?: string; + children?: React.ReactNode; }; const PRIMARY_COLOR = "violet"; -export default function NodeCenter({ username = "", className, ...rest }: Props) { +export default function NodeCenter({ username = "", className, children }: NodeCenterProps) { const label = username ? `${username}의\n마인드맵` : "마인드맵"; return ( -
+
+ {/* 왼쪽 버튼: 항상 존재 */} -
+ {children} + + {/* 중앙 원형 콘텐츠 */} +
{label}
+ + {/* 오른쪽 버튼: 항상 존재 */} & VariantProps; + +const tempNodeVariants = cva("", { + variants: { + type: { + new: "flex w-40 min-w-40 px-4.5 py-5 justify-center items-center gap-2.5 typo-body-14-medium rounded-xl border-2 border-gray-500 bg-base-white shadow-[0_0_15px_0_rgba(43,46,67,0.15)]", + ghost: `w-[${TEMP_NODE_SIZE.ghost.width}px] h-[${TEMP_NODE_SIZE.ghost.height}px] h-10.5 rounded-xl bg-node-blue-op-100`, + }, + }, +}); + +export default function TempNode({ type = "ghost", className, ...rest }: TempNodeProps) { + const isGhost = type === "ghost"; + + const content = isGhost ? null : "새로운 노드"; + + return ( +
+ {content} +
+ ); +} diff --git a/frontend/src/features/mindmap/pages/MindmapPage.tsx b/frontend/src/features/mindmap/pages/MindmapPage.tsx index 9689ecf36..508defd4c 100644 --- a/frontend/src/features/mindmap/pages/MindmapPage.tsx +++ b/frontend/src/features/mindmap/pages/MindmapPage.tsx @@ -1,5 +1,16 @@ +import { useRef } from "react"; + +import MindMapShowcase from "@/features/mindmap/pages/MindmapShowcase"; +import { MindMapProvider } from "@/features/mindmap/providers/MindmapProvider"; + const MindmapPage = () => { - return <>mmm; + const canvasRef = useRef(null); + + return ( + + + + ); }; export default MindmapPage; diff --git a/frontend/src/features/mindmap/pages/MindmapShowcase.tsx b/frontend/src/features/mindmap/pages/MindmapShowcase.tsx new file mode 100644 index 000000000..8708f2c7d --- /dev/null +++ b/frontend/src/features/mindmap/pages/MindmapShowcase.tsx @@ -0,0 +1,17 @@ +import MindMapRenderer from "@/features/mindmap/components/MindMapRenderer"; + +interface MindMapShowcaseProps { + canvasRef: React.RefObject; +} + +export default function MindMapShowcase({ canvasRef }: MindMapShowcaseProps) { + return ( +
+
+ + + +
+
+ ); +} diff --git a/frontend/src/features/mindmap/providers/MindmapContext.ts b/frontend/src/features/mindmap/providers/MindmapContext.ts new file mode 100644 index 000000000..2538ec525 --- /dev/null +++ b/frontend/src/features/mindmap/providers/MindmapContext.ts @@ -0,0 +1,21 @@ +import { createContext } from "react"; + +import MindMapCore from "@/features/mindmap/core/MindMapCore"; +import { AddNodeDirection, NodeDirection, NodeId } from "@/features/mindmap/types/node"; + +export type MindMapRefContextType = { + core: MindMapCore; + actions: { + addNode: (baseId: NodeId, dir: NodeDirection, addNode: AddNodeDirection) => void; + deleteNode: (nodeId: NodeId) => void; + moveNode: (targetId: NodeId, movingId: NodeId, dir: NodeDirection) => void; + updateNodeSize: (nodeId: NodeId, w: number, h: number) => void; + }; +}; + +export type MindMapStateContextType = { + version: number; +}; + +export const MindMapRefContext = createContext(null); +export const MindMapStateContext = createContext(null); diff --git a/frontend/src/features/mindmap/providers/MindmapProvider.tsx b/frontend/src/features/mindmap/providers/MindmapProvider.tsx index 3f8f74b04..e1e32fe67 100644 --- a/frontend/src/features/mindmap/providers/MindmapProvider.tsx +++ b/frontend/src/features/mindmap/providers/MindmapProvider.tsx @@ -1,145 +1,71 @@ -/** - * 해당 파일에 여러 훅, 로직이 함께 있습니다. - * 하나가 수정되면 보통 다 수정되므로 파일 왔다갔다 없이 빠르게 수정할 수 있도록 함께 두었습니다. - * 이후에 마인드맵 로직이 어느정도 굳어졌다 싶으면 분리할 예정입니다. - */ -import { createContext, useCallback, useContext, useMemo, useRef, useState, useSyncExternalStore } from "react"; - -import { NodeId } from "@/features/mindmap/types/mindmapType"; -import MindmapLayoutManager from "@/features/mindmap/utils/MindmapLayoutManager"; -import TreeContainer from "@/features/mindmap/utils/TreeContainer"; -import { EventBroker } from "@/utils/EventBroker"; - -type MindMapRefContextType = { - container: TreeContainer; - broker: EventBroker; - actions: { - addNode: (parentId: NodeId) => void; - deleteNode: (nodeId: NodeId) => void; - moveNode: (targetId: NodeId, movingId: NodeId) => void; - updateNodeSize: (nodeId: NodeId, width: number, height: number) => void; - forceLayout: () => void; - }; -}; +import { useEffect, useMemo, useState } from "react"; + +import MindMapCore from "@/features/mindmap/core/MindMapCore"; +import { MindMapRefContext, MindMapStateContext } from "@/features/mindmap/providers/MindmapContext"; +import { AddNodeDirection, NodeDirection, NodeId } from "@/features/mindmap/types/node"; + +export const MindMapProvider = ({ + children, + canvasRef, +}: { + children: React.ReactNode; + canvasRef?: React.RefObject; +}) => { + // 1. 엔진 인스턴스는 즉시 생성 + // 리렌더링 시 인스턴스가 유지되도록 useMemo 사용 + const core = useMemo(() => new MindMapCore(() => setVersion((v) => v + 1)), []); + + // 리액트 UI를 갱신하기 위한 버전 상태 + const [version, setVersion] = useState(0); -type MindMapStateContextType = { - version: number; -}; + useEffect(() => { + if (!canvasRef || !canvasRef.current) return; + const svg = canvasRef.current; -const MindMapRefContext = createContext(null); -const MindMapStateContext = createContext(null); + // 이미 초기화되었거나 SVG가 아직 없다면 중단 + if (!svg || core.getIsReady()) return; -// controller -export const MindMapProvider = ({ children }: { children: React.ReactNode }) => { - const storeRef = useRef<{ - container: TreeContainer; - broker: EventBroker; - layoutManager: MindmapLayoutManager; - } | null>(null); + // 2. SVG의 실제 크기를 감시 + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; - const [version, setVersion] = useState(0); + const { width, height } = entry.contentRect; - if (!storeRef.current) { - const broker = new EventBroker(); - const container = new TreeContainer({ broker, quadTreeManager: undefined }); - const layoutManager = new MindmapLayoutManager({ treeContainer: container }); - storeRef.current = { container, broker, layoutManager }; - } + // 실제 픽셀 크기가 확보된 시점에 엔진 초기화 + if (width > 0 && height > 0) { + core.initialize(svg); + // 초기화는 한 번이면 족하므로 감시 중단 + observer.disconnect(); + } + }); - const { container, broker, layoutManager } = storeRef.current; + observer.observe(svg); + return () => observer.disconnect(); + }, [canvasRef, core]); - // 지금은 필요한 메서드를 모두 호출하고 있음. 이는 유지보수를 매우매우 어렵게 함. - // 그래서 이후 디펜던시 관계를 다시 정립해야할거임. + // 3. 외부 노출용 액션 (core가 상수로 존재하므로 내부 체크 생략 가능) const actions = useMemo( () => ({ - addNode: (parentId: NodeId) => { - container.appendChild({ parentNodeId: parentId }); - layoutManager.invalidate(parentId); - const rootId = container.getRootId(); - if (rootId) layoutManager.updateLayout({ rootId }); - setVersion((v) => v + 1); - }, - deleteNode: (nodeId: NodeId) => { - const parentId = container.getParentId(nodeId); - container.delete({ nodeId }); - if (parentId) { - layoutManager.invalidate(parentId); - const rootId = container.getRootId(); - if (rootId) layoutManager.updateLayout({ rootId }); - } - setVersion((v) => v + 1); - }, - updateNodeSize: (nodeId: NodeId, width: number, height: number) => { - const node = container.safeGetNode(nodeId); - if (node && (node.width !== width || node.height !== height)) { - container.update({ nodeId, newNodeData: { width, height } }); - layoutManager.invalidate(nodeId); - const rootId = container.getRootId(); - if (rootId) layoutManager.updateLayout({ rootId }); - setVersion((v) => v + 1); - } - }, - moveNode: (targetId: NodeId, movingId: NodeId) => { - const oldParentId = container.getParentId(movingId); - container.moveTo({ baseNodeId: targetId, movingNodeId: movingId, direction: "child" }); - layoutManager.invalidate(targetId); - if (oldParentId) layoutManager.invalidate(oldParentId); - const rootId = container.getRootId(); - if (rootId) layoutManager.updateLayout({ rootId }); - setVersion((v) => v + 1); - }, - forceLayout: () => { - const rootId = container.getRootId(); - if (rootId) layoutManager.updateLayout({ rootId }); - setVersion((v) => v + 1); + addNode: (parentId: NodeId, direction: NodeDirection, addNodeDirection: AddNodeDirection) => { + core.addNode(parentId, direction, addNodeDirection); }, + deleteNode: (nodeId: NodeId) => core.deleteNode(nodeId), + updateNodeSize: (nodeId: NodeId, width: number, height: number) => + core.updateNodeSize(nodeId, width, height), + moveNode: (targetId: NodeId, movingId: NodeId, direction: NodeDirection) => + core.moveNode(targetId, movingId, direction), }), - [container, broker, layoutManager], + [core], ); - const refValue = useMemo(() => ({ container, broker, actions }), [container, broker, actions]); + // core가 인스턴스화 되었을 때 컨텍스트 값 생성 + const controller = useMemo(() => ({ core, actions }), [core, actions]); const stateValue = useMemo(() => ({ version }), [version]); return ( - + {children} ); }; - -export const useNode = (nodeId: NodeId) => { - const context = useContext(MindMapRefContext); - if (!context) throw new Error("Provider missing!"); - const { container, broker } = context; - - const subscribe = useCallback( - (onStoreChange: () => void) => broker.subscribe({ key: nodeId, callback: onStoreChange }), - [broker, nodeId], - ); - - const getSnapshot = useCallback(() => container.safeGetNode(nodeId), [container, nodeId]); - - return useSyncExternalStore(subscribe, getSnapshot); -}; - -export const useMindmapActions = () => { - const context = useContext(MindMapRefContext); - - if (!context) throw new Error("Provider missing!"); - - return context.actions; -}; - -export const useMindmapContainer = () => { - const context = useContext(MindMapRefContext); - if (!context) throw new Error("Provider missing!"); - return context.container; -}; - -// version만 -// 해당 훅은 리렌더링 강제 트리거를 위한 값인 version을 사용하기 위한것인데.. 이후에 성능을 위해서는 해당 값을 제거해야할 필요가 있을것. 일단은 임시로 넣어두었습니다. -export const useMindmapVersion = () => { - const context = useContext(MindMapStateContext); - if (!context) throw new Error("Provider missing!"); - return context.version; -}; diff --git a/frontend/src/features/mindmap/providers/ViewportProvider.tsx b/frontend/src/features/mindmap/providers/ViewportProvider.tsx deleted file mode 100644 index f039092a5..000000000 --- a/frontend/src/features/mindmap/providers/ViewportProvider.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import React, { createContext, ReactNode, useRef } from "react"; - -import Renderer from "@/features/mindmap/utils/Renderer"; - -// Renderer 인스턴스를 담을 컨텍스트 -export const ViewportContext = createContext | null>(null); - -/** Renderer 인스턴스를 생성, 하위 컴포넌트에 공급 */ -export const ViewportProvider = ({ children }: { children: ReactNode }) => { - const rendererRef = useRef(null); - - return {children}; -}; diff --git a/frontend/src/features/mindmap/types/events.ts b/frontend/src/features/mindmap/types/events.ts new file mode 100644 index 000000000..bba7c1871 --- /dev/null +++ b/frontend/src/features/mindmap/types/events.ts @@ -0,0 +1,39 @@ +import { NodeDirection, NodeId } from "@/features/mindmap/types/node"; + +/** * 정해진 규칙이 있는 정적 이벤트들 + */ +type StaticEvents = { + // 1. 입력 신호 + RAW_MOUSE_DOWN: React.MouseEvent | MouseEvent; + RAW_MOUSE_MOVE: React.MouseEvent | MouseEvent; + RAW_MOUSE_UP: React.MouseEvent | MouseEvent; + RAW_WHEEL: React.WheelEvent | WheelEvent; + RAW_KEYDOWN: KeyboardEvent; + NODE_DELETE: KeyboardEvent; + + // 2. [추가] 노드 관련 상호작용 + // 어떤 노드가 클릭되었는지 ID와 이벤트 객체를 함께 전달 + NODE_CLICK: { nodeId: NodeId; event: React.MouseEvent | MouseEvent }; + NODE_SELECT: { nodeId: NodeId | null }; // 노드 선택 해제는 null + + // 3. 매니저 명령 + VIEWPORT_PAN: { dx: number; dy: number }; + VIEWPORT_ZOOM: { delta: number; clientX: number; clientY: number }; + NODE_MOVE_REQUEST: { targetId: NodeId; movingId: NodeId; direction: NodeDirection }; + + // 4. 상태 알림 + RENDER_UPDATE: undefined; + INTERACTION_FRAME: undefined; + DRAG_SESSION: undefined; + + // 5. 에러 + NODE_DELETE_ERROR: string; +}; + +/** * + * 정적 이벤트는 고유 타입을 유지하고, + * 그 외의 문자열(NodeId)은 '신호 전용(unknown/undefined)'으로 처리합니다. + */ +export type MindMapEvents = StaticEvents & { + [nodeId: string]: unknown; +}; diff --git a/frontend/src/features/mindmap/types/interaction.ts b/frontend/src/features/mindmap/types/interaction.ts new file mode 100644 index 000000000..b7bf259bf --- /dev/null +++ b/frontend/src/features/mindmap/types/interaction.ts @@ -0,0 +1,47 @@ +import { NodeDirection, NodeId } from "@/features/mindmap/types/node"; + +export type InteractionMode = "idle" | "potential_drag" | "dragging" | "panning" | "pending_creation"; + +export type BaseNodeInfo = { + targetId: NodeId | null; + direction?: NodeDirection | null; +}; + +export type InteractionStatus = { + mode: InteractionMode; + draggingNodeId: NodeId | null; + dragDelta: { x: number; y: number }; + mousePos: { x: number; y: number }; + dragSubtreeIds: Set | null; + baseNode: BaseNodeInfo; +}; + +export type InteractionSnapshot = { + mode: InteractionMode; + draggingNodeId: NodeId | null; + dragDelta: { x: number; y: number }; + mousePos: { x: number; y: number }; + dragSubtreeIds: Set | null; + baseNode: BaseNodeInfo; +}; + +export type DragSessionSnapshot = { + isDragging: boolean; + draggingNodeId: NodeId | null; + dragSubtreeIds: Set | null; +}; + +export const EMPTY_INTERACTION_SNAPSHOT: InteractionSnapshot = { + mode: "idle", + draggingNodeId: null, + dragDelta: { x: 0, y: 0 }, + mousePos: { x: 0, y: 0 }, + dragSubtreeIds: null, + baseNode: { targetId: null, direction: null }, +}; + +export const EMPTY_DRAG_SESSION_SNAPSHOT: DragSessionSnapshot = { + isDragging: false, + draggingNodeId: null, + dragSubtreeIds: null, +}; diff --git a/frontend/src/features/mindmap/types/mindmapType.ts b/frontend/src/features/mindmap/types/mindmapType.ts deleted file mode 100644 index 5f163aba9..000000000 --- a/frontend/src/features/mindmap/types/mindmapType.ts +++ /dev/null @@ -1,33 +0,0 @@ -export type NodeId = string; - -export type Node = { - id: NodeId; - - x: number; - y: number; - - width: number; - height: number; - - data: NodeData; -}; - -export type NodeType = "root" | "normal"; - -export type NodeElement = Node & { - parentId: NodeId; - - // double linked list - nextId: NodeId | null; - prevId: NodeId | null; - - firstChildId: NodeId | null; - lastChildId: NodeId | null; - - type: NodeType; -}; - -export type NodeData = { - contents: string; - pakxepakxe?: "뭔 타입이 올지 모르겠으니.."; -}; diff --git a/frontend/src/features/mindmap/types/mindmap_interaction_type.ts b/frontend/src/features/mindmap/types/mindmap_interaction_type.ts deleted file mode 100644 index ac23394e7..000000000 --- a/frontend/src/features/mindmap/types/mindmap_interaction_type.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { NodeId } from "@/features/mindmap/types/mindmapType"; - -export type InteractionMode = "idle" | "potential_drag" | "dragging" | "panning"; - -export type ViewportTransform = { - x: number; - y: number; - scale: number; -}; - -export interface BaseNodeInfo { - targetId: NodeId | null; -} diff --git a/frontend/src/features/mindmap/types/node.ts b/frontend/src/features/mindmap/types/node.ts new file mode 100644 index 000000000..472418041 --- /dev/null +++ b/frontend/src/features/mindmap/types/node.ts @@ -0,0 +1,36 @@ +import { Point } from "@/features/mindmap/types/spatial"; + +/** + * 마인드맵 데이터 구조 관련 + */ +export type NodeId = string; + +export type NodeType = "root" | "normal"; + +export type AddNodeDirection = "left" | "right"; + +export type NodeDirection = "prev" | "next" | "child"; + +export type NodeData = { + contents: string; + // pakxepakxe?: any; // 추가적인 확장 데이터를 위한 필드 +}; + +export type Node = Point & { + width: number; + height: number; + data: NodeData; +}; + +export type NodeElement = Node & { + parentId: NodeId; + type: NodeType; + + // 계층 이동을 위한 Double Linked List + firstChildId: NodeId | null; + lastChildId: NodeId | null; + nextId: NodeId | null; + prevId: NodeId | null; + + addNodeDirection: AddNodeDirection; +}; diff --git a/frontend/src/features/mindmap/types/spatial.ts b/frontend/src/features/mindmap/types/spatial.ts new file mode 100644 index 000000000..85f142902 --- /dev/null +++ b/frontend/src/features/mindmap/types/spatial.ts @@ -0,0 +1,22 @@ +/** + * 공간 관련 + */ +export type Point = { + x: number; //노드 정중아 world 좌표 + y: number; + id: string; +}; + +export type Rect = { + minX: number; + maxX: number; + minY: number; + maxY: number; +}; + +export type ViewportTransform = { + // 카메라 설정 값 + x: number; + y: number; + scale: number; +}; diff --git a/frontend/src/features/mindmap/utils/MindmapInteractionManager.ts b/frontend/src/features/mindmap/utils/MindmapInteractionManager.ts deleted file mode 100644 index 7cba9c18a..000000000 --- a/frontend/src/features/mindmap/utils/MindmapInteractionManager.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { MOUSE_DOWN } from "@/constants/mouse"; -import { ATTRIBUTE_NAME_OF_NODE_ID } from "@/features/mindmap/constants/node"; -import { BaseNodeInfo, InteractionMode, ViewportTransform } from "@/features/mindmap/types/mindmap_interaction_type"; -import { NodeId } from "@/features/mindmap/types/mindmapType"; -import TreeContainer from "@/features/mindmap/utils/TreeContainer"; -import { calcDistance } from "@/utils/calc_distance"; - -const DRAG_THRESHOLD = 5; -const BASE_NODE_DETECTION_THRESHOLD = 100; - -export class MindmapInteractionManager { - private container: TreeContainer; - private onUpdate: () => void; - private onPan: (dx: number, dy: number) => void; - private onMoveNode: (targetId: NodeId, movingId: NodeId) => void; - - private mode: InteractionMode = "idle"; - private transform: ViewportTransform = { x: 0, y: 0, scale: 1 }; - - private startMousePos = { x: 0, y: 0 }; - private lastMousePos = { x: 0, y: 0 }; - - private draggingNodeId: NodeId | null = null; - private dragDelta = { x: 0, y: 0 }; - - private dragSubtreeIds: Set | null = null; - - private baseNode: BaseNodeInfo = { - targetId: null, - }; - - constructor( - container: TreeContainer, - onUpdate: () => void, - onPan: (dx: number, dy: number) => void, - onMoveNode: (targetId: NodeId, movingId: NodeId) => void, - ) { - this.container = container; - this.onUpdate = onUpdate; - this.onPan = onPan; - this.onMoveNode = onMoveNode; - } - - public setTransform(transform: ViewportTransform) { - this.transform = transform; - } - - public getInteractionStatus() { - return { - mode: this.mode, - draggingNodeId: this.draggingNodeId, - dragDelta: this.dragDelta, - dragSubtreeIds: this.dragSubtreeIds, - baseNode: this.baseNode, - }; - } - - private projectScreenToWorld(clientX: number, clientY: number) { - return { - x: (clientX - this.transform.x) / this.transform.scale, - y: (clientY - this.transform.y) / this.transform.scale, - }; - } - - public handleMouseDown = (e: React.MouseEvent) => { - // 좌클릭 아니면 무시 - if (e.button !== MOUSE_DOWN.left) { - return; - } - - this.startMousePos = { x: e.clientX, y: e.clientY }; - this.lastMousePos = { x: e.clientX, y: e.clientY }; - - // TODO: - const targetEl = (e.target as HTMLElement).closest(`[${ATTRIBUTE_NAME_OF_NODE_ID}]`); - const nodeId = targetEl?.getAttribute(ATTRIBUTE_NAME_OF_NODE_ID); - - if (nodeId) { - const node = this.container.safeGetNode(nodeId); - if (!node || node.type === "root") { - return; - } - - this.draggingNodeId = nodeId; - this.mode = "potential_drag"; - this.dragDelta = { x: 0, y: 0 }; - } else { - this.mode = "panning"; - } - }; - - public handleMouseMove = (e: React.MouseEvent) => { - const clientX = e.clientX; - const clientY = e.clientY; - - switch (this.mode) { - case "idle": - break; - - case "panning": { - const dx = clientX - this.lastMousePos.x; - const dy = clientY - this.lastMousePos.y; - - this.onPan(dx, dy); - this.lastMousePos = { x: clientX, y: clientY }; - - break; - } - - case "potential_drag": { - const dist = calcDistance(clientX, clientY, this.startMousePos.x, this.startMousePos.y); - if (dist > DRAG_THRESHOLD) { - this.mode = "dragging"; - - if (this.draggingNodeId) { - this.dragSubtreeIds = this.container.getAllDescendantIds(this.draggingNodeId); - } - - this.onUpdate(); - } - - break; - } - - case "dragging": { - if (!this.draggingNodeId) { - return; - } - - const dx = (clientX - this.lastMousePos.x) / this.transform.scale; - const dy = (clientY - this.lastMousePos.y) / this.transform.scale; - - this.dragDelta = { - x: this.dragDelta.x + dx, - y: this.dragDelta.y + dy, - }; - - this.calcBaseNode(e); - - this.lastMousePos = { x: clientX, y: clientY }; - this.onUpdate(); - - break; - } - } - }; - - private calcBaseNode(e: React.MouseEvent) { - if (!this.draggingNodeId) { - return; - } - - const worldPos = this.projectScreenToWorld(e.clientX, e.clientY); - const checkX = worldPos.x; - const checkY = worldPos.y; - - let minDist = Infinity; - let nearestId: NodeId | null = null; - - for (const [id, node] of this.container.nodes) { - if (this.dragSubtreeIds?.has(id)) continue; - - const dist = calcDistance(node.x, node.y, checkX, checkY); - if (dist < minDist && dist < BASE_NODE_DETECTION_THRESHOLD) { - minDist = dist; - nearestId = id; - } - } - - this.baseNode.targetId = nearestId; - } - - public handleMouseUp = (_e: React.MouseEvent) => { - if (this.mode === "dragging" && this.draggingNodeId && this.baseNode.targetId) { - if (this.baseNode.targetId !== this.draggingNodeId) { - this.onMoveNode(this.baseNode.targetId, this.draggingNodeId); - } - } - - this.clearStatus(); - - this.onUpdate(); - }; - - private clearStatus() { - this.mode = "idle"; - this.draggingNodeId = null; - this.dragDelta = { x: 0, y: 0 }; - this.dragSubtreeIds = null; - - this.baseNode.targetId = null; - } -} diff --git a/frontend/src/features/mindmap/utils/Renderer.ts b/frontend/src/features/mindmap/utils/Renderer.ts deleted file mode 100644 index 8fd2a7a42..000000000 --- a/frontend/src/features/mindmap/utils/Renderer.ts +++ /dev/null @@ -1,126 +0,0 @@ -import { NodeElement } from "@/features/mindmap/types/mindmapType"; -import { Rect } from "@/features/quad_tree/types/rect"; -import QuadTree from "@/features/quad_tree/utils/QuadTree"; - -/** - * Renderer - * canvas: 화면을 그리는 실제 SVG 엘리먼트 - * qt: 공간 데이터를 관리하는 쿼드 트리 인스턴스 - * bounds: 이동 및 확장이 제한되는 전체 쿼드 트리 영역 - * viewBox: 현재 사용자 화면에 보이는 가상 좌표 영역 - */ -export default class Renderer { - private canvas: SVGSVGElement; - private qt: QuadTree; - private viewBox: Rect; - - private readonly INITIAL_QUAD_FACTOR = 20; // 쿼드 트리 크기: 루트 노드의 n배 - private readonly INITIAL_VIEW_FACTOR = 6; // 초기 뷰포트 크기: 루트 노드의 n배 - - /** [Init] 루트 노드를 중앙에 배치하고 쿼드 트리와 줌인된 초기 뷰포트를 설정 */ - constructor(canvas: SVGSVGElement, rootNode: NodeElement) { - this.canvas = canvas; - - //쿼드 트리 영역 설정 - const quadW = rootNode.width * this.INITIAL_QUAD_FACTOR; - const quadH = rootNode.height * this.INITIAL_QUAD_FACTOR; - - const initialBounds = { - minX: rootNode.x - quadW / 2, - maxX: rootNode.x + quadW / 2, - minY: rootNode.y - quadH / 2, - maxY: rootNode.y + quadH / 2, - }; - this.qt = new QuadTree(initialBounds); - - //초기 카메라 위치 설정 - const rect = this.canvas.getBoundingClientRect(); - const canvasRatio = rect.width / rect.height; - - const viewHeight = rootNode.height * this.INITIAL_VIEW_FACTOR; - const viewWidth = viewHeight * canvasRatio; - - this.viewBox = { - minX: rootNode.x - viewWidth / 2, - maxX: rootNode.x + viewWidth / 2, - minY: rootNode.y - viewHeight / 2, - maxY: rootNode.y + viewHeight / 2, - }; - - this.applyViewBox(); - } - - /** 마우스 드래그를 통해 카메라 위치를 이동 */ - panningHandler(dx: number, dy: number): void { - const rect = this.canvas.getBoundingClientRect(); - const viewW = this.viewBox.maxX - this.viewBox.minX; - const viewH = this.viewBox.maxY - this.viewBox.minY; - - // 마우스의 픽셀 이동량(px)을 현재 확대 배율(ViewBox) 기준의 거리로 변환 - const sensitivity = 0.65; - const worldDx = (dx / rect.width) * viewW * sensitivity; - const worldDy = (dy / rect.height) * viewH * sensitivity; - - this.updateViewBox(this.viewBox.minX - worldDx, this.viewBox.minY - worldDy, viewW, viewH); - } - - /** 확대/축소 (마우스 포인터 지점 고정) */ - zoomHandler(delta: number, e: { clientX: number; clientY: number }): void { - const rect = this.canvas.getBoundingClientRect(); - const currentW = this.viewBox.maxX - this.viewBox.minX; - const currentH = this.viewBox.maxY - this.viewBox.minY; - - //줌 배율 결정 - const zoomSpeed = 0.001; - const scaleChange = Math.exp(delta * zoomSpeed); - - let nextW = currentW * scaleChange; - let nextH = currentH * scaleChange; - - //쿼드 트리 bounds 내로 줌아웃 최대 영역 제한 - const currentBounds = this.qt.getBounds(); - const boundsW = currentBounds.maxX - currentBounds.minX; - const boundsH = currentBounds.maxY - currentBounds.minY; - - if (nextW > boundsW) { - nextW = boundsW; - nextH = nextW / (rect.width / rect.height); - } - if (nextH > boundsH) { - nextH = boundsH; - nextW = nextH * (rect.width / rect.height); - } - - //마우스 좌표 고정 - const mouseRatioX = (e.clientX - rect.left) / rect.width; - const mouseRatioY = (e.clientY - rect.top) / rect.height; - - const worldMouseX = this.viewBox.minX + mouseRatioX * currentW; - const worldMouseY = this.viewBox.minY + mouseRatioY * currentH; - - const nextMinX = worldMouseX - mouseRatioX * nextW; - const nextMinY = worldMouseY - mouseRatioY * nextH; - - this.updateViewBox(nextMinX, nextMinY, nextW, nextH); - } - - /** 뷰포트의 위치와 크기를 최종 확정 */ - private updateViewBox(nextMinX: number, nextMinY: number, width: number, height: number): void { - // 매번 현재 쿼드 트리 최신 크기 참조 - const currentBounds = this.qt.getBounds(); - - this.viewBox.minX = Math.max(currentBounds.minX, Math.min(nextMinX, currentBounds.maxX - width)); - this.viewBox.minY = Math.max(currentBounds.minY, Math.min(nextMinY, currentBounds.maxY - height)); - this.viewBox.maxX = this.viewBox.minX + width; - this.viewBox.maxY = this.viewBox.minY + height; - - this.applyViewBox(); - } - - /** [Apply] 계산된 viewBox 데이터를 실제 SVG viewBox 속성 형식으로 변환하여 적용 */ - private applyViewBox(): void { - const width = this.viewBox.maxX - this.viewBox.minX; - const height = this.viewBox.maxY - this.viewBox.minY; - this.canvas.setAttribute("viewBox", `${this.viewBox.minX} ${this.viewBox.minY} ${width} ${height}`); - } -} diff --git a/frontend/src/features/mindmap/utils/node_geometry.ts b/frontend/src/features/mindmap/utils/node_geometry.ts new file mode 100644 index 000000000..3ce1bb612 --- /dev/null +++ b/frontend/src/features/mindmap/utils/node_geometry.ts @@ -0,0 +1,70 @@ +import { NodeElement } from "@/features/mindmap/types/node"; + +// * node.width에서 AddNode 영역만큼 제외해 content 벽 좌표를 계산 */ +const DEFAULT_OUTER_W = 200; // 측정 전 fallback +const DEFAULT_OUTER_H = 60; + +export const ADD_NODE_TOTAL_W = 62; + +export function getOuterSize(node: NodeElement) { + return { + w: node.width || DEFAULT_OUTER_W, + h: node.height || DEFAULT_OUTER_H, + }; +} + +/** + * Node.Content의 좌/우 벽을 월드 좌표로 반환 + * - root: AddNode가 양쪽 + * - normal: addNodeDirection에 따라 왼쪽/오른쪽 한쪽에만 AddNode + */ +export function getContentBounds(node: NodeElement) { + const { w, h } = getOuterSize(node); + const halfW = w / 2; + const halfH = h / 2; + + const outerLeft = node.x - halfW; + const outerRight = node.x + halfW; + + let contentLeft = outerLeft; + let contentRight = outerRight; + + if (node.type === "root") { + // 루트는 AddNode가 양쪽 -> 양쪽에서 62px씩 제외 + contentLeft = outerLeft + ADD_NODE_TOTAL_W; + contentRight = outerRight - ADD_NODE_TOTAL_W; + } else if (node.addNodeDirection === "right") { + // AddNode가 오른쪽에 있으니 오른쪽 벽에서 62px 제외 + contentLeft = outerLeft; + contentRight = outerRight - ADD_NODE_TOTAL_W; + } else { + // AddNode가 왼쪽 + contentLeft = outerLeft + ADD_NODE_TOTAL_W; + contentRight = outerRight; + } + + return { + left: contentLeft, + right: contentRight, + top: node.y - halfH, + bottom: node.y + halfH, + }; +} + +/** + * 부모-자식 edge 앵커: + * - child가 parent 오른쪽이면 parent.right -> child.left + * - child가 parent 왼쪽이면 parent.left -> child.right + */ +export function getParentChildEdgeAnchors(parent: NodeElement, child: NodeElement) { + const p = getContentBounds(parent); + const c = getContentBounds(child); + + const isRight = child.x >= parent.x; + + return { + start: { x: isRight ? p.right : p.left, y: parent.y }, + end: { x: isRight ? c.left : c.right, y: child.y }, + isRight, + }; +} diff --git a/frontend/src/features/mindmap/utils/path.ts b/frontend/src/features/mindmap/utils/path.ts new file mode 100644 index 000000000..ce918f57f --- /dev/null +++ b/frontend/src/features/mindmap/utils/path.ts @@ -0,0 +1,12 @@ +import { AddNodeDirection, NodeDirection, NodeElement } from "@/features/mindmap/types/node"; + +export const getBezierPath = (x1: number, y1: number, x2: number, y2: number) => { + const cx = (x1 + x2) / 2; + return `M ${x1} ${y1} C ${cx} ${y1}, ${cx} ${y2}, ${x2} ${y2}`; +}; + +/** 고스트 노드의 엣지가 시작하는 점의 방향 */ +export function resolveSide(targetNode: NodeElement, direction: NodeDirection): AddNodeDirection { + if (direction === "child" && targetNode.type === "root") return "right"; + return targetNode.addNodeDirection ?? "right"; +} diff --git a/frontend/src/features/quad_tree/QuadTreeShowCase.tsx b/frontend/src/features/quad_tree/QuadTreeShowCase.tsx deleted file mode 100644 index 4fd9b84a7..000000000 --- a/frontend/src/features/quad_tree/QuadTreeShowCase.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import React, { useEffect, useMemo, useRef, useState } from "react"; - -import QuadTree from "@/features/quad_tree/utils/QuadTree"; - -import { Point } from "./types/point"; -import { Rect } from "./types/rect"; - -const CANVAS_SIZE = 600; -const SEARCH_RADIUS = 60; - -// 시각화용 색상 설정 -const DEPTH_COLORS = ["#475569", "#6366f1", "#a855f7", "#ec4899", "#ef4444"]; -const SEARCH_COLOR = "#22c55e"; -const RANGE_BOX_COLOR = "rgba(34, 197, 94, 0.15)"; - -export const QuadTreeShowcase = () => { - const canvasRef = useRef(null); - const [points, setPoints] = useState([]); - const [mousePos, setMousePos] = useState(null); - const [draggingPoint, setDraggingPoint] = useState(null); - const [nearbyPoints, setNearbyPoints] = useState([]); - - // 1. QuadTree 인스턴스 생성 (데이터 변화 시 재구축) - const { tree, rebuildCount, bounds } = useMemo(() => { - const bounds: Rect = { minX: 0, maxX: CANVAS_SIZE, minY: 0, maxY: CANVAS_SIZE }; - const tree = new QuadTree(bounds, 4); - let rebuilds = 0; - let prevBounds = tree.getBounds(); - - points.forEach((p) => { - tree.insert(p); - const nextBounds = tree.getBounds(); - if (!rectEquals(prevBounds, nextBounds)) { - rebuilds += 1; - prevBounds = nextBounds; - } - }); - - return { tree, rebuildCount: rebuilds, bounds: tree.getBounds() }; - }, [points]); - - // 탐색 범위 계산 - const searchRange = useMemo(() => { - if (!mousePos) return null; - return { - minX: mousePos.x - SEARCH_RADIUS, - maxX: mousePos.x + SEARCH_RADIUS, - minY: mousePos.y - SEARCH_RADIUS, - maxY: mousePos.y + SEARCH_RADIUS, - }; - }, [mousePos]); - - // 2. 마우스 핸들러 - const handleMouseMove = (e: React.MouseEvent) => { - const rect = canvasRef.current?.getBoundingClientRect(); - if (!rect) return; - const currentPos = { x: e.clientX - rect.left, y: e.clientY - rect.top }; - setMousePos(currentPos); - - // 드래그 중 로직 - if (draggingPoint) { - setPoints((prev) => prev.map((p) => (p === draggingPoint ? currentPos : p))); - setDraggingPoint(currentPos); - } - - // 실시간 근처 노드 탐색 - if (searchRange) { - setNearbyPoints(tree.getPointsInRange(searchRange)); - } - }; - - const handleMouseDown = (e: React.MouseEvent) => { - if (!mousePos) return; - - // 우클릭 삭제 (tryMerge 시각화 확인용) - if (e.button === 2) { - const range = { - minX: mousePos.x - 10, - maxX: mousePos.x + 10, - minY: mousePos.y - 10, - maxY: mousePos.y + 10, - }; - const targets = tree.getPointsInRange(range); - if (targets.length > 0) { - setPoints((prev) => prev.filter((p) => p !== targets[0])); - } - return; - } - - // 좌클릭: 드래그 시작 또는 점 추가 - const range = { minX: mousePos.x - 10, maxX: mousePos.x + 10, minY: mousePos.y - 10, maxY: mousePos.y + 10 }; - const targets = tree.getPointsInRange(range); - - if (targets.length > 0) { - setDraggingPoint(targets[0] ?? null); - } else { - setPoints((prev) => [...prev, mousePos]); - } - }; - - const handleMouseUp = () => setDraggingPoint(null); - - // 3. 캔버스 렌더링 루프 - useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - const ctx = canvas.getContext("2d"); - if (!ctx) return; - - ctx.clearRect(0, 0, CANVAS_SIZE, CANVAS_SIZE); - - // A. 쿼드트리 전체 격자 시각화 (깊이별 색상) - const allBounds = collectQuadBounds(tree); - allBounds.forEach(({ rect, depth }) => { - const color = DEPTH_COLORS[depth % DEPTH_COLORS.length] ?? "#475569"; - ctx.strokeStyle = color; - ctx.lineWidth = Math.max(0.5, 2 - depth * 0.4); - ctx.strokeRect(rect.minX, rect.minY, rect.maxX - rect.minX, rect.maxY - rect.minY); - - // 영역 면적 칠하기 (매우 투명하게) - ctx.fillStyle = `${color}08`; - ctx.fillRect(rect.minX, rect.minY, rect.maxX - rect.minX, rect.maxY - rect.minY); - }); - - // B. 현재 쿼드트리 전체 경계 (rebuild 시각화) - const currentBounds = tree.getBounds(); - ctx.strokeStyle = "rgba(79, 70, 229, 0.4)"; - ctx.setLineDash([10, 5]); - ctx.lineWidth = 2; - ctx.strokeRect( - currentBounds.minX, - currentBounds.minY, - currentBounds.maxX - currentBounds.minX, - currentBounds.maxY - currentBounds.minY, - ); - ctx.setLineDash([]); - - // C. 모든 점 그리기 (드래그/인접 상태에 따라 색상 변경) - points.forEach((p) => { - const isDragging = draggingPoint === p; - const isNearby = nearbyPoints.includes(p); - - ctx.fillStyle = isDragging ? "#fbbf24" : isNearby ? "#22c55e" : "#64748b"; - ctx.shadowBlur = isDragging || isNearby ? 12 : 0; - ctx.shadowColor = ctx.fillStyle; - - ctx.beginPath(); - ctx.arc(p.x, p.y, isDragging ? 6 : 4, 0, Math.PI * 2); - ctx.fill(); - ctx.shadowBlur = 0; - }); - - // D. 마우스 탐색 범위 (돋보기 박스) - if (searchRange && mousePos) { - ctx.strokeStyle = SEARCH_COLOR; - ctx.lineWidth = 1; - ctx.setLineDash([4, 4]); - ctx.strokeRect(searchRange.minX, searchRange.minY, SEARCH_RADIUS * 2, SEARCH_RADIUS * 2); - ctx.fillStyle = RANGE_BOX_COLOR; - ctx.fillRect(searchRange.minX, searchRange.minY, SEARCH_RADIUS * 2, SEARCH_RADIUS * 2); - ctx.setLineDash([]); - } - }, [tree, mousePos, draggingPoint, nearbyPoints, searchRange]); - - const boundsWidth = bounds.maxX - bounds.minX; - const boundsHeight = bounds.maxY - bounds.minY; - const innerBounds = { - minX: bounds.minX + boundsWidth * 0.05, - maxX: bounds.maxX - boundsWidth * 0.05, - minY: bounds.minY + boundsHeight * 0.05, - maxY: bounds.maxY - boundsHeight * 0.05, - }; - - const handleAddNearBoundary = () => { - const x = innerBounds.maxX + 2; - const y = innerBounds.maxY + 2; - setPoints((prev) => [...prev, { x, y }]); - }; - - return ( -
-

- QUADTREE VISUALIZER -

-
-
Rebuilds: {rebuildCount}
-
- Bounds: {Math.round(boundsWidth)} x {Math.round(boundsHeight)} -
- -
-
-
-
Root -
-
-
Level 1 -
-
-
Level 2 -
-
-
Visited -
-
- - e.preventDefault()} - className="border-2 border-slate-800 bg-black cursor-crosshair rounded-xl shadow-inner" - /> - -
-

LEFT CLICK : ADD / DRAG

-

RIGHT CLICK : DELETE

-
-
- ); -}; - -const rectEquals = (a: Rect, b: Rect) => - a.minX === b.minX && a.maxX === b.maxX && a.minY === b.minY && a.maxY === b.maxY; - -const collectQuadBounds = (tree: QuadTree, depth = 0, acc: { rect: Rect; depth: number }[] = []) => { - acc.push({ rect: tree.getBounds(), depth }); - const children = (tree as unknown as { children?: Record }).children; - - if (children) { - Object.values(children).forEach((child) => collectQuadBounds(child, depth + 1, acc)); - } - - return acc; -}; diff --git a/frontend/src/features/quad_tree/types/point.ts b/frontend/src/features/quad_tree/types/point.ts deleted file mode 100644 index 46d73796d..000000000 --- a/frontend/src/features/quad_tree/types/point.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type Point = { - x: number; - y: number; -}; diff --git a/frontend/src/features/quad_tree/types/rect.ts b/frontend/src/features/quad_tree/types/rect.ts deleted file mode 100644 index 94c895eb1..000000000 --- a/frontend/src/features/quad_tree/types/rect.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type Rect = { - minY: number; - maxY: number; - minX: number; - maxX: number; -}; diff --git a/frontend/src/global.css b/frontend/src/global.css index 96b639e07..56a3454ab 100644 --- a/frontend/src/global.css +++ b/frontend/src/global.css @@ -169,4 +169,12 @@ visibility: hidden; transition: opacity 0.2s; } + + .moving-fragment-group { + pointer-events: none; + } + + .mindmap-render-root[data-dragging="true"] .static-graph { + pointer-events: none; + } } diff --git a/frontend/src/shared/utils/rect_helper.ts b/frontend/src/shared/utils/rect_helper.ts index 49472b1ce..fdeea521d 100644 --- a/frontend/src/shared/utils/rect_helper.ts +++ b/frontend/src/shared/utils/rect_helper.ts @@ -1,5 +1,4 @@ -import { Point } from "@/features/quad_tree/types/point"; -import { Rect } from "@/features/quad_tree/types/rect"; +import { Point, Rect } from "@/features/mindmap/types/spatial"; /** 특정 점이 지정된 사각형 영역 안에 포함되는지 확인 */ export const isPointInRect = (point: Point, rect: Rect): boolean => { diff --git a/frontend/src/utils/EventBroker.ts b/frontend/src/utils/EventBroker.ts index 8af33728b..f2a796a69 100644 --- a/frontend/src/utils/EventBroker.ts +++ b/frontend/src/utils/EventBroker.ts @@ -1,34 +1,36 @@ -type Callback = () => void; type CleanUp = () => void; -type Key = string; -export class EventBroker { - private subscribers: Map> = new Map(); +/** + * 시스템 내부의 이벤트를 발행하고 구독하는 중앙 이벤트 허브 + */ +export class EventBroker> { + // 이벤트 키별 구독자(콜백 함수 세트)를 관리하는 저장소 + private subscribers: Map void>> = new Map(); - subscribe({ key, callback }: { key: T; callback: Callback }): CleanUp { + /** 특정 이벤트가 발생했을 때 실행될 콜백 함수를 등록하고 해제 함수를 반환 */ + subscribe({ key, callback }: { key: K; callback: (data: TEvents[K]) => void }): CleanUp { const container = this.getCallbacks(key); - container.add(callback); + + container.add(callback as (data: TEvents[keyof TEvents]) => void); return () => { const container = this.subscribers.get(key); if (!container) return; - - container.delete(callback); - - if (container.size === 0) { - this.subscribers.delete(key); - } + container.delete(callback as (data: TEvents[keyof TEvents]) => void); + if (container.size === 0) this.subscribers.delete(key); }; } - publish(key: T) { + /** 특정 이벤트를 발생시키고 해당 이벤트를 구독 중인 모든 콜백에 데이터를 전달 */ + publish(key: K, data: TEvents[K]) { const container = this.subscribers.get(key); if (container) { - [...container].forEach((callback) => callback()); + container.forEach((callback) => callback(data)); } } - private getCallbacks(key: T): Set { + /** 이벤트 키에 해당하는 구독자 세트를 가져오거나 없으면 새로 생성 */ + private getCallbacks(key: K): Set<(data: TEvents[keyof TEvents]) => void> { let container = this.subscribers.get(key); if (!container) { container = new Set();