Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
f3219db
feat: MindmapCore 클래스 생성자
arty0928 Feb 9, 2026
88802f9
refactor: Renderer 내부 쿼드 트리 생성 로직 -> MindMapCore에 위임
arty0928 Feb 9, 2026
c3aa8a4
refactor: 타입 구조 정리 및 중복 제거 (node, spatial, interaction)
arty0928 Feb 9, 2026
fcbe51f
feat: QuadTree 주입받아 노드 데이터 동기화하도록 TreeContainer 반영
arty0928 Feb 9, 2026
d6f31e8
refactor: MindmapCore 생성자 순서 조정
arty0928 Feb 9, 2026
48cb2be
feat: MindCore 내 데이터 변화에 따라 layout, quadtree, renderer 업데이트하는 sync 함수
arty0928 Feb 10, 2026
5b2c16c
refactor: NodeDirection type 적용
arty0928 Feb 10, 2026
c4ae057
feat: dragDrop 마우스 위치에 따라 방향 적용, 실시간 고스트 정보 업데이트
arty0928 Feb 10, 2026
f314eca
feat: 마인드맵에 Node 컴포넌트를 그리는 NodeItem 컴포넌트
arty0928 Feb 10, 2026
27dc17e
feat: type에 따라 ghost | new 인 TempNode 컴포넌트
arty0928 Feb 11, 2026
5a4e28e
feat: type에 따라 표시되는 EdgeLayer
arty0928 Feb 11, 2026
22c14ca
refactor: TempNode size 상수화
arty0928 Feb 11, 2026
8fad6f4
feat: Interaction 과정에서 엣지와 node만 그려주는 InteractionLayer 컴포넌트
arty0928 Feb 11, 2026
e50f86d
feat: InteractionManager
arty0928 Feb 12, 2026
47b1c89
feat: mode에 따른 분기처리하는 Interactionlayer
arty0928 Feb 12, 2026
b23ea3f
feat: MovingNodeFragment
arty0928 Feb 12, 2026
49aaaa6
feat: 고스트 가이드를 보여주는 DropIndicator
arty0928 Feb 12, 2026
c115ed6
feat: 정적 노드와 섀도우 노드 덩어리화해서 렌더링
arty0928 Feb 12, 2026
5b10e8c
feat: InteractionLayer도 fragment 화 덩어리 렌더링
arty0928 Feb 12, 2026
f16746f
feat: useViewportEvents에서 외부 이벤트를 받아 mindmap 내부 broker를 통해 publish 구조
arty0928 Feb 12, 2026
00ac99a
feat: MindmapCore 통합
arty0928 Feb 12, 2026
108f966
feat: svg 먼저 그리고 core 생성
arty0928 Feb 12, 2026
0375b1d
feat: 기본값으로 노드 렌더링 후 노드 텍스트 사이즈 감지해서 리렌더링
arty0928 Feb 12, 2026
3fa5dd5
feat: 좌우 노드 높이 자동 정렬 로직 제거, head 노드에서 사용자 입력 방향에 따라 노드 생성
arty0928 Feb 12, 2026
ec0ae52
feat: core 생성시 model만 초기화, core 생성 이후 전체 logic 초기화, viewport 적용
arty0928 Feb 12, 2026
2362db6
feat: core에서 노드 클릭 or 배경 클릭 구분 후 publish
arty0928 Feb 12, 2026
18ce880
feat: 노드간 Edge 연결, 색상 적용
arty0928 Feb 12, 2026
ccbcd8d
fix: quadTree 영역 범위내에 insert 안되는 문제 해결
arty0928 Feb 13, 2026
87125d1
feat: viewport 이동에 따른 좌표 변경 문제 해결 및 노드 좌표를 center 월드 좌표로 통일
arty0928 Feb 13, 2026
c848e0e
fix: 노드 2개 생성 문제 해결(core에서만 addNode 호출)
arty0928 Feb 13, 2026
800fe6c
fix: 자식 노드 추가 시 노드 겹치지 않게 레이아웃 캐시 무효화
arty0928 Feb 14, 2026
2012914
feat: movingFragment Edge 포함
arty0928 Feb 14, 2026
54c0e4c
fix: movingFragment 절대 좌표 기반 계산으로 delta 누적 부하 해결
arty0928 Feb 14, 2026
7dc6978
feat: 드래깅 중에는 원본 노드들 event-none
arty0928 Feb 14, 2026
a19ab80
feat: 마우스 좌표를 viewbox world 좌표로 변환
arty0928 Feb 14, 2026
7a00394
feat: 고스트 노드 띄우기 temp
arty0928 Feb 15, 2026
d95475a
feat: 드래그 드롭 고스트 노드 보이기
arty0928 Feb 15, 2026
69ad8cd
design: 고스트 노드 색 진한 blue-100
arty0928 Feb 15, 2026
b199d09
feat: 노드 삭제
arty0928 Feb 15, 2026
d020a6d
feat: 루트 노드 삭제 시 error publish
arty0928 Feb 15, 2026
0ea5579
chore: 주석 제거 및 메소드 private -> public 순서 정렬
arty0928 Feb 15, 2026
be10380
fix: MindMapShowcase 컴포넌트의 props 타입 정의 추가 및 상위 Page 레벨에서의 canvasRef 전…
arty0928 Feb 15, 2026
566f598
chore: 디버깅용 렌더링 삭제
arty0928 Feb 15, 2026
e6a528d
feat: movingFragment가 고스트 노드보다 위에 렌더링
arty0928 Feb 15, 2026
101b32e
chore: 디버깅용 콘솔 제거
arty0928 Feb 15, 2026
a0458c8
feat: movingFragment만 외부 스토어 구독해서 리렌더
arty0928 Feb 15, 2026
0be0203
fix: 드래깅 중일떄 StaticLayer pointer event none
arty0928 Feb 15, 2026
24b8543
fix: CSS 로 dragging 중일때 addNode 안 보이게 pointer-event-none
arty0928 Feb 15, 2026
7b4498b
fix: data-edge-to로 attribute 이름 정정
arty0928 Feb 15, 2026
89718e9
chore: 노드 기본 너비 높이 상수화
arty0928 Feb 15, 2026
62eeb93
fix: DOM 요소 직접 접근보다 useRef로 접근해서 dragging 중에는 static-layer pointer-ev…
arty0928 Feb 15, 2026
c5d0a17
chore: 미사용 코드 제거
arty0928 Feb 15, 2026
70bc1e5
chore: == -> ===
arty0928 Feb 15, 2026
b9cf82a
chore: 중복 메서드 제거
arty0928 Feb 15, 2026
8306b29
Merge branch 'dev' into feat/#314/mindmap_engine
arty0928 Feb 15, 2026
a7320a7
chore: quadtree showcase 삭제
arty0928 Feb 16, 2026
21df283
Merge branch 'feat/#314/mindmap_engine' of https://github.com/softeer…
arty0928 Feb 16, 2026
0371501
Merge branch 'dev' into feat/#314/mindmap_engine
arty0928 Feb 16, 2026
0efd6b3
fix: mindmapListPage에 provider 제공시 canvasRef optional
arty0928 Feb 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/constants/mouse.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const MOUSE_DOWN = {
left: 0,
wheel: 1,
right: 2,
};
22 changes: 22 additions & 0 deletions frontend/src/features/mindmap/components/DragGhostStyle.tsx
Original file line number Diff line number Diff line change
@@ -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 <style>{rules.join("\n")}</style>;
}
129 changes: 129 additions & 0 deletions frontend/src/features/mindmap/components/DropIndicator.tsx
Original file line number Diff line number Diff line change
@@ -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<NodeId, NodeElement>;
};

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 (
<g className="drop-indicator pointer-events-none">
<path d={pathData} className={edgeVariants({ type: "ghost" })} />

<g transform={`translate(${ghostX}, ${ghostY})`}>
<foreignObject width={ghostWidth} height={ghostHeight} x={-ghostWidth / 2} y={-ghostHeight / 2}>
<TempNode type="ghost" />
</foreignObject>
</g>
</g>
);
}
61 changes: 61 additions & 0 deletions frontend/src/features/mindmap/components/EdgeLayer.tsx
Original file line number Diff line number Diff line change
@@ -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<NodeId, NodeElement>;
filterNode: NodeElement[]; //이번 레이어에서 그릴 노드 ID(선명하게, 투명하게 그려야 하는 경우가 나뉨)
color?: NodeColor;
type?: "active" | "ghost";
} & VariantProps<typeof edgeVariants>;

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 (
<g className="edge-layer">
{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 (
<g key={`edge-${node.id}`}>
<path
d={pathD}
data-edge-to={node.id}
data-edge-from={parent.id}
className={cn(edgeVariants({ type, color }))}
/>
</g>
);
})}
</g>
);
}
24 changes: 24 additions & 0 deletions frontend/src/features/mindmap/components/InteractionLayer.tsx
Original file line number Diff line number Diff line change
@@ -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<NodeId, NodeElement>;
status: InteractionSnapshot;
};
export default function InteractionLayer({ nodeMap, status }: InteractionLayerProps) {
const { mode, draggingNodeId, dragDelta, dragSubtreeIds, baseNode } = status;

if (mode !== "dragging" || !draggingNodeId || !dragSubtreeIds) return null;

return (
<g className="interaction-layer">
{baseNode.targetId && baseNode.direction && (
<DropIndicator targetId={baseNode.targetId} direction={baseNode.direction} nodeMap={nodeMap} />
)}

<MovingNodeFragment filterIds={dragSubtreeIds} nodeMap={nodeMap} delta={dragDelta} />
</g>
);
}
61 changes: 61 additions & 0 deletions frontend/src/features/mindmap/components/MindMapRenderer.tsx
Original file line number Diff line number Diff line change
@@ -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<NodeId, NodeElement>;
rootRef: React.RefObject<SVGGElement | null>;
}) {
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 <InteractionLayer status={status} nodeMap={nodeMap} />;
}

function MindMapInnerRenderer() {
const mindmap = useMindMapCore();
const version = useMindMapVersion();
const rootRef = useRef<SVGGElement>(null);

if (!mindmap) return null;
useViewportEvents();

const nodeMap = mindmap.tree.nodes;

return (
<g ref={rootRef} className="mindmap-render-root" data-version={version} data-dragging="false">
<StaticLayer nodeMap={nodeMap} />
<DragGhostStyle />
<InteractionOverlay nodeMap={nodeMap} rootRef={rootRef} />
</g>
);
}

export default function MindMapRenderer() {
const mindmap = useMindMapCore();

if (!mindmap || !mindmap.getIsReady()) return null;
return <MindMapInnerRenderer />;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function MindmapNavigationBar() {
return <div>MindmapNavigationBar</div>;
}
47 changes: 47 additions & 0 deletions frontend/src/features/mindmap/components/MovingNodeFragment.tsx
Original file line number Diff line number Diff line change
@@ -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<NodeId, NodeElement>;
filterIds: Set<NodeId>; //
delta: { x: number; y: number };
};

/** 마우스로 잡고 노드를 움직일때 마우스에 따라다니는 덩어리 노드들 */
export default function MovingNodeFragment({ filterIds, nodeMap, delta }: MovingNodeFragmentProps) {
// 전체 nodeMap 중 movingHead ~ 자식 노드를 하나의 덩어리화, 맵 내용이 변경될때만 fragment 다시 그림
const { fragmentNodes, fragmentMap } = useMemo(() => {
const map = new Map<NodeId, NodeElement>();

// 드래그 중인 노드들만 맵에 담기
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 (
<g transform={`translate(${delta.x}, ${delta.y})`} className="moving-fragment-group">
<EdgeLayer nodeMap={fragmentMap} type="active" filterNode={fragmentNodes} color="violet" />

{Array.from(filterIds).map((id) => (
<NodeItem key={`moving-${id}`} nodeId={id} measure={false} />
))}
</g>
);
}
20 changes: 20 additions & 0 deletions frontend/src/features/mindmap/components/StaticLayer.tsx
Original file line number Diff line number Diff line change
@@ -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<NodeId, NodeElement>;
};

export default function StaticLayer({ nodeMap }: StaticLayerProps) {
const allNodes = Array.from(nodeMap.values());

return (
<g className="static-graph">
<EdgeLayer nodeMap={nodeMap} filterNode={allNodes} color="violet" />
{allNodes.map((node) => (
<NodeItem key={node.id} nodeId={node.id} />
))}
</g>
);
}
Loading