이번에 마인드맵을 react-konva로 구현하는 과정을 거치면서 마인드맵의 각 노드 간에 충돌을 방지시키지 않으면 여러 요소들이 겹쳐져 있는 상태가 나올 것이기 때문에, 이러한 충돌을 방지하고자 이에 관련되어 로직을 보강하기로 했다.
type NodeProps = {
parentNode?: Node;
node: Node;
depth: number;
text: string;
updateNode: (id: number, updatedNode: Node) => void;
};
function NodeComponent({ parentNode, node, depth, text, updateNode }: NodeProps) {
return (
<>
<Circle
id={node.id.toString()}
onDragMove={(e) => {
updateNode(node.id, {
...node,
location: {
x: e.target.x(),
y: e.target.y(),
},
});
}}
onDragEnd={(e) =>
updateNode(node.id, {
...node,
location: {
x: e.target.x(),
y: e.target.y(),
},
})
}
draggable
x={node.location.x}
y={node.location.y}
fill={"white"}
width={100}
height={100}
radius={70 - depth * 10}
stroke="black"
strokeWidth={3}
/>
<Text name="text" text={text} x={node.location.x - 20} y={node.location.y - 10} />
{parentNode && (
<ConnectedLine
from={parentNode.location}
to={node.location}
fromRadius={70 - (depth - 1) * 10 + 10}
toRadius={70 - depth * 10 + 10}
/>
)}
</>
);
}
기본적인 노드의 정보는 이러하다. 만약 노드 간에 부모-자식 관계가 있다면 이런 식으로 react-konva의 Line
을 커스텀한 ConnectedLine
이라는 컴포넌트를 추가적으로 렌더링 하도록 했다.
추가적으로 각 노드에는 dragmove
와 dragend
이벤트를 달아서 해당 요소를 드래그 앤 드롭 시에 계속해서 상태를 바꾸면서 노드의 위치를 갱신할 수 있도록 했다.
export type Node = {
id: number;
keyword: string;
depth: number;
location: Location;
children: number[] | [];
};
type DrawNodeProps = {
data: Node[];
root: Node;
depth?: number;
parentNode?: any;
update?: (id: number, node: Node) => void;
};
export function DrawNodefromData({ data, root, depth = 0, parentNode, update }: DrawNodeProps) {
return (
<>
{/* from */}
<NodeComponent text={root.keyword} depth={depth} parentNode={parentNode} node={root} updateNode={update} />
{/* to */}
{root.children?.map((childNode, index) => (
<DrawNodefromData
data={data}
key={index}
root={data[childNode - 1]}
depth={depth + 1}
parentNode={root}
update={update}
/>
))}
</>
);
}
노드의 부모-자식을 렌더링하는 방식은 위와 같다. 모든 노드는 직렬화 되어있는 상태이고, children에는 노드의 id만을 가지고 있다.
노드의 id는 index를 1부터 시작하여 만들었기 때문에 계속해서 서브트리의 루트로 가게 될 때에는 root
를 id값에서 1을 빼어 인덱스로 접근할 수 있도록 하였다.
그렇다면 이 노드 사이에서 어떻게 충돌할 때의 문제를 해결할 수 있을까? 내가 생각했을 때 가장 기본적인 해결 방식은 '충돌이 됐을 때, 충돌당한 노드를 겹치지 않도록 밀어낸다' 였다. 그렇기 때문에, 나는 충돌한 각도를 어느정도 알고 있고, 밀어낼 때마다 각 원이 가져야 하는 최소 사이의 길이만 정한다면 이를 통해 리렌더링 과정에서 노드들을 피하게 할 수 있을 것 같았다.
//...
const layer = useRef(null);
useEffect(() => {
layer.current.on("dragmove", (event) => checkCollision(layer, event, updateNodeList));
}, []);
가장 먼저 필요한 것은 layer에 이벤트를 다는 일이다.
react-konva
에서는 useRef
를 layer에 달게 되면 layer 안의 모든 요소들이 jsx와 같은 요소처럼 접근할 수 있기 때문에, children에도 접근할 수 있다.
가장 처음에 생각한 방식은 무조건 맨 처음에 어떤 요소를 드래그해서 옮기기 시작하면 옮기는 이벤트가 발동될 때마다 layer 안에 있는 children들에 대해 모두 collision을 확인하고, 드래그하고 있는 요소들과 충돌하는 노드가 있는지 확인을 해야 했다.
export function checkCollision(layer, event, update) {
const dragTarget = event.target;
const dragTargetRect = event.target.getClientRect();
layer.current.children.forEach((child) => {
if (
!(child.attrs.name === "text") &&
!(child.attrs.name === "line") &&
isCollided(child, dragTarget, dragTargetRect)
) {
const newPosition = moveOnCollision(child, dragTarget);
update(parseInt(child.attrs.id), { location: newPosition });
}
});
}
dragmove
이벤트가 발동될 때마다 해당 checkCollision
함수가 실행된다. 이벤트가 발동되면서 해당 함수에 이벤트 객체를 그대로 넘겨주어 이 함수에서 확인할 수 있도록 해주었다.
dragTarget
의 경우, 현재 드래그 하고 있는 Target
의 객체를 넘겨준다.
event
객체의 target
property에서 getClientRect
메서드는 해당 요소가 가지고 있는 크기범위를 직사각형 형태로 나타내준다.
요런 식으로 각도가 있는 요소들도 전부 직사각형의 범위로 측정한 값을 보내준다. 대부분 충돌 감지를 할 때는 위와 같이 충돌이라고 할 수 있는 범위를 직사각형 형태로 측정하여 감지하는 것으로 보았기 때문에 나 또한 이를 기준으로 측정했다. 따라서 getClientRect()
를 통해 서로의 범위를 측정하고, 이 두 직사각형이 충돌하는지 검사하는 함수에 넣어 충돌 감지를 할 수 있도록 했다.
const nodes = layer.children.filter((child) => child.attrs.name === "node");
nodes.forEach((base) => {
nodes.forEach((target) => {
if (base !== target) {
if (isCollided(base.children[0].getClientRect(), target.children[0].getClientRect())) {
handleCollision(base, target);
}
}
});