+ {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)}
-
-
-
-
-
-
- );
-};
-
-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();