diff --git a/README.md b/README.md
index 12debdfb..bfc1b661 100644
--- a/README.md
+++ b/README.md
@@ -43,11 +43,12 @@ Cloud-Canvas는 이러한 문제점을 해결하기 위한 GUI 기반 인프라
## 📌 아키텍쳐
### 전반적인 인프라
+
![image](https://github.com/user-attachments/assets/5901b688-0d3d-4698-ad22-a4d4bb7aa8fd)
### CI/CD
-
+
# 팀
diff --git a/apps/client/mocks/instance.ts b/apps/client/mocks/instance.ts
deleted file mode 100644
index cd8f0759..00000000
--- a/apps/client/mocks/instance.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-// import { nanoid } from 'nanoid';
-// import { AnchorType, Edge, Node } from '../src/cloud-graph/types';
-//
-// const getRandomPoint = () => {
-// return {
-// x: Math.floor(Math.random() * 10000),
-// y: Math.floor(Math.random() * 10000),
-// };
-// };
-//
-// const randomAnchorType = () => {
-// const anchors: AnchorType[] = ['top', 'right', 'bottom', 'left'];
-// return anchors[Math.floor(Math.random() * anchors.length)];
-// };
-//
-// export const createMockNodesAndEdges = (
-// nodeCount: number,
-// edgeCount: number,
-// ) => {
-// const nodes: Node[] = Array.from({ length: nodeCount }, () => ({
-// id: 'mock'.concat(nanoid()),
-// type: 'server',
-// point: getRandomPoint(),
-// }));
-//
-// const edges: Edge[] = Array.from({ length: edgeCount }, () => {
-// const sourceIndex = Math.floor(Math.random() * nodes.length);
-// let targetIndex = Math.floor(Math.random() * nodes.length);
-// while (targetIndex === sourceIndex) {
-// targetIndex = Math.floor(Math.random() * nodes.length);
-// }
-//
-// return {
-// id: nanoid(),
-// type: 'arrow',
-// source: {
-// ...nodes[sourceIndex],
-// anchorType: randomAnchorType() as AnchorType,
-// },
-// target: {
-// ...nodes[targetIndex],
-// anchorType: randomAnchorType() as AnchorType,
-// },
-// };
-// });
-//
-// return {
-// nodes,
-// edges,
-// };
-// };
diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx
index e2111eaf..a9787bac 100644
--- a/apps/client/src/App.tsx
+++ b/apps/client/src/App.tsx
@@ -1,7 +1,67 @@
-import Header from '@components/Header';
-import Sidebar from '@components/Sidebar';
+import CloudGraph from '@/src/CloudGraph';
+import Header from '@components/Layout/Header';
+import Sidebar from '@components/Layout/Sidebar';
+import { useSelectionContext } from '@contexts/SelectionContext';
+import useGraphActions from '@hooks/useGraphActions';
+import {
+ AppBar,
+ InputLabel,
+ MenuItem,
+ Select,
+ SelectChangeEvent,
+ Toolbar,
+ Typography,
+} from '@mui/material';
import Box from '@mui/material/Box';
-import { CloudGraph } from '@cloud-graph/index';
+import { useState } from 'react';
+
+const PropertiesBar = () => {
+ return (
+
+
+ Properties
+
+ {/* {selectedCloud.properties && */}
+ {/* Object.entries(selectedCloud.properties).map( */}
+ {/* ([key, value]) => { */}
+ {/* return ( */}
+ {/* */}
+ {/* ); */}
+ {/* }, */}
+ {/* )} */}
+
+
+
+ );
+};
function App() {
return (
@@ -21,7 +81,14 @@ function App() {
}}
>
-
+
+
+
+
);
diff --git a/apps/client/src/CloudGraph.tsx b/apps/client/src/CloudGraph.tsx
new file mode 100644
index 00000000..a8d75d4a
--- /dev/null
+++ b/apps/client/src/CloudGraph.tsx
@@ -0,0 +1,145 @@
+import BendingPointer from '@components/BendingPointer';
+import Connection from '@components/Connection';
+import Connectors from '@components/Connectors';
+import Edge from '@components/Edge';
+import Graph from '@components/Graph';
+import GridBackground from '@components/GridBackground';
+import Group from '@components/Group';
+import Node from '@components/Node';
+import { useEdgeContext } from '@contexts/EdgeContext';
+import { useGroupContext } from '@contexts/GroupContext';
+import { useNodeContext } from '@contexts/NodeContext';
+import { useSvgContext } from '@contexts/SvgContext';
+import useConnection from '@hooks/useConnection';
+import useGraphActions from '@hooks/useGraphActions';
+import useSelection from '@hooks/useSelection';
+import { useEffect } from 'react';
+
+export default () => {
+ const { svgRef } = useSvgContext();
+ const {
+ state: { nodes },
+ } = useNodeContext();
+ const {
+ state: { edges },
+ } = useEdgeContext();
+ const {
+ state: { groups },
+ } = useGroupContext();
+ const {
+ selectedNodeId,
+ selectedEdge,
+ selectedGroupId,
+ clearSelection,
+ selectNode,
+ selectSegEdge,
+ selectEntireEdge,
+ } = useSelection();
+
+ const {
+ moveNode,
+ addEdge,
+ splitEdge,
+ moveBendingPointer,
+ getGroupBounds,
+ moveGroup,
+ removeNode,
+ removeEdge,
+ } = useGraphActions();
+
+ const {
+ connection,
+ isConnecting,
+ openConnection,
+ connectConnection,
+ closeConnection,
+ } = useConnection({
+ updateEdgeFn: addEdge,
+ });
+
+ useEffect(() => {
+ const handleContextMenu = (e: MouseEvent) => e.preventDefault();
+ const handleMouseDown = () => clearSelection();
+ document.addEventListener('contextmenu', handleContextMenu);
+ document.addEventListener('mousedown', handleMouseDown);
+
+ return () => {
+ document.removeEventListener('contextmenu', handleContextMenu);
+ svgRef.current?.removeEventListener('mousedown', handleMouseDown);
+ };
+ }, []);
+
+ return (
+
+
+ {Object.values(groups).map((group) => (
+
+ ))}
+ {Object.values(nodes).map((node) => (
+ <>
+
+
+ >
+ ))}
+ {connection && (
+
+ )}
+
+ {edges &&
+ Object.values(edges).map((edge) => (
+ <>
+
+ {edge.bendingPoints.map((point, index) => (
+
+ moveBendingPointer(edge.id, index, newPoint)
+ }
+ />
+ ))}
+ >
+ ))}
+
+ );
+};
diff --git a/apps/client/src/cloud-graph/components/Anchor.tsx b/apps/client/src/cloud-graph/components/Anchor.tsx
deleted file mode 100644
index 994905e0..00000000
--- a/apps/client/src/cloud-graph/components/Anchor.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { Point } from '@cloud-graph/types';
-import { ANCHOR_RADIUS } from '@cloud-graph/constants';
-import { useTheme } from '@mui/material';
-
-type Props = {
- visible: boolean;
- cx?: number;
- cy?: number;
- onStartConnect: () => void;
- onConnect: (point: Point) => void;
- onStopConnect: () => void;
-};
-
-export default ({
- cx,
- cy,
- visible,
- onStartConnect,
- onConnect,
- onStopConnect,
-}: Props) => {
- const theme = useTheme();
- const handleMouseDown = () => {
- onStartConnect();
-
- const handleMouseMove = (moveEvent: MouseEvent) => {
- onConnect({ x: moveEvent.clientX, y: moveEvent.clientY });
- };
-
- const handleMouseUp = () => {
- onStopConnect();
- window.removeEventListener('mousemove', handleMouseMove);
- window.removeEventListener('mouseup', handleMouseUp);
- };
-
- window.addEventListener('mousemove', handleMouseMove);
- window.addEventListener('mouseup', handleMouseUp);
- };
-
- return (
-
- );
-};
diff --git a/apps/client/src/cloud-graph/components/Anchors.tsx b/apps/client/src/cloud-graph/components/Anchors.tsx
deleted file mode 100644
index ca56b051..00000000
--- a/apps/client/src/cloud-graph/components/Anchors.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import Anchor from '@cloud-graph/components/Anchor';
-import { AnchorType, Dimension, Edge, Node, Point } from '@cloud-graph/types';
-import { calculateAnchorPoints } from '@cloud-graph/utils';
-import React from 'react';
-
-type AnchorsProps = {
- node: Node;
- edges: Edge[];
- dimension: Dimension;
- isSelected: boolean;
- onStartConnect: (node: Node, anchorType: AnchorType) => void;
- onConnect: (point: Point) => void;
- onStopConnect: () => void;
-};
-
-const Anchors: React.FC = ({
- node,
- edges,
- dimension,
- isSelected,
- onStartConnect,
- onConnect,
- onStopConnect,
-}) => {
- const anchors = calculateAnchorPoints(node, dimension);
-
- const connectedAnchors = edges
- .filter((edge) => edge.source.node.id === node.id)
- .map((edge) => edge.source.anchorType);
-
- return (
- <>
- {anchors &&
- Object.entries(anchors).map(([type, point]) => (
-
- onStartConnect(node, type as AnchorType)
- }
- onConnect={onConnect}
- onStopConnect={onStopConnect}
- />
- ))}
- >
- );
-};
-
-export default Anchors;
diff --git a/apps/client/src/cloud-graph/components/Edge.tsx b/apps/client/src/cloud-graph/components/Edge.tsx
deleted file mode 100644
index 95fee6ea..00000000
--- a/apps/client/src/cloud-graph/components/Edge.tsx
+++ /dev/null
@@ -1,98 +0,0 @@
-import { Dimension, Edge, Point } from '@cloud-graph/types';
-import { calculateAnchorPoints } from '@cloud-graph/utils';
-import { useTheme } from '@mui/material';
-import { useRef } from 'react';
-
-type Props = {
- edge: Edge;
- isSelected: boolean;
- dimension: Dimension;
- onSelect: (id: string) => void;
- onSplit: (edge: Edge, point: Point) => void;
- onSelectEntireEdge: (edge: Edge) => void;
-};
-
-export default ({
- edge,
- isSelected,
- dimension,
- onSelect,
- onSplit,
- onSelectEntireEdge,
-}: Props) => {
- const theme = useTheme();
- const { id, type, source, target } = edge;
- const timeoutRef = useRef(null);
-
- const sourceAnchor = calculateAnchorPoints(source.node, dimension);
- const targetAnchor = calculateAnchorPoints(target.node, dimension);
-
- const sourcePoint = source.anchorType
- ? sourceAnchor[source.anchorType]
- : source.node.point;
- const targetPoint = target.anchorType
- ? targetAnchor[target.anchorType]
- : target.node.point;
-
- const color = isSelected
- ? theme.palette.primary.main
- : theme.palette.text.primary;
-
- const handleClick = (event: React.MouseEvent) => {
- if (event.shiftKey) {
- onSelectEntireEdge(edge);
- } else {
- onSelect(id);
- }
- };
-
- const handleMouseDown = (event: React.MouseEvent) => {
- event.stopPropagation();
- timeoutRef.current = setTimeout(() => {
- const { clientX, clientY } = event;
- onSplit(edge, { x: clientX, y: clientY });
- }, 500);
-
- const handleMouseUp = () => {
- if (timeoutRef.current) {
- clearTimeout(timeoutRef.current);
- timeoutRef.current = null;
- }
-
- document.removeEventListener('mouseup', handleMouseUp);
- };
-
- document.addEventListener('mouseup', handleMouseUp);
- };
-
- return (
-
-
-
-
-
-
-
-
- );
-};
diff --git a/apps/client/src/cloud-graph/components/Graph.tsx b/apps/client/src/cloud-graph/components/Graph.tsx
deleted file mode 100644
index b967b7d8..00000000
--- a/apps/client/src/cloud-graph/components/Graph.tsx
+++ /dev/null
@@ -1,76 +0,0 @@
-import Background from '@cloud-graph/components/Background';
-import useKey from '@cloud-graph/hooks/useKey';
-import { Dimension, Point, ViewBox } from '@cloud-graph/types';
-import React, { forwardRef, ReactNode } from 'react';
-
-type Props = {
- viewBox: ViewBox;
- dimension: Dimension;
- onZoom: (wheelY: number, point: Point) => void;
- onStartPan: (point: Point) => void;
- onMovePan: (point: Point) => void;
- onStopPan: () => void;
- onDeselectAll: () => void;
- children: ReactNode;
-};
-
-const Graph = forwardRef(
- (
- {
- children,
- viewBox,
- dimension,
- onZoom,
- onStartPan,
- onMovePan,
- onStopPan,
- onDeselectAll,
- },
- ref,
- ) => {
- const isActiveKey = useKey('space');
-
- const handleWheel = (event: React.WheelEvent) => {
- onZoom(event.deltaY, { x: event.clientX, y: event.clientY });
- };
-
- const handleStartPan = (event: React.MouseEvent) => {
- onStartPan({ x: event.clientX, y: event.clientY });
-
- const handleMovePan = (moveEvent: MouseEvent) => {
- if (!isActiveKey) return;
- onMovePan({ x: moveEvent.clientX, y: moveEvent.clientY });
- };
-
- const handleStopPan = () => {
- onStopPan();
- document.removeEventListener('mousemove', handleMovePan);
- document.removeEventListener('mouseup', handleStopPan);
- };
-
- document.addEventListener('mousemove', handleMovePan);
- document.addEventListener('mouseup', handleStopPan);
- };
-
- const handleMouseDown = (event: React.MouseEvent) => {
- handleStartPan(event);
- onDeselectAll();
- };
-
- return (
-
- );
- },
-);
-
-export default Graph;
diff --git a/apps/client/src/cloud-graph/components/Node/NodeRenderer.tsx b/apps/client/src/cloud-graph/components/Node/NodeRenderer.tsx
deleted file mode 100644
index 055ccf15..00000000
--- a/apps/client/src/cloud-graph/components/Node/NodeRenderer.tsx
+++ /dev/null
@@ -1,44 +0,0 @@
-import CloudFunctionNode from '@cloud-graph/components/Node/ncloud/CloudFunctionNode';
-import DBMySQLNode from '@cloud-graph/components/Node/ncloud/DBMySQLNode';
-import ObjectStorageNode from '@cloud-graph/components/Node/ncloud/ObjectStorageNode';
-import ServerNode from '@cloud-graph/components/Node/ncloud/ServerNode';
-import Selected2DBox from '@cloud-graph/components/Node/Selected2DBox';
-import PointerNode from '@cloud-graph/components/Node/utility/PointerNode';
-import { Dimension, Node } from '@cloud-graph/types';
-import { isCloudNode } from '@cloud-graph/utils';
-
-type Props = {
- node: Node;
- dimension: Dimension;
- isSelected: boolean;
-};
-
-const nodeFactory = (node: Node, dimension: Dimension) => {
- switch (node.type) {
- case 'server':
- return ;
- case 'cloud-function':
- return ;
- case 'object-storage':
- return ;
- case 'db-mysql':
- return ;
- case 'pointer':
- return ;
- default:
- null;
- }
-};
-
-const NodeRenderer = ({ node, dimension, isSelected }: Props) => {
- return (
- <>
- {nodeFactory(node, dimension)}
- {dimension === '2d' && isCloudNode(node) && (
-
- )}
- >
- );
-};
-
-export default NodeRenderer;
diff --git a/apps/client/src/cloud-graph/components/Node/Selected2DBox.tsx b/apps/client/src/cloud-graph/components/Node/Selected2DBox.tsx
deleted file mode 100644
index 0afd98e0..00000000
--- a/apps/client/src/cloud-graph/components/Node/Selected2DBox.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-type Props = {
- isSelected: boolean;
-};
-export default ({ isSelected }: Props) => {
- if (!isSelected) return;
-
- return (
-
- );
-};
diff --git a/apps/client/src/cloud-graph/components/Node/index.tsx b/apps/client/src/cloud-graph/components/Node/index.tsx
deleted file mode 100644
index 119b6789..00000000
--- a/apps/client/src/cloud-graph/components/Node/index.tsx
+++ /dev/null
@@ -1,69 +0,0 @@
-import NodeRenderer from '@cloud-graph/components/Node/NodeRenderer';
-import useKey from '@cloud-graph/hooks/useKey';
-import { Dimension, Node } from '@cloud-graph/types';
-import { useRef } from 'react';
-
-type Props = {
- node: Node;
- dimension: Dimension;
- isSelected?: boolean;
- onStartDrag: (nodeId: string, point: { x: number; y: number }) => void;
- onDrag: (point: { x: number; y: number }) => void;
- onStopDrag: () => void;
- onSelect?: (nodeId: string) => void;
- onMultiSelect?: (nodeId: string) => void;
-};
-export default ({
- node,
- dimension,
- isSelected = false,
- onStartDrag,
- onDrag,
- onStopDrag,
- onSelect,
- onMultiSelect,
-}: Props) => {
- const handleMouseDown = (event: React.MouseEvent) => {
- event.stopPropagation();
- const { clientX, clientY } = event;
-
- if (event.shiftKey) {
- onMultiSelect && onMultiSelect(node.id);
- return;
- }
- onSelect && onSelect(node.id);
- onStartDrag(node.id, {
- x: clientX,
- y: clientY,
- });
-
- const handleMouseMove = (moveEvent: MouseEvent) => {
- onDrag({ x: moveEvent.clientX, y: moveEvent.clientY });
- };
-
- const handleMouseUp = () => {
- onStopDrag();
- window.removeEventListener('mousemove', handleMouseMove);
- window.removeEventListener('mouseup', handleMouseUp);
- };
-
- window.addEventListener('mousemove', handleMouseMove);
- window.addEventListener('mouseup', handleMouseUp);
- };
-
- return (
-
-
-
- );
-};
diff --git a/apps/client/src/cloud-graph/components/Node/ncloud/DBMySQLNode.tsx b/apps/client/src/cloud-graph/components/Node/ncloud/DBMySQLNode.tsx
deleted file mode 100644
index 999e3d81..00000000
--- a/apps/client/src/cloud-graph/components/Node/ncloud/DBMySQLNode.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-import { Dimension } from '@cloud-graph/types';
-
-type Props = {
- dimension: Dimension;
-};
-
-const Node3D = () => {
- return (
- <>
-
- >
- );
-};
-
-const Node2D = () => {
- return (
- <>
-
- >
- );
-};
-export default ({ dimension }: Props) => {
- return dimension === '2d' ? : ;
-};
diff --git a/apps/client/src/cloud-graph/components/Node/utility/PointerNode.tsx b/apps/client/src/cloud-graph/components/Node/utility/PointerNode.tsx
deleted file mode 100644
index c6854f97..00000000
--- a/apps/client/src/cloud-graph/components/Node/utility/PointerNode.tsx
+++ /dev/null
@@ -1,6 +0,0 @@
-import { useTheme } from '@mui/material';
-
-export default () => {
- const theme = useTheme();
- return ;
-};
diff --git a/apps/client/src/cloud-graph/constants/index.ts b/apps/client/src/cloud-graph/constants/index.ts
deleted file mode 100644
index 1a12e8b3..00000000
--- a/apps/client/src/cloud-graph/constants/index.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export const GRID_2D_SIZE = 90;
-export const GRID_3D_WIDTH_SIZE = 128;
-export const GRID_3D_HEIGHT_SIZE = 74;
-export const GRID_3D_DEPTH_SIZE = 37;
-export const ANCHOR_RADIUS = 6;
diff --git a/apps/client/src/cloud-graph/contexts/DimensionContext.tsx b/apps/client/src/cloud-graph/contexts/DimensionContext.tsx
deleted file mode 100644
index 038dd6b3..00000000
--- a/apps/client/src/cloud-graph/contexts/DimensionContext.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { Dimension } from '@cloud-graph/types';
-import { createContext, ReactNode, useContext, useState } from 'react';
-
-type DimensionType = Dimension;
-
-type DimensionContextType = {
- dimension: DimensionType;
- handleToggleDimension: () => void;
-};
-
-const DimensionContext = createContext(null);
-
-export const DimensionProvider = ({ children }: { children: ReactNode }) => {
- const [dimension, setDimension] = useState('2d');
-
- const handleToggleDimension = () => {
- setDimension((prev) => (prev === '2d' ? '3d' : '2d'));
- };
-
- return (
-
- {children}
-
- );
-};
-
-export const useDimensionContext = () => {
- const context = useContext(DimensionContext);
- if (!context) throw new Error('DimensionContext: context is undefined');
-
- return context;
-};
diff --git a/apps/client/src/cloud-graph/contexts/GraphContext.tsx b/apps/client/src/cloud-graph/contexts/GraphContext.tsx
deleted file mode 100644
index bd83a925..00000000
--- a/apps/client/src/cloud-graph/contexts/GraphContext.tsx
+++ /dev/null
@@ -1,213 +0,0 @@
-import { Edge, Group, Node } from '@cloud-graph/types';
-import { nanoid } from 'nanoid';
-import {
- createContext,
- Dispatch,
- ReactNode,
- useContext,
- useReducer,
-} from 'react';
-
-type GraphState = {
- nodes: Node[];
- edges: Edge[];
- groups: Group[];
-};
-
-interface GraphContextType extends GraphState {
- dispatch: Dispatch;
-}
-
-type GraphAction =
- | {
- type: 'ADD_NODE';
- payload: Node;
- }
- | {
- type: 'MOVE_NODE';
- payload: {
- nodes: Node[];
- edges: Edge[];
- };
- }
- | {
- type: 'REMOVE_NODE';
- payload: {
- nodeIds: string[];
- edgeIds: string[];
- };
- }
- | {
- type: 'ADD_EDGE';
- payload: Edge;
- }
- | {
- type: 'SPLIT_EDGE';
- payload: {
- pointer: Node;
- edge: Edge;
- sourceToPointer: Edge;
- pointerToTarget: Edge;
- };
- }
- | {
- type: 'REMOVE_EDGE';
- payload: {
- edges: Edge[];
- pointerId?: string;
- };
- }
- | {
- type: 'REMOVE_ENTIRE_EDGE';
- payload: {
- pointerIds: string[];
- edgeIds: string[];
- };
- };
-const GraphContext = createContext(null);
-
-const graphReducer = (state: GraphState, action: GraphAction) => {
- switch (action.type) {
- case 'ADD_NODE': {
- return {
- ...state,
- nodes: [...state.nodes, action.payload],
- };
- }
- case 'MOVE_NODE': {
- return {
- ...state,
- nodes: action.payload.nodes,
- edges: action.payload.edges,
- };
- }
- case 'ADD_EDGE': {
- return {
- ...state,
- edges: [...state.edges, action.payload],
- };
- }
- case 'SPLIT_EDGE': {
- return {
- ...state,
- nodes: [...state.nodes, action.payload.pointer],
- edges: state.edges
- .filter((edge) => edge.id !== action.payload.edge.id)
- .concat([
- action.payload.sourceToPointer,
- action.payload.pointerToTarget,
- ]),
- };
- }
- case 'REMOVE_NODE': {
- return {
- ...state,
- nodes: state.nodes.filter(
- (node) => !action.payload.nodeIds.includes(node.id),
- ),
- edges: state.edges.filter(
- (edge) => !action.payload.edgeIds.includes(edge.id),
- ),
- };
- }
- case 'REMOVE_EDGE': {
- return {
- ...state,
- nodes: action.payload.pointerId
- ? state.nodes.filter(
- (node) => node.id !== action.payload.pointerId,
- )
- : state.nodes,
- edges: action.payload.edges,
- };
- }
- case 'REMOVE_ENTIRE_EDGE': {
- return {
- ...state,
- nodes: state.nodes.filter(
- (node) => !action.payload.pointerIds.includes(node.id),
- ),
- edges: state.edges.filter(
- (edge) => !action.payload.edgeIds.includes(edge.id),
- ),
- };
- }
- default:
- return state;
- }
-};
-
-const mockNodes = [
- {
- id: `node-${nanoid()}`,
- type: 'server',
- point: { x: 0, y: 0 },
- size: {
- d2: { width: 90, height: 90 },
- d3: { width: 128, height: 111 },
- },
- label: 'G1',
- },
- {
- id: `node-${nanoid()}`,
- type: 'server',
- point: { x: 0, y: 0 },
- size: {
- d2: { width: 90, height: 90 },
- d3: { width: 128, height: 111 },
- },
- label: 'G1',
- },
- {
- id: `node-${nanoid()}`,
- type: 'cloud-function',
- point: { x: 10, y: 100 },
- size: {
- d2: { width: 90, height: 90 },
- d3: { width: 96, height: 113.438 },
- },
- },
- {
- id: `node-${nanoid()}`,
- type: 'object-storage',
- size: {
- d2: { width: 90, height: 90 },
- d3: { width: 100.626, height: 115.695 },
- },
- point: { x: 100, y: 10 },
- },
- {
- id: `node-${nanoid()}`,
- type: 'db-mysql',
- size: {
- d2: { width: 90, height: 90 },
- d3: { width: 128, height: 137.5 },
- },
- point: { x: 100, y: 100 },
- },
-];
-export const GraphProvider = ({ children }: { children: ReactNode }) => {
- const [state, dispatch] = useReducer(graphReducer, {
- nodes: [...mockNodes],
- edges: [],
- groups: [],
- });
-
- return (
-
- {children}
-
- );
-};
-
-export const useGraphContext = () => {
- const context = useContext(GraphContext);
- if (!context) throw new Error('GraphCOntext: context is undefined');
-
- return context;
-};
diff --git a/apps/client/src/cloud-graph/hooks/useConnector.ts b/apps/client/src/cloud-graph/hooks/useConnector.ts
deleted file mode 100644
index 124b0951..00000000
--- a/apps/client/src/cloud-graph/hooks/useConnector.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-import useEdge from '@cloud-graph/hooks/useEdge';
-import { AnchorType, Dimension, Edge, Node, Point } from '@cloud-graph/types';
-import {
- calculateAnchorPoints,
- getDistance,
- getSvgPoint,
-} from '@cloud-graph/utils';
-import { useRef, useState } from 'react';
-
-type Props = {
- svg: SVGSVGElement;
- nodes: Node[];
- dimension: Dimension;
-};
-
-export default ({ svg, nodes, dimension }: Props) => {
- const { handleAdd: handleAddEdge } = useEdge({ svg });
-
- const [connection, setConnection] = useState<{
- from: Point;
- to: Point;
- } | null>(null);
- const source = useRef(null);
- const target = useRef(null);
-
- const handleStartConnect = (node: Node, anchorType: AnchorType) => {
- const anchors = calculateAnchorPoints(node, dimension);
-
- const anchorPoint = anchors[anchorType];
-
- source.current = {
- node,
- anchorType,
- };
- setConnection({
- from: anchorPoint,
- to: anchorPoint,
- });
- };
-
- const handleConnect = (point: Point) => {
- const svgPoint = getSvgPoint(svg, point);
-
- let minDistance = Infinity;
- let newPoint: Point = svgPoint;
- nodes.forEach((node) => {
- const anchors = calculateAnchorPoints(node, dimension);
-
- Object.entries(anchors).forEach(([type, point]) => {
- const distance = getDistance(svgPoint, point);
- const snappedThreshold = 30;
- if (distance < snappedThreshold && distance < minDistance) {
- target.current = {
- node,
- anchorType: type as AnchorType,
- };
- newPoint = point;
- minDistance = distance;
- }
- });
- });
-
- setConnection((prev) => {
- if (!prev) return null;
- return {
- from: prev.from,
- to: newPoint,
- };
- });
- };
-
- const handleStopConnect = () => {
- if (
- source.current &&
- target.current &&
- source.current.node.id !== target.current.node.id
- ) {
- handleAddEdge({
- type: 'arrow',
- source: source.current,
- target: target.current,
- });
- }
- setConnection(null);
- source.current = null;
- target.current = null;
- };
-
- return {
- connection,
- handleStartConnect,
- handleConnect,
- handleStopConnect,
- };
-};
diff --git a/apps/client/src/cloud-graph/hooks/useDrag.ts b/apps/client/src/cloud-graph/hooks/useDrag.ts
deleted file mode 100644
index d56a2158..00000000
--- a/apps/client/src/cloud-graph/hooks/useDrag.ts
+++ /dev/null
@@ -1,154 +0,0 @@
-import { useGraphContext } from '@cloud-graph/contexts/GraphContext';
-import { Dimension, Point } from '@cloud-graph/types';
-import {
- getDistance,
- getGridAlignedPoint,
- getSvgPoint,
- calculateAnchorPoints,
- findNearestAnchorPair,
- isUtilityNode,
-} from '@cloud-graph/utils';
-import { useRef } from 'react';
-
-type Props = {
- svg: SVGSVGElement;
- dimension: Dimension;
-};
-
-export default function useDrag({ svg, dimension }: Props) {
- const { nodes, edges, dispatch } = useGraphContext();
-
- const isDragging = useRef(false);
- const dragNodeBBox = useRef(null);
- const draggingId = useRef(null);
- const startDragPoint = useRef(null);
-
- const handleStartDrag = (nodeId: string, point: Point) => {
- const $node = svg.getElementById(nodeId) as SVGGElement;
-
- if (!$node) return;
-
- startDragPoint.current = getSvgPoint(svg, point);
- dragNodeBBox.current = $node.getBBox();
- draggingId.current = nodeId;
- isDragging.current = true;
- };
-
- const calculateUpdatedNodes = (newPoint: Point) => {
- return nodes
- .map((node) =>
- node.id === draggingId.current
- ? { ...node, point: newPoint }
- : node,
- )
- .sort((a, b) => {
- if (a.point.y === b.point.y) {
- return a.point.x - b.point.x;
- }
- return a.point.y - b.point.y;
- });
- };
-
- const calculateUpdatedEdges = (newPoint: Point) => {
- return edges.map((edge) => {
- const sourceAnchors = calculateAnchorPoints(
- edge.source.node,
- dimension,
- );
- const targetAnchors = calculateAnchorPoints(
- edge.target.node,
- dimension,
- );
-
- const nearestAnchorPair = findNearestAnchorPair(
- sourceAnchors,
- targetAnchors,
- );
-
- if (edge.source.node.id === draggingId.current) {
- return {
- ...edge,
- source: {
- ...edge.source,
- node: {
- ...edge.source.node,
- point: newPoint,
- },
- anchorType: !isUtilityNode(edge.source.node)
- ? nearestAnchorPair.sourceAnchorType
- : undefined,
- },
- target: {
- ...edge.target,
- anchorType: !isUtilityNode(edge.target.node)
- ? nearestAnchorPair.targetAnchorType
- : undefined,
- },
- };
- }
- if (edge.target.node.id === draggingId.current) {
- return {
- ...edge,
- source: {
- ...edge.source,
- anchorType: !isUtilityNode(edge.source.node)
- ? nearestAnchorPair.sourceAnchorType
- : undefined,
- },
- target: {
- ...edge.target,
- node: {
- ...edge.target.node,
- point: newPoint,
- },
- anchorType: !isUtilityNode(edge.target.node)
- ? nearestAnchorPair.targetAnchorType
- : undefined,
- },
- };
- }
-
- return edge;
- });
- };
-
- const handleDrag = (point: Point) => {
- if (!isDragging.current || !draggingId.current) return;
-
- const curPoint = getSvgPoint(svg, point);
- const { width, height } = dragNodeBBox.current!;
-
- const distance = getDistance(curPoint, startDragPoint.current!);
- if (distance < 10) return;
-
- const centerPoint = {
- x: curPoint.x - width / 2,
- y: curPoint.y - height / 2,
- };
- const newPoint = getGridAlignedPoint(centerPoint, dimension);
-
- const updatedNodes = calculateUpdatedNodes(newPoint);
- const updatedEdges = calculateUpdatedEdges(newPoint);
-
- dispatch({
- type: 'MOVE_NODE',
- payload: {
- nodes: updatedNodes,
- edges: updatedEdges,
- },
- });
- };
-
- const handleStopDrag = () => {
- isDragging.current = false;
- draggingId.current = null;
- dragNodeBBox.current = null;
- startDragPoint.current = null;
- };
-
- return {
- handleStartDrag,
- handleDrag,
- handleStopDrag,
- };
-}
diff --git a/apps/client/src/cloud-graph/hooks/useEdge.ts b/apps/client/src/cloud-graph/hooks/useEdge.ts
deleted file mode 100644
index b03f29d9..00000000
--- a/apps/client/src/cloud-graph/hooks/useEdge.ts
+++ /dev/null
@@ -1,145 +0,0 @@
-import { useGraphContext } from '@cloud-graph/contexts/GraphContext';
-import { Edge, Point } from '@cloud-graph/types';
-import { getSvgPoint } from '@cloud-graph/utils';
-import { nanoid } from 'nanoid';
-
-type Props = {
- svg?: SVGSVGElement;
-};
-export default ({ svg }: Props) => {
- const { edges, dispatch } = useGraphContext();
-
- const handleAdd = (edge: Pick) => {
- dispatch({
- type: 'ADD_EDGE',
- payload: {
- id: `edge-${nanoid()}`,
- ...edge,
- },
- });
- };
-
- const handleRemoveEntire = (edges: Edge[]) => {
- const pointerIds = new Set();
- edges.forEach((edge) => {
- const { source, target } = edge;
- if (source.node.type === 'pointer') pointerIds.add(source.node.id);
- if (target.node.type === 'pointer') pointerIds.add(target.node.id);
- });
-
- dispatch({
- type: 'REMOVE_ENTIRE_EDGE',
- payload: {
- pointerIds: Array.from(pointerIds),
- edgeIds: edges.map((edge) => edge.id),
- },
- });
- };
-
- const handleRemove = (id: string) => {
- const selectedEdge = edges.find((edge) => edge.id === id);
- if (!selectedEdge) return;
-
- const { source, target } = selectedEdge;
- let filteredEdges = edges.filter((edge) => edge.id !== id);
-
- let pointerId;
-
- //INFO: source.type === 'pointer' && target.type ==='pointer'조건도 target pointer로 위치를 변경하여
- //source.type ==='pointer'와 조건이 동일
- if (source.node.type === 'pointer') {
- const sourceEdge = edges.find(
- (edge) => edge.target.node.id === source.node.id,
- );
- pointerId = source.node.id;
- filteredEdges = filteredEdges.map((edge) => {
- if (sourceEdge && edge.id === sourceEdge.id) {
- return {
- ...edge,
- target: selectedEdge.target,
- type:
- selectedEdge.target.node.type === 'pointer'
- ? 'line'
- : 'arrow',
- };
- }
- return edge;
- });
- } else if (target.node.type === 'pointer') {
- const targetEdge = edges.find(
- (edge) => edge.source.node.id === target.node.id,
- );
- pointerId = target.node.id;
- filteredEdges = filteredEdges.map((edge) => {
- if (targetEdge && edge.id === targetEdge.id) {
- return {
- ...edge,
- source: selectedEdge.source,
- };
- }
- return edge;
- });
- }
-
- dispatch({
- type: 'REMOVE_EDGE',
- payload: {
- edges: filteredEdges,
- pointerId,
- },
- });
- };
-
- const handleSplit = (edge: Edge, point: Point) => {
- const svgPoint = getSvgPoint(svg!, point);
-
- const pointer = {
- id: `node-${nanoid()}`,
- type: 'pointer',
- point: svgPoint,
- size: {
- d2: { width: 4, height: 4 },
- d3: { width: 4, height: 4 },
- },
- };
-
- const sourceToPointer = {
- id: `edge-${nanoid()}`,
- type: 'line',
- source: {
- ...edge.source,
- },
- target: {
- node: pointer,
- },
- };
-
- const pointerToTarget = {
- id: `edge-${nanoid()}`,
- type: edge.target.node.type === 'pointer' ? 'line' : 'arrow',
- source: {
- node: pointer,
- },
- target: {
- ...edge.target,
- },
- };
-
- dispatch({
- type: 'SPLIT_EDGE',
- payload: {
- pointer,
- edge,
- sourceToPointer,
- pointerToTarget,
- },
- });
- };
-
- return {
- handleAdd,
- handleRemove,
- handleRemoveEntire,
- handleSplit,
- };
-};
diff --git a/apps/client/src/cloud-graph/hooks/useNode.ts b/apps/client/src/cloud-graph/hooks/useNode.ts
deleted file mode 100644
index ddabd0f1..00000000
--- a/apps/client/src/cloud-graph/hooks/useNode.ts
+++ /dev/null
@@ -1,74 +0,0 @@
-import { useGraphContext } from '@cloud-graph/contexts/GraphContext';
-import { Edge, Node } from '@cloud-graph/types';
-
-export default () => {
- const { edges, dispatch } = useGraphContext();
-
- const traverseEdge = (edge: Edge, direction: 'source' | 'target') => {
- let nextEdge: Edge | undefined;
- let nextNode =
- direction === 'target' ? edge.target.node : edge.source.node;
- let pointersToRemove: string[] = [];
- let edgesToRemove: string[] = [edge.id];
-
- while (nextNode.type === 'pointer') {
- pointersToRemove.push(nextNode.id);
- nextEdge =
- direction === 'target'
- ? edges.find((e) => e.source.node.id === nextNode!.id)
- : edges.find((e) => e.target.node.id === nextNode!.id);
-
- if (nextEdge) {
- edgesToRemove.push(nextEdge.id);
- nextNode =
- direction === 'target'
- ? nextEdge.target.node
- : nextEdge.source.node;
- } else {
- break;
- }
- }
-
- return {
- pointersToRemove,
- edgesToRemove,
- };
- };
-
- const handleAdd = (node: Node) => {
- dispatch({ type: 'ADD_NODE', payload: node });
- };
-
- const handleRemove = (id: string) => {
- const connectedEdges = edges.filter(
- (edge) => edge.source.node.id === id || edge.target.node.id === id,
- );
-
- const edgeIds: string[] = [];
- const nodeIds: string[] = [id];
-
- connectedEdges.forEach((edge) => {
- const direction = edge.source.node.id === id ? 'target' : 'source';
- const { pointersToRemove, edgesToRemove } = traverseEdge(
- edge,
- direction,
- );
-
- edgeIds.push(...edgesToRemove);
- nodeIds.push(...pointersToRemove);
- });
-
- dispatch({
- type: 'REMOVE_NODE',
- payload: {
- nodeIds,
- edgeIds,
- },
- });
- };
-
- return {
- handleAdd,
- handleRemove,
- };
-};
diff --git a/apps/client/src/cloud-graph/hooks/useSelect.ts b/apps/client/src/cloud-graph/hooks/useSelect.ts
deleted file mode 100644
index 15d57cd7..00000000
--- a/apps/client/src/cloud-graph/hooks/useSelect.ts
+++ /dev/null
@@ -1,83 +0,0 @@
-import { useGraphContext } from '@cloud-graph/contexts/GraphContext';
-import { Edge } from '@cloud-graph/types';
-import { useState } from 'react';
-
-export default () => {
- const { edges } = useGraphContext();
- const [selectedIds, setSelectedIds] = useState>(
- new Set(),
- );
-
- const handleSelect = (id: string) => {
- if (selectedIds.has(id)) {
- handleDeselectAll();
- return;
- }
- setSelectedIds(new Set([id]));
- };
-
- const handleMultiSelect = (id: string) => {
- if (selectedIds.has(id)) {
- handleDeselect(id);
- return;
- }
- setSelectedIds((prev) => {
- const newSet = new Set(prev);
- newSet.add(id);
- return newSet;
- });
- };
-
- const handleDeselect = (id: string) => {
- setSelectedIds((prev) => {
- const newSet = new Set(prev);
- newSet.delete(id);
- return newSet;
- });
- };
-
- const handleDeselectAll = () => {
- setSelectedIds(new Set());
- };
-
- const isSelected = (id: string) => {
- return selectedIds.has(id);
- };
-
- const handleSelectEntireEdge = (edge: Edge) => {
- if (selectedIds.has(edge.id)) {
- handleDeselectAll();
- return;
- }
- const { source, target } = edge;
- const ids = [edge.id];
- let sourceNode = source.node;
- let targetNode = target.node;
- while (sourceNode.type === 'pointer') {
- const sourceEdge = edges.find(
- (edge) => edge.target.node.id === sourceNode.id,
- );
- sourceNode = sourceEdge!.source.node;
- ids.push(sourceEdge!.id);
- }
- while (targetNode.type === 'pointer') {
- const targetEdge = edges.find(
- (edge) => edge.source.node.id === targetNode.id,
- );
- targetNode = targetEdge!.target.node;
- ids.push(targetEdge!.id);
- }
-
- setSelectedIds(new Set(ids));
- };
-
- return {
- selectedIds,
- isSelected,
- handleSelect,
- handleMultiSelect,
- handleDeselect,
- handleDeselectAll,
- handleSelectEntireEdge,
- };
-};
diff --git a/apps/client/src/cloud-graph/hooks/useSvgViewBox.ts b/apps/client/src/cloud-graph/hooks/useSvgViewBox.ts
deleted file mode 100644
index 8b526549..00000000
--- a/apps/client/src/cloud-graph/hooks/useSvgViewBox.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import { ViewBox } from '@cloud-graph/types';
-import { useLayoutEffect, useRef, useState } from 'react';
-
-export default () => {
- const svgRef = useRef(null);
- const [viewBox, setViewBox] = useState({
- x: 0,
- y: 0,
- width: 0,
- height: 0,
- });
-
- useLayoutEffect(() => {
- if (svgRef.current) {
- const updateViewBoxSize = () => {
- setViewBox((prev) => ({
- ...prev,
- width: svgRef.current!.clientWidth,
- height: svgRef.current!.clientHeight,
- }));
- };
- updateViewBoxSize();
- window.addEventListener('resize', updateViewBoxSize);
-
- return () => {
- window.removeEventListener('resize', updateViewBoxSize);
- };
- }
- }, []);
-
- return { svgRef, viewBox, setViewBox };
-};
diff --git a/apps/client/src/cloud-graph/hooks/useVisible.ts b/apps/client/src/cloud-graph/hooks/useVisible.ts
deleted file mode 100644
index 51cbab91..00000000
--- a/apps/client/src/cloud-graph/hooks/useVisible.ts
+++ /dev/null
@@ -1,73 +0,0 @@
-import {
- GRID_2D_SIZE,
- GRID_3D_DEPTH_SIZE,
- GRID_3D_HEIGHT_SIZE,
- GRID_3D_WIDTH_SIZE,
-} from '@cloud-graph/constants';
-import { Dimension, Edge, Node, ViewBox } from '@cloud-graph/types';
-import { useMemo } from 'react';
-
-type Props = {
- nodes: Node[];
- edges: Edge[];
- viewBox: ViewBox;
- dimension: Dimension;
-};
-
-export default ({ nodes, edges, viewBox, dimension }: Props) => {
- const calculateOffset = () => {
- return dimension === '2d'
- ? { width: GRID_2D_SIZE, height: GRID_2D_SIZE }
- : {
- width: GRID_3D_WIDTH_SIZE,
- height: GRID_3D_HEIGHT_SIZE + GRID_3D_DEPTH_SIZE,
- };
- };
-
- const isNodeVisible = (node: Node) => {
- const { x, y } = node.point;
- const offset = calculateOffset();
-
- return (
- x >= viewBox.x - offset.width &&
- x <= viewBox.x + viewBox.width + offset.width &&
- y >= viewBox.y - offset.height &&
- y <= viewBox.y + viewBox.height + offset.height
- );
- };
-
- const visibleNodes: Node[] = useMemo(
- () => nodes.filter(isNodeVisible),
- [nodes, isNodeVisible],
- );
-
- const mapEdgeToVisibleNodes = (edge: Edge) => {
- const sourceNode = visibleNodes.find(
- (node) => node.id === edge.source.node.id,
- );
- const targetNode = nodes.find(
- (node) => node.id === edge.target.node.id,
- );
-
- return sourceNode && targetNode
- ? {
- ...edge,
- source: {
- node: sourceNode,
- anchorType: edge.source.anchorType,
- },
- target: {
- node: targetNode,
- anchorType: edge.target.anchorType,
- },
- }
- : null;
- };
-
- const visibleEdges = useMemo(
- () => edges.map(mapEdgeToVisibleNodes).filter((node) => node !== null),
- [edges, mapEdgeToVisibleNodes],
- );
-
- return { visibleNodes, visibleEdges };
-};
diff --git a/apps/client/src/cloud-graph/hooks/useZoomPan.ts b/apps/client/src/cloud-graph/hooks/useZoomPan.ts
deleted file mode 100644
index 1baee165..00000000
--- a/apps/client/src/cloud-graph/hooks/useZoomPan.ts
+++ /dev/null
@@ -1,67 +0,0 @@
-import { Point, ViewBox } from '@cloud-graph/types';
-import { getSvgPoint } from '@cloud-graph/utils';
-import { Dispatch, SetStateAction, useRef } from 'react';
-
-type Props = {
- svg: SVGSVGElement;
- viewBox: ViewBox;
- setViewBox: Dispatch>;
-};
-
-export default ({ svg, viewBox, setViewBox }: Props) => {
- const isPanningRef = useRef(false);
- const startPointRef = useRef({ x: 0, y: 0 });
-
- const handleZoom = (wheelY: number, point: Point) => {
- const zoomFactor = wheelY > 0 ? 1.1 : 0.9;
- const cursorSvgPoint = getSvgPoint(svg, point);
- if (!cursorSvgPoint) return;
-
- setViewBox((prev) => {
- const newWidth = prev.width * zoomFactor;
- const newHeight = prev.height * zoomFactor;
-
- const deltaX = (cursorSvgPoint.x - prev.x) * (1 - zoomFactor);
- const deltaY = (cursorSvgPoint.y - prev.y) * (1 - zoomFactor);
-
- return {
- x: prev.x + deltaX,
- y: prev.y + deltaY,
- width: newWidth,
- height: newHeight,
- };
- });
- };
-
- const handleStartPan = (point: Point) => {
- isPanningRef.current = true;
- startPointRef.current = point;
- };
-
- const handleMovePan = (point: Point) => {
- if (!isPanningRef.current || !svg) return;
- document.body.style.cursor = 'grabbing';
-
- const dx =
- (startPointRef.current.x - point.x) *
- (viewBox.width / svg.clientWidth);
- const dy =
- (startPointRef.current.y - point.y) *
- (viewBox.height / svg.clientHeight);
-
- startPointRef.current = point;
-
- setViewBox((prev) => ({
- ...prev,
- x: prev.x + dx,
- y: prev.y + dy,
- }));
- };
-
- const handleStopPan = () => {
- isPanningRef.current = false;
- document.body.style.cursor = 'default';
- };
-
- return { handleStartPan, handleMovePan, handleStopPan, handleZoom };
-};
diff --git a/apps/client/src/cloud-graph/index.tsx b/apps/client/src/cloud-graph/index.tsx
deleted file mode 100644
index be2bca74..00000000
--- a/apps/client/src/cloud-graph/index.tsx
+++ /dev/null
@@ -1,177 +0,0 @@
-import Graph from '@/src/cloud-graph/components/Graph';
-import Anchors from '@cloud-graph/components/Anchors';
-import Connector from '@cloud-graph/components/Connector';
-import Edge from '@cloud-graph/components/Edge';
-import Node from '@cloud-graph/components/Node';
-import {
- DimensionProvider,
- useDimensionContext,
-} from '@cloud-graph/contexts/DimensionContext';
-import {
- GraphProvider,
- useGraphContext,
-} from '@cloud-graph/contexts/GraphContext';
-import useEdgeConnector from '@cloud-graph/hooks/useConnector';
-import useDrag from '@cloud-graph/hooks/useDrag';
-import useEdge from '@cloud-graph/hooks/useEdge';
-import useKey from '@cloud-graph/hooks/useKey';
-import useNode from '@cloud-graph/hooks/useNode';
-import useSelect from '@cloud-graph/hooks/useSelect';
-import useSvgViewBox from '@cloud-graph/hooks/useSvgViewBox';
-import useVisible from '@cloud-graph/hooks/useVisible';
-import useZoomPan from '@cloud-graph/hooks/useZoomPan';
-import { isCloudNode, isUtilityNode } from '@cloud-graph/utils';
-import { Box } from '@mui/material';
-import { ReactNode, useEffect } from 'react';
-
-//INFO: 일단 한곳에 모아놓고 코딩을 했음 나중에 리팩토링 필요
-//- 기존 메모이제이션 코드는 다 삭제, 개발 과정에서 메모이제이션을 적용하니 다른 기능 구현이 까다로워 짐
-//- 추후 성능 문제가 있을시 메모이제이션 적용해야함
-//- 최소한의 최적화로 viewBox내의 노드만 렌더링하도록 함
-//- Context로 구현한 의미가 없을 듯 함 -> 추후 리팩토링 필요, 코드 분리가 필요하여 일단은 hooks로 분리
-
-export const CloudGraph = () => {
- const { dimension } = useDimensionContext();
- const { nodes, edges } = useGraphContext();
- const { svgRef, viewBox, setViewBox } = useSvgViewBox();
- const { handleRemove: handleRemoveNode } = useNode();
- const {
- handleRemove: handleRemoveEdge,
- handleSplit: handleSplitEdge,
- handleRemoveEntire: handleRemoveEntireEdge,
- } = useEdge({
- svg: svgRef.current!,
- });
- const { handleStartDrag, handleStopDrag, handleDrag } = useDrag({
- svg: svgRef.current!,
- dimension,
- });
- const { handleZoom, handleStartPan, handleMovePan, handleStopPan } =
- useZoomPan({
- svg: svgRef.current!,
- viewBox,
- setViewBox,
- });
- const { connection, handleStartConnect, handleConnect, handleStopConnect } =
- useEdgeConnector({
- svg: svgRef.current!,
- dimension,
- nodes,
- });
-
- const { visibleEdges, visibleNodes } = useVisible({
- nodes,
- edges,
- viewBox,
- dimension,
- });
- const {
- isSelected,
- handleSelect,
- handleDeselectAll,
- handleSelectEntireEdge,
- handleMultiSelect,
- } = useSelect();
-
- // Delete
- const backspaceKey = useKey('backspace');
- useEffect(() => {
- if (backspaceKey) {
- const selectedNodes = nodes.filter((node) => isSelected(node.id));
- const selectedEdges = edges.filter((edge) => isSelected(edge.id));
- selectedNodes.forEach((node) => handleRemoveNode(node.id));
- if (selectedEdges.length === 1) {
- handleRemoveEdge(selectedEdges.at(0)!.id);
- } else if (selectedEdges.length > 1) {
- handleRemoveEntireEdge(selectedEdges);
- }
- handleDeselectAll();
- }
- }, [backspaceKey]);
-
- // Global Event Listener
- useEffect(() => {
- const handleContextMenu = (event: MouseEvent) => event.preventDefault();
- document.addEventListener('contextmenu', handleContextMenu);
-
- return () => {
- document.removeEventListener('contextmenu', handleContextMenu);
- };
- }, []);
-
- return (
-
-
- {visibleEdges.map((edge) => (
-
- ))}
- {visibleNodes.filter(isCloudNode).map((node) => {
- const isNodeSelected = isSelected(node.id);
- return (
-
-
-
-
- );
- })}
-
- {visibleNodes.filter(isUtilityNode).map((node) => (
-
- ))}
-
- {connection && (
-
- )}
-
-
- );
-};
-
-export const CloudGraphProvider = ({ children }: { children: ReactNode }) => {
- return (
-
- {children}
-
- );
-};
diff --git a/apps/client/src/cloud-graph/types/index.ts b/apps/client/src/cloud-graph/types/index.ts
deleted file mode 100644
index 12070d41..00000000
--- a/apps/client/src/cloud-graph/types/index.ts
+++ /dev/null
@@ -1,55 +0,0 @@
-export type Dimension = '2d' | '3d';
-
-export type Point = {
- x: number;
- y: number;
-};
-
-export type GridPoint = {
- col: number;
- row: number;
-};
-
-export type Size = {
- width: number;
- height: number;
-};
-
-export interface ViewBox extends Point, Size {}
-
-export type Node = {
- id: string;
- type: string;
- size: {
- d2: Size;
- d3: Size;
- };
- point: Point;
- label?: string;
- groupId?: string;
- properties?: Record;
-};
-
-export type Edge = {
- id: string;
- type: string;
- source: {
- node: Node;
- anchorType?: AnchorType;
- };
- target: {
- node: Node;
- anchorType?: AnchorType;
- };
-};
-
-export type Group = {
- id: string;
- type: string;
- label: string;
- point: Point;
- nodes: string[];
-};
-
-export type AnchorType = 'top' | 'right' | 'bottom' | 'left';
-export type Anchors = Record;
diff --git a/apps/client/src/cloud-graph/utils/index.ts b/apps/client/src/cloud-graph/utils/index.ts
deleted file mode 100644
index a4fc7da2..00000000
--- a/apps/client/src/cloud-graph/utils/index.ts
+++ /dev/null
@@ -1,171 +0,0 @@
-import {
- GRID_2D_SIZE,
- GRID_3D_DEPTH_SIZE,
- GRID_3D_HEIGHT_SIZE,
- GRID_3D_WIDTH_SIZE,
-} from '@cloud-graph/constants';
-import {
- Anchors,
- AnchorType,
- Dimension,
- GridPoint,
- Node,
- Point,
- Size,
-} from '@cloud-graph/types';
-
-export const getDistance = (point1: Point, point2: Point) => {
- return Math.sqrt((point1.x - point2.x) ** 2 + (point1.y - point2.y) ** 2);
-};
-
-export const getSvgPoint = (flow: SVGSVGElement, cursorPoint: Point) => {
- const svgPoint = flow.createSVGPoint();
- svgPoint.x = cursorPoint.x;
- svgPoint.y = cursorPoint.y;
- const screenCTM = flow.getScreenCTM();
- return svgPoint.matrixTransform(screenCTM!.inverse());
-};
-
-/* https://clintbellanger.net/articles/isometric_math/ */
-export const gridToScreen = (gridPoint: GridPoint): Point => {
- const { col, row } = gridPoint;
-
- const x = (col - row) * (GRID_3D_WIDTH_SIZE / 2);
- const y = (col + row) * (GRID_3D_HEIGHT_SIZE / 2);
-
- return { x, y };
-};
-
-export const screenToGrid = (point: Point) => {
- const { x, y } = point;
-
- const col =
- (x / (GRID_3D_WIDTH_SIZE / 2) + y / (GRID_3D_HEIGHT_SIZE / 2)) / 2;
- const row =
- (y / (GRID_3D_HEIGHT_SIZE / 2) - x / (GRID_3D_WIDTH_SIZE / 2)) / 2;
-
- return { col, row };
-};
-
-export const getGridAlignedPoint = (
- point: Point,
- dimension: Dimension,
-): Point => {
- if (dimension === '2d') {
- const snappedSize = GRID_2D_SIZE / 4;
- const gridAlignedX = Math.round(point.x / snappedSize) * snappedSize;
- const gridAlignedY = Math.round(point.y / snappedSize) * snappedSize;
-
- return {
- x: gridAlignedX,
- y: gridAlignedY,
- };
- } else if (dimension === '3d') {
- const snappedSize = 1 / 4;
- const { col, row } = screenToGrid(point);
-
- const snappedCol = Math.round(col / snappedSize) * snappedSize;
- const snappedRow = Math.round(row / snappedSize) * snappedSize;
-
- return gridToScreen({
- col: snappedCol,
- row: snappedRow,
- });
- } else {
- throw new Error('only support 2d and 3d dimension');
- }
-};
-
-export const getNodeSizeForDimension = (dimension: Dimension) => {
- const width = dimension === '2d' ? GRID_2D_SIZE : GRID_3D_WIDTH_SIZE;
- const height = dimension === '2d' ? GRID_2D_SIZE : GRID_3D_HEIGHT_SIZE;
-
- return { width, height };
-};
-
-export const calculateAnchorPoints = (
- node: Node,
- dimension: Dimension,
-): Anchors => {
- const point = node.point;
- const { width, height } = dimension === '2d' ? node.size.d2 : node.size.d3;
-
- return {
- top: { x: point.x + width / 2, y: point.y },
- right:
- dimension === '2d'
- ? { x: point.x + width, y: point.y + height / 2 }
- : {
- x: point.x + width,
- y: point.y + (height - GRID_3D_DEPTH_SIZE) / 2,
- },
- left:
- dimension === '2d'
- ? { x: point.x, y: point.y + height / 2 }
- : {
- x: point.x,
- y: point.y + (height - GRID_3D_DEPTH_SIZE) / 2,
- },
- bottom: { x: point.x + width / 2, y: point.y + height },
- };
-};
-
-export const findNearestAnchorPair = (
- sourceAnchors: Anchors,
- targetAnchros: Anchors,
-): {
- sourceAnchorType: AnchorType;
- targetAnchorType: AnchorType;
-} => {
- let nearestAnchorPair: {
- sourceAnchorType: AnchorType | null;
- targetAnchorType: AnchorType | null;
- distance: number;
- } = {
- sourceAnchorType: null,
- targetAnchorType: null,
- distance: Infinity,
- };
-
- Object.entries(sourceAnchors).forEach(
- ([sourceAnchorType, sourceAnchorPoint]) => {
- Object.entries(targetAnchros).forEach(
- ([targetAnchorType, targetAnchorPoint]) => {
- if (sourceAnchorType === targetAnchorType) return;
-
- const distance = getDistance(
- sourceAnchorPoint,
- targetAnchorPoint,
- );
-
- if (distance < nearestAnchorPair.distance) {
- nearestAnchorPair = {
- sourceAnchorType: sourceAnchorType as AnchorType,
- targetAnchorType: targetAnchorType as AnchorType,
- distance,
- };
- }
- },
- );
- },
- );
-
- return {
- sourceAnchorType: nearestAnchorPair.sourceAnchorType!,
- targetAnchorType: nearestAnchorPair.targetAnchorType!,
- };
-};
-
-export const isUtilityNode = (node: Node) => {
- return ['pointer'].includes(node.type);
-};
-
-export const isCloudNode = (node: Node) => {
- return ['server', 'cloud-function', 'object-storage', 'db-mysql'].includes(
- node.type,
- );
-};
-
-export const isCommonNode = (node: Node) => {
- return ['text', 'image'].includes(node.type);
-};
diff --git a/apps/client/src/components/BendingPointer.tsx b/apps/client/src/components/BendingPointer.tsx
new file mode 100644
index 00000000..fde53d2c
--- /dev/null
+++ b/apps/client/src/components/BendingPointer.tsx
@@ -0,0 +1,57 @@
+import useDrag from '@hooks/useDrag';
+import { useTheme } from '@mui/material';
+import { Point } from '@types';
+import { useEffect } from 'react';
+
+type Props = {
+ edgeId: string;
+ point: Point;
+ index: number;
+ onMove: (point: Point) => void;
+};
+
+export default ({ point, onMove }: Props) => {
+ const theme = useTheme();
+ const { isDragging, startDrag, drag, stopDrag } = useDrag({
+ initialPoint: point,
+ updateFn: (newPoint) => onMove(newPoint),
+ });
+
+ const handleMouseDown = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ const { clientX, clientY } = e;
+ startDrag({ x: clientX, y: clientY });
+ document.body.style.cursor = 'move';
+ };
+
+ const handleMouseMove = (e: MouseEvent) => {
+ drag({ x: e.clientX, y: e.clientY });
+ };
+
+ const handleMouseUp = () => {
+ stopDrag();
+ document.body.style.cursor = 'default';
+ };
+
+ useEffect(() => {
+ if (isDragging) {
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+ }
+
+ return () => {
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ };
+ }, [isDragging]);
+
+ return (
+
+ );
+};
diff --git a/apps/client/src/cloud-graph/components/Connector.tsx b/apps/client/src/components/Connection.tsx
similarity index 78%
rename from apps/client/src/cloud-graph/components/Connector.tsx
rename to apps/client/src/components/Connection.tsx
index 70b8169b..e4433c6f 100644
--- a/apps/client/src/cloud-graph/components/Connector.tsx
+++ b/apps/client/src/components/Connection.tsx
@@ -1,18 +1,18 @@
import { useTheme } from '@mui/material';
-import { Point } from '@cloud-graph/types';
+import { Point } from '@types';
type Props = {
- from: Point;
- to: Point;
+ source: Point;
+ target: Point;
};
-export default ({ from, to }: Props) => {
+export default ({ source, target }: Props) => {
const theme = useTheme();
const color =
theme.palette.mode === 'dark'
? theme.palette.grey[200]
: theme.palette.grey[800];
- const linePathD = `M ${from.x} ${from.y} L ${to.x} ${to.y}`;
+ const linePathD = `M ${source.x} ${source.y} L ${target.x} ${target.y}`;
return (
@@ -32,7 +32,7 @@ export default ({ from, to }: Props) => {
d={linePathD}
stroke={color}
fill="none"
- strokeWidth={2}
+ strokeWidth={3}
markerEnd="url(#arrowhead)"
/>
diff --git a/apps/client/src/components/Connectors/Connector.tsx b/apps/client/src/components/Connectors/Connector.tsx
new file mode 100644
index 00000000..0ad2b247
--- /dev/null
+++ b/apps/client/src/components/Connectors/Connector.tsx
@@ -0,0 +1,25 @@
+import { useTheme } from '@mui/material';
+import { Point } from '@types';
+
+type Props = {
+ visible: boolean;
+ point: Point;
+ onMouseDown: (e: React.MouseEvent) => void;
+};
+
+export default ({ point, visible, onMouseDown }: Props) => {
+ const theme = useTheme();
+
+ return (
+
+ );
+};
diff --git a/apps/client/src/components/Connectors/index.tsx b/apps/client/src/components/Connectors/index.tsx
new file mode 100644
index 00000000..4cb94ea4
--- /dev/null
+++ b/apps/client/src/components/Connectors/index.tsx
@@ -0,0 +1,70 @@
+import Connector from '@components/Connectors/Connector';
+import { Connection, Node, Point } from '@types';
+import { useEffect } from 'react';
+
+type Props = {
+ node: Node;
+ isSelected: boolean;
+ isConnecting: boolean;
+ onOpenConnection: (from: Connection) => void;
+ onConnectConnection: (point: Point) => void;
+ onCloseConnection: () => void;
+};
+
+export default ({
+ node,
+ isSelected,
+ isConnecting,
+ onOpenConnection,
+ onConnectConnection,
+ onCloseConnection,
+}: Props) => {
+ const handleMouseDown = (
+ e: React.MouseEvent,
+ connectorType: string,
+ point: Point,
+ ) => {
+ e.stopPropagation();
+ onOpenConnection({
+ id: node.id,
+ connectorType,
+ point,
+ });
+ document.body.style.cursor = 'move';
+ };
+
+ const handleMouseMove = (e: MouseEvent) => {
+ const { clientX, clientY } = e;
+ onConnectConnection({ x: clientX, y: clientY });
+ };
+
+ const handleCloseConnection = () => {
+ onCloseConnection();
+ document.body.style.cursor = 'default';
+ };
+
+ useEffect(() => {
+ if (isConnecting) {
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleCloseConnection);
+ }
+
+ return () => {
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleCloseConnection);
+ };
+ }, [isConnecting]);
+
+ return (
+ <>
+ {Object.entries(node.connectors).map(([type, point]) => (
+ handleMouseDown(e, type, point)}
+ />
+ ))}
+ >
+ );
+};
diff --git a/apps/client/src/components/Edge.tsx b/apps/client/src/components/Edge.tsx
new file mode 100644
index 00000000..1330d8c2
--- /dev/null
+++ b/apps/client/src/components/Edge.tsx
@@ -0,0 +1,123 @@
+import { useTheme } from '@mui/material';
+import { Edge, Point } from '@types';
+import { useEffect } from 'react';
+
+type Props = {
+ edge: Edge;
+ selectedEdge?: { id: string; segmentIdxes: number[] };
+ sourceConnector: Point;
+ targetConnector: Point;
+ onSplit: (id: string, point: Point, bendingPoints: Point[]) => void;
+ onSelectEntire: (id: string, segmentIdxes: number[]) => void;
+ onSelectSegment: (id: string, bendingPoint: Point[], point: Point) => void;
+ onRemove: (id: string, segmentIdxes: number[]) => void;
+};
+
+export default ({
+ edge,
+ selectedEdge,
+ sourceConnector,
+ targetConnector,
+ onSplit,
+ onSelectEntire,
+ onSelectSegment,
+ onRemove,
+}: Props) => {
+ const { id, type, bendingPoints } = edge;
+ const theme = useTheme();
+
+ const points = [sourceConnector, ...bendingPoints, targetConnector];
+ const isSelectedEdge = selectedEdge?.id === id;
+
+ const handleDoubleClick = () => {
+ onSelectEntire(
+ id,
+ Array.from({ length: points.length - 1 }, (_, i) => i),
+ );
+ };
+
+ const handleMouseDown = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ if (e.ctrlKey) {
+ const { clientX, clientY } = e;
+ onSplit(edge.id, { x: clientX, y: clientY }, points);
+ } else {
+ const { clientX, clientY } = e;
+ onSelectSegment(id, points, { x: clientX, y: clientY });
+ }
+
+ const handleMouseUp = () => {
+ document.removeEventListener('mouseup', handleMouseUp);
+ };
+
+ document.addEventListener('mouseup', handleMouseUp);
+ };
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (selectedEdge && e.key === 'Backspace') {
+ onRemove(id, selectedEdge.segmentIdxes);
+ }
+ };
+
+ useEffect(() => {
+ if (isSelectedEdge) {
+ document.addEventListener('keydown', handleKeyDown);
+ }
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [selectedEdge]);
+
+ return (
+
+
+
+
+
+
+ {points.slice(0, -1).map((point, idx) => {
+ const nextPoint = points[idx + 1];
+ const isLastPoint = idx === points.length - 2;
+ const isSelectedSegment =
+ isSelectedEdge && selectedEdge.segmentIdxes.includes(idx);
+
+ return (
+
+ );
+ })}
+
+ );
+};
diff --git a/apps/client/src/components/Graph/index.tsx b/apps/client/src/components/Graph/index.tsx
new file mode 100644
index 00000000..27a276b8
--- /dev/null
+++ b/apps/client/src/components/Graph/index.tsx
@@ -0,0 +1,132 @@
+import { useGraphContext } from '@contexts/GraphConetxt';
+import { useSvgContext } from '@contexts/SvgContext';
+import useKey from '@hooks/useKey';
+import { Point } from '@types';
+import { getSvgPoint } from '@utils';
+import { PropsWithChildren, useLayoutEffect, useRef } from 'react';
+
+export default ({ children }: PropsWithChildren) => {
+ const { svgRef } = useSvgContext();
+ const {
+ state: { viewBox },
+ dispatch,
+ } = useGraphContext();
+
+ const isPanning = useRef(false);
+ const startPoint = useRef({ x: 0, y: 0 });
+ const spaceActiveKey = useKey('space');
+
+ useLayoutEffect(() => {
+ if (svgRef.current) {
+ const updateViewBoxSize = () => {
+ dispatch({
+ type: 'SET_VIEWBOX',
+ payload: {
+ x: 0,
+ y: 0,
+ width: svgRef.current?.clientWidth ?? 0,
+ height: svgRef.current?.clientHeight ?? 0,
+ },
+ });
+ };
+ updateViewBoxSize();
+ window.addEventListener('resize', updateViewBoxSize);
+
+ return () => {
+ window.removeEventListener('resize', updateViewBoxSize);
+ };
+ }
+ }, [svgRef.current]);
+
+ const zoom = (wheelY: number, point: Point) => {
+ if (!svgRef.current) return;
+
+ const zoomFactor = wheelY > 0 ? 1.1 : 0.9;
+ const cursorSvgPoint = getSvgPoint(svgRef.current, point);
+ if (!cursorSvgPoint) return;
+
+ dispatch({
+ type: 'SET_VIEWBOX',
+ payload: {
+ x:
+ viewBox.x +
+ (cursorSvgPoint.x - viewBox.x) * (1 - zoomFactor),
+ y:
+ viewBox.y +
+ (cursorSvgPoint.y - viewBox.y) * (1 - zoomFactor),
+ width: viewBox.width * zoomFactor,
+ height: viewBox.height * zoomFactor,
+ },
+ });
+ };
+
+ const startPan = (point: Point) => {
+ isPanning.current = true;
+ startPoint.current = point;
+ };
+
+ const movePan = (point: Point) => {
+ if (!isPanning.current || !svgRef.current) return;
+
+ const svg = svgRef.current;
+ const dx =
+ (startPoint.current.x - point.x) *
+ (viewBox.width / svg.clientWidth);
+ const dy =
+ (startPoint.current.y - point.y) *
+ (viewBox.height / svg.clientHeight);
+ startPoint.current = point;
+
+ dispatch({
+ type: 'SET_VIEWBOX',
+ payload: {
+ x: viewBox.x + dx,
+ y: viewBox.y + dy,
+ width: viewBox.width,
+ height: viewBox.height,
+ },
+ });
+ };
+
+ const stopPan = () => (isPanning.current = false);
+
+ const handleWheel = (e: React.WheelEvent) => {
+ const { deltaY, clientX, clientY } = e;
+ zoom(deltaY, { x: clientX, y: clientY });
+ if (deltaY > 0) document.body.style.cursor = 'zoom-out';
+ else document.body.style.cursor = 'zoom-in';
+ };
+
+ const handleMouseDown = (e: React.MouseEvent) => {
+ if (!spaceActiveKey) return;
+ const { clientX, clientY } = e;
+ startPan({ x: clientX, y: clientY });
+ };
+
+ const handleMouseMove = (e: React.MouseEvent) => {
+ if (!isPanning.current) return;
+ const { clientX, clientY } = e;
+ movePan({ x: clientX, y: clientY });
+ document.body.style.cursor = 'grabbing';
+ };
+
+ const handleMouseUp = () => {
+ stopPan();
+ document.body.style.cursor = 'default';
+ };
+
+ return (
+
+ );
+};
diff --git a/apps/client/src/cloud-graph/components/Background/index.tsx b/apps/client/src/components/GridBackground/index.tsx
similarity index 65%
rename from apps/client/src/cloud-graph/components/Background/index.tsx
rename to apps/client/src/components/GridBackground/index.tsx
index 3af4d011..a713ce24 100644
--- a/apps/client/src/cloud-graph/components/Background/index.tsx
+++ b/apps/client/src/components/GridBackground/index.tsx
@@ -1,15 +1,16 @@
-import { Dimension, ViewBox } from '@cloud-graph/types';
+import { useDimensionContext } from '@contexts/DimensionContext';
+import { useGraphContext } from '@contexts/GraphConetxt';
import GridPatternMajor from './patterns/GridPatternMajor';
import GridPatternMinor from './patterns/GridPatternMinor';
-export default ({
- viewBox,
- dimension,
-}: {
- viewBox: ViewBox;
- dimension: Dimension;
-}) => {
+export default () => {
+ const { dimension } = useDimensionContext();
+ const {
+ state: { viewBox },
+ } = useGraphContext();
+
const { x, y, width, height } = viewBox;
+
const points = [
`${x},${y}`,
`${x + width},${y}`,
diff --git a/apps/client/src/cloud-graph/components/Background/patterns/GridPatternMajor.tsx b/apps/client/src/components/GridBackground/patterns/GridPatternMajor.tsx
similarity index 87%
rename from apps/client/src/cloud-graph/components/Background/patterns/GridPatternMajor.tsx
rename to apps/client/src/components/GridBackground/patterns/GridPatternMajor.tsx
index 0411001d..ef35ac90 100644
--- a/apps/client/src/cloud-graph/components/Background/patterns/GridPatternMajor.tsx
+++ b/apps/client/src/components/GridBackground/patterns/GridPatternMajor.tsx
@@ -1,10 +1,9 @@
import {
- GRID_3D_DEPTH_SIZE,
GRID_3D_HEIGHT_SIZE,
GRID_3D_WIDTH_SIZE,
GRID_2D_SIZE,
-} from '@cloud-graph/constants';
-import { Dimension } from '@cloud-graph/types';
+} from '@constants';
+import { Dimension } from '@types';
import { useTheme } from '@mui/material';
type Props = {
@@ -17,7 +16,7 @@ export default ({ points, dimension }: Props) => {
dimension === '2d'
? `M 0 0 L 90 0 90 90 0 90 z`
: `M 64 0 L 128 37 64 74 0 37 z`;
- const y = dimension === '2d' ? 0 : GRID_3D_DEPTH_SIZE;
+ const y = dimension === '2d' ? 0 : GRID_3D_HEIGHT_SIZE / 2;
const width = dimension === '2d' ? GRID_2D_SIZE : GRID_3D_WIDTH_SIZE;
const height = dimension === '2d' ? GRID_2D_SIZE : GRID_3D_HEIGHT_SIZE;
diff --git a/apps/client/src/cloud-graph/components/Background/patterns/GridPatternMinor.tsx b/apps/client/src/components/GridBackground/patterns/GridPatternMinor.tsx
similarity index 84%
rename from apps/client/src/cloud-graph/components/Background/patterns/GridPatternMinor.tsx
rename to apps/client/src/components/GridBackground/patterns/GridPatternMinor.tsx
index 368e0c1a..81987491 100644
--- a/apps/client/src/cloud-graph/components/Background/patterns/GridPatternMinor.tsx
+++ b/apps/client/src/components/GridBackground/patterns/GridPatternMinor.tsx
@@ -1,10 +1,9 @@
import {
- GRID_3D_DEPTH_SIZE,
GRID_3D_HEIGHT_SIZE,
GRID_3D_WIDTH_SIZE,
GRID_2D_SIZE,
-} from '@cloud-graph/constants';
-import { Dimension } from '@cloud-graph/types';
+} from '@constants';
+import { Dimension } from '@types';
import { useTheme } from '@mui/material';
type Props = {
@@ -15,8 +14,8 @@ type Props = {
export default ({ points, dimension }: Props) => {
const theme = useTheme();
const d1 = dimension === '2d' ? `M 0 45 L 90 45` : `M 0 0 L 128 74`;
- const d2 = dimension === '2d' ? `M 45 0 L 45 90` : `M 0 74 L 128 0`;
- const y = dimension === '2d' ? 0 : GRID_3D_DEPTH_SIZE;
+ const d2 = dimension === '2d' ? `M 45 0 L 45 90` : `M 128 0 L 0 74`;
+ const y = dimension === '2d' ? 0 : GRID_3D_HEIGHT_SIZE / 2;
const width = dimension === '2d' ? GRID_2D_SIZE : GRID_3D_WIDTH_SIZE;
const height = dimension === '2d' ? GRID_2D_SIZE : GRID_3D_HEIGHT_SIZE;
diff --git a/apps/client/src/components/Group/index.tsx b/apps/client/src/components/Group/index.tsx
new file mode 100644
index 00000000..32ff33b6
--- /dev/null
+++ b/apps/client/src/components/Group/index.tsx
@@ -0,0 +1,76 @@
+import RegionGroup from '@components/Group/ncloud/RegionGroup';
+import useDrag from '@hooks/useDrag';
+import { Bounds, Group, Point } from '@types';
+import { useEffect } from 'react';
+
+const GroupFacctory = (group: Group & { bounds: Bounds }) => {
+ switch (group.type) {
+ case 'region':
+ return ;
+ case 'vpc':
+ return ;
+ case 'subnet':
+ return ;
+ case 'security-group':
+ return ;
+ }
+};
+
+type Props = {
+ group: Group;
+ bounds: Bounds;
+ onMove: (id: string, offset: Point) => void;
+};
+
+export default ({ group, bounds, onMove }: Props) => {
+ const { id } = group;
+ const { isDragging, startDrag, drag, stopDrag } = useDrag({
+ initialPoint: { x: bounds.x, y: bounds.y },
+ updateFn: (point) => {
+ const offset = {
+ x: point.x - bounds.x,
+ y: point.y - bounds.y,
+ };
+
+ onMove(id, offset);
+ },
+ });
+
+ const handleMouseDown = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ const { clientX, clientY } = e;
+ startDrag({ x: clientX, y: clientY });
+ document.body.style.cursor = 'move';
+ };
+
+ const handleMouseMove = (moveEvent: MouseEvent) => {
+ drag({ x: moveEvent.clientX, y: moveEvent.clientY });
+ };
+
+ const handleMouseUp = () => {
+ stopDrag();
+ document.body.style.cursor = 'default';
+ };
+
+ useEffect(() => {
+ if (isDragging) {
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+ }
+
+ return () => {
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ };
+ }, [isDragging]);
+
+ return (
+
+ {GroupFacctory({ ...group, bounds })}
+
+ );
+};
diff --git a/apps/client/src/components/Group/ncloud/RegionGroup.tsx b/apps/client/src/components/Group/ncloud/RegionGroup.tsx
new file mode 100644
index 00000000..91e8f498
--- /dev/null
+++ b/apps/client/src/components/Group/ncloud/RegionGroup.tsx
@@ -0,0 +1,74 @@
+import { useDimensionContext } from '@contexts/DimensionContext';
+import { Bounds } from '@types';
+import { generateRandomRGB, gridToScreen3d, screenToGrid2d } from '@utils';
+import { useMemo } from 'react';
+
+type Props = {
+ bounds: Bounds;
+ stroke: string;
+};
+
+const Region3D = ({ bounds, stroke }: Props) => {
+ const topLeftGrid = screenToGrid2d({ x: 0, y: 0 });
+ const topRightGrid = screenToGrid2d({ x: bounds.width, y: 0 });
+ const bottomRightGrid = screenToGrid2d({
+ x: bounds.width,
+ y: bounds.height,
+ });
+ const bottomLeftGrid = screenToGrid2d({ x: 0, y: bounds.height });
+
+ const point1 = gridToScreen3d({
+ col: topLeftGrid.col + 1,
+ row: topLeftGrid.row,
+ });
+ const point2 = gridToScreen3d({
+ col: topRightGrid.col + 1,
+ row: topRightGrid.row,
+ });
+ const point3 = gridToScreen3d({
+ col: bottomRightGrid.col + 1,
+ row: bottomRightGrid.row,
+ });
+ const point4 = gridToScreen3d({
+ col: bottomLeftGrid.col + 1,
+ row: bottomLeftGrid.row,
+ });
+
+ const points = `
+ ${point1.x} ${point1.y},
+ ${point2.x} ${point2.y},
+ ${point3.x} ${point3.y},
+ ${point4.x} ${point4.y}
+ `;
+ return (
+
+ );
+};
+
+const Region2D = ({ bounds, stroke }: Props) => {
+ const points = `0 0, 0 ${bounds.height}, ${bounds.width} ${bounds.height}, ${bounds.width} 0`;
+ return (
+
+ );
+};
+
+export default ({ bounds }: Pick) => {
+ const { dimension } = useDimensionContext();
+ const stroke = useMemo(() => generateRandomRGB(), []);
+
+ return dimension === '2d' ? (
+
+ ) : (
+
+ );
+};
diff --git a/apps/client/src/components/Header/index.tsx b/apps/client/src/components/Layout/Header/index.tsx
similarity index 94%
rename from apps/client/src/components/Header/index.tsx
rename to apps/client/src/components/Layout/Header/index.tsx
index 326ccd5d..c24a4d7a 100644
--- a/apps/client/src/components/Header/index.tsx
+++ b/apps/client/src/components/Layout/Header/index.tsx
@@ -1,4 +1,4 @@
-import { useDimensionContext } from '@cloud-graph/contexts/DimensionContext';
+import { useDimensionContext } from '@contexts/DimensionContext';
import DarkModeIcon from '@mui/icons-material/DarkMode';
import GitHubIcon from '@mui/icons-material/GitHub';
import LightModeIcon from '@mui/icons-material/LightMode';
@@ -37,7 +37,7 @@ const StyledIconButton = styled(IconButton)(({ theme }) => ({
export default () => {
const { mode: themeMode, setMode: setThemeMode } = useColorScheme();
- const { dimension, handleToggleDimension } = useDimensionContext();
+ const { dimension, toggleDimension } = useDimensionContext();
const handleToggleTheme = () =>
setThemeMode(themeMode === 'dark' ? 'light' : 'dark');
@@ -55,7 +55,7 @@ export default () => {
({
backgroundColor:
@@ -51,7 +50,7 @@ export default ({
items: Array<{
title: string;
desc: string;
- nodeType?: string;
+ type: string;
}>;
}) => {
return (
@@ -75,6 +74,7 @@ export default ({
{items.map((item, index) => (
{}}
diff --git a/apps/client/src/components/Sidebar/ServiceInstance.tsx b/apps/client/src/components/Layout/Sidebar/ServiceInstance.tsx
similarity index 71%
rename from apps/client/src/components/Sidebar/ServiceInstance.tsx
rename to apps/client/src/components/Layout/Sidebar/ServiceInstance.tsx
index 4ee9cc85..8f9d63ee 100644
--- a/apps/client/src/components/Sidebar/ServiceInstance.tsx
+++ b/apps/client/src/components/Layout/Sidebar/ServiceInstance.tsx
@@ -1,3 +1,4 @@
+import useGraphActions from '@hooks/useGraphActions';
import ListItem, { ListItemProps } from '@mui/material/ListItem';
import ListItemText from '@mui/material/ListItemText';
import { styled } from '@mui/material/styles';
@@ -20,11 +21,18 @@ const StyledServiceInstanceText = styled(ListItemText)(({ theme }) => ({
export default ({
title,
+ type,
desc,
...props
-}: { title: string; desc: string } & ListItemProps) => {
+}: { title: string; desc: string; type: string } & ListItemProps) => {
+ const { addNode } = useGraphActions();
+
return (
-
+ addNode(type)}
+ >
);
diff --git a/apps/client/src/components/Sidebar/index.tsx b/apps/client/src/components/Layout/Sidebar/index.tsx
similarity index 89%
rename from apps/client/src/components/Sidebar/index.tsx
rename to apps/client/src/components/Layout/Sidebar/index.tsx
index 6d347bf5..1b2faced 100644
--- a/apps/client/src/components/Sidebar/index.tsx
+++ b/apps/client/src/components/Layout/Sidebar/index.tsx
@@ -7,9 +7,9 @@ import IconButton from '@mui/material/IconButton';
import SearchIcon from '@mui/icons-material/Search';
import List from '@mui/material/List';
-import { MOCK_SERVICES } from '@/mocks';
-import Service from '@components/Sidebar/Service';
-import SelectPlatform from '@components/Sidebar/SelectPlatform';
+import Service from '@components/Layout/Sidebar/Service';
+import SelectPlatform from '@components/Layout/Sidebar/SelectPlatform';
+import { NCLOUD_SERVICES } from '@constants';
const CLOUD_PLATFORMS = [
{
@@ -60,7 +60,7 @@ export default () => {
overflow: 'auto',
}}
>
- {MOCK_SERVICES.map((service) => (
+ {NCLOUD_SERVICES.map((service) => (
{
+ switch (node.type) {
+ case 'server':
+ return ;
+ case 'cloud-function':
+ return ;
+ case 'object-storage':
+ return ;
+ case 'db-mysql':
+ return ;
+ default:
+ null;
+ }
+};
+type Props = {
+ node: Node;
+ isSelected: boolean;
+ onMove: (id: string, newPoint: Point) => void;
+ onSelect: (id: string) => void;
+ onRemove: (id: string) => void;
+};
+export default ({ node, isSelected, onMove, onSelect, onRemove }: Props) => {
+ const { id, point } = node;
+
+ const { isDragging, startDrag, drag, stopDrag } = useDrag({
+ initialPoint: point,
+ updateFn: (newPoint) => onMove(id, newPoint),
+ });
+
+ const handleMouseDown = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ const { clientX, clientY } = e;
+ startDrag({ x: clientX, y: clientY });
+ onSelect(id);
+ document.body.style.cursor = 'move';
+ };
+
+ const handleMouseMove = (e: MouseEvent) => {
+ drag({ x: e.clientX, y: e.clientY });
+ };
+
+ const handleMouseUp = () => {
+ stopDrag();
+ document.body.style.cursor = 'default';
+ };
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Backspace') {
+ onRemove(id);
+ }
+ };
+
+ useEffect(() => {
+ if (isDragging) {
+ document.addEventListener('mousemove', handleMouseMove);
+ document.addEventListener('mouseup', handleMouseUp);
+ }
+
+ return () => {
+ document.removeEventListener('mousemove', handleMouseMove);
+ document.removeEventListener('mouseup', handleMouseUp);
+ };
+ }, [isDragging]);
+
+ useEffect(() => {
+ if (isSelected) {
+ document.addEventListener('keydown', handleKeyDown);
+ }
+ return () => {
+ document.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [isSelected]);
+
+ return (
+
+ {nodeFactory(node)}
+
+ );
+};
diff --git a/apps/client/src/cloud-graph/components/Node/ncloud/CloudFunctionNode.tsx b/apps/client/src/components/Node/ncloud/CloudFunctionNode.tsx
similarity index 96%
rename from apps/client/src/cloud-graph/components/Node/ncloud/CloudFunctionNode.tsx
rename to apps/client/src/components/Node/ncloud/CloudFunctionNode.tsx
index 15819d17..240f1f97 100644
--- a/apps/client/src/cloud-graph/components/Node/ncloud/CloudFunctionNode.tsx
+++ b/apps/client/src/components/Node/ncloud/CloudFunctionNode.tsx
@@ -1,8 +1,5 @@
-import { Dimension } from '@cloud-graph/types';
-
-type Props = {
- dimension: Dimension;
-};
+import { useDimensionContext } from '@contexts/DimensionContext';
+import { Node } from '@types';
const Node3D = () => {
return (
@@ -123,6 +120,7 @@ const Node2D = () => {
);
};
-export default ({ dimension }: Props) => {
+export default ({}: Partial) => {
+ const { dimension } = useDimensionContext();
return dimension === '2d' ? : ;
};
diff --git a/apps/client/src/components/Node/ncloud/DBMySQLNode.tsx b/apps/client/src/components/Node/ncloud/DBMySQLNode.tsx
new file mode 100644
index 00000000..0ad78def
--- /dev/null
+++ b/apps/client/src/components/Node/ncloud/DBMySQLNode.tsx
@@ -0,0 +1,103 @@
+import { useDimensionContext } from '@contexts/DimensionContext';
+import { Node } from '@types';
+
+const Node3D = () => {
+ return (
+
+ );
+};
+
+const Node2D = () => {
+ return (
+ <>
+
+ >
+ );
+};
+export default ({}: Partial) => {
+ const { dimension } = useDimensionContext();
+ return dimension === '2d' ? : ;
+};
diff --git a/apps/client/src/cloud-graph/components/Node/ncloud/ObjectStorageNode.tsx b/apps/client/src/components/Node/ncloud/ObjectStorageNode.tsx
similarity index 94%
rename from apps/client/src/cloud-graph/components/Node/ncloud/ObjectStorageNode.tsx
rename to apps/client/src/components/Node/ncloud/ObjectStorageNode.tsx
index 1769ddc0..986417d7 100644
--- a/apps/client/src/cloud-graph/components/Node/ncloud/ObjectStorageNode.tsx
+++ b/apps/client/src/components/Node/ncloud/ObjectStorageNode.tsx
@@ -1,8 +1,5 @@
-import { Dimension } from '@cloud-graph/types';
-
-type Props = {
- dimension: Dimension;
-};
+import { useDimensionContext } from '@contexts/DimensionContext';
+import { Node } from '@types';
const Node3D = () => {
return (
@@ -60,6 +57,7 @@ const Node2D = () => {
);
};
-export default ({ dimension }: Props) => {
+export default ({}: Partial) => {
+ const { dimension } = useDimensionContext();
return dimension === '2d' ? : ;
};
diff --git a/apps/client/src/cloud-graph/components/Node/ncloud/ServerNode.tsx b/apps/client/src/components/Node/ncloud/ServerNode.tsx
similarity index 87%
rename from apps/client/src/cloud-graph/components/Node/ncloud/ServerNode.tsx
rename to apps/client/src/components/Node/ncloud/ServerNode.tsx
index 13265ba7..73cc3f8f 100644
--- a/apps/client/src/cloud-graph/components/Node/ncloud/ServerNode.tsx
+++ b/apps/client/src/components/Node/ncloud/ServerNode.tsx
@@ -1,14 +1,9 @@
-import { Dimension, Node } from '@cloud-graph/types';
+import { useDimensionContext } from '@contexts/DimensionContext';
+import { Node } from '@types';
-type NodeProps = {
- label?: string;
-};
-
-interface Props extends NodeProps {
- dimension: Dimension;
-}
+type Props = {};
-const Node3D = ({ label }: NodeProps) => {
+const Node3D = () => {
return (
<>
>
);
};
-const Node2D = ({ label }: NodeProps) => {
+const Node2D = () => {
return (
<>
>
);
};
-export default ({ label, dimension }: Props) => {
- return dimension === '2d' ? (
-
- ) : (
-
- );
+export default ({}: Partial) => {
+ const { dimension } = useDimensionContext();
+ return dimension === '2d' ? : ;
};
diff --git a/apps/client/src/constants/index.ts b/apps/client/src/constants/index.ts
new file mode 100644
index 00000000..b44849d7
--- /dev/null
+++ b/apps/client/src/constants/index.ts
@@ -0,0 +1,37 @@
+export const GRID_2D_SIZE = 90;
+export const GRID_3D_WIDTH_SIZE = 128;
+export const GRID_3D_HEIGHT_SIZE = 74;
+export const NODE_BASE_SIZE = {
+ '2d': { width: 90, height: 90 },
+ '3d': { width: 128, height: 111 },
+};
+
+export const NCLOUD_SERVICES = [
+ {
+ title: 'compute',
+ items: [
+ {
+ title: 'Compute Server',
+ desc: 'Compute server instances',
+ type: 'server',
+ },
+ {
+ title: 'Cloud Functions',
+ desc: 'Serverless functions',
+ type: 'cloud-function',
+ },
+ ],
+ },
+ {
+ title: 'database',
+ items: [
+ {
+ title: 'DB for MySQL',
+ desc: 'Managed MySQL database',
+ type: 'db-mysql',
+ },
+ ],
+ },
+];
+
+export const GROUP_TYPES = ['region', 'vpc', 'subnet', 'security'];
diff --git a/apps/client/src/contexts/DimensionContext.tsx b/apps/client/src/contexts/DimensionContext.tsx
new file mode 100644
index 00000000..274a5931
--- /dev/null
+++ b/apps/client/src/contexts/DimensionContext.tsx
@@ -0,0 +1,39 @@
+import { Dimension } from '@types';
+import { createContext, ReactNode, useContext, useRef, useState } from 'react';
+
+type DimensionState = {
+ dimension: Dimension;
+ prevDimension: Dimension;
+ toggleDimension: () => void;
+};
+
+const DimensionContext = createContext(null);
+
+export const DimensionProvider = ({ children }: { children: ReactNode }) => {
+ const [dimension, setDimension] = useState('2d');
+ const prevDimensionRef = useRef('2d');
+
+ const toggleDimension = () => {
+ prevDimensionRef.current = dimension;
+ setDimension((prev) => (prev === '2d' ? '3d' : '2d'));
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useDimensionContext = () => {
+ const context = useContext(DimensionContext);
+ if (!context) throw new Error('DimensionContext: context is undefined');
+
+ return context;
+};
diff --git a/apps/client/src/contexts/EdgeContext/index.tsx b/apps/client/src/contexts/EdgeContext/index.tsx
new file mode 100644
index 00000000..08184430
--- /dev/null
+++ b/apps/client/src/contexts/EdgeContext/index.tsx
@@ -0,0 +1,36 @@
+import {
+ EdgeAction,
+ edgeReducer,
+ EdgeState,
+} from '@contexts/EdgeContext/reducer';
+import { createContext, ReactNode, useContext, useReducer } from 'react';
+
+type EdgeContextProps = {
+ state: EdgeState;
+ dispatch: React.Dispatch;
+};
+
+const EdgeContext = createContext(undefined);
+
+const initialState: EdgeState = {
+ edges: {},
+ connection: null,
+};
+
+export const EdgeProvider = ({ children }: { children: ReactNode }) => {
+ const [state, dispatch] = useReducer(edgeReducer, initialState);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useEdgeContext = () => {
+ const context = useContext(EdgeContext);
+ if (!context) {
+ throw new Error('EdgeContext: context is undefined');
+ }
+ return context;
+};
diff --git a/apps/client/src/contexts/EdgeContext/reducer.ts b/apps/client/src/contexts/EdgeContext/reducer.ts
new file mode 100644
index 00000000..0b986f13
--- /dev/null
+++ b/apps/client/src/contexts/EdgeContext/reducer.ts
@@ -0,0 +1,182 @@
+import { getClosestSegEdgeIdx } from '@helpers/edge';
+import { Edge, Point } from '@types';
+
+export type EdgeState = {
+ edges: Record;
+ connection: { from: Point; to: Point } | null;
+};
+
+export type EdgeAction =
+ | { type: 'ADD_EDGE'; payload: Omit }
+ | { type: 'UPDATE_EDGE'; payload: Partial & { id: string } }
+ | { type: 'UPDATE_EDGES'; payload: Record }
+ | {
+ type: 'REMOVE_EDGE';
+ payload: { id: string; segmentIdxes: number[] };
+ }
+ | {
+ type: 'SPLIT_EDGE';
+ payload: { id: string; point: Point; insertAfter: number };
+ }
+ | { type: 'REMOVE_EDGES'; payload: string[] }
+ | {
+ type: 'MOVE_BENDING_POINTER';
+ payload: {
+ id: string;
+ bendingPointer: {
+ index: number;
+ point: Point;
+ };
+ connector?: {
+ [key: string]: {
+ id: string;
+ connectorType: string;
+ };
+ };
+ };
+ };
+
+export const edgeReducer = (
+ state: EdgeState,
+ action: EdgeAction,
+): EdgeState => {
+ switch (action.type) {
+ case 'ADD_EDGE':
+ return {
+ ...state,
+ edges: {
+ ...state.edges,
+ [action.payload.id]: {
+ ...action.payload,
+ bendingPoints: [],
+ },
+ },
+ };
+ case 'UPDATE_EDGE':
+ return {
+ ...state,
+ edges: {
+ ...state.edges,
+ [action.payload.id]: {
+ ...state.edges[action.payload.id],
+ ...action.payload,
+ },
+ },
+ };
+ case 'UPDATE_EDGES': {
+ return {
+ ...state,
+ edges: {
+ ...state.edges,
+ ...action.payload,
+ },
+ };
+ }
+ case 'REMOVE_EDGES': {
+ const removedEdge = action.payload;
+ const remainingEdges = Object.values(state.edges).reduce(
+ (acc, edge) => {
+ if (removedEdge.includes(edge.id)) return acc;
+ return {
+ [edge.id]: {
+ ...edge,
+ },
+ };
+ },
+ {},
+ );
+ return {
+ ...state,
+ edges: {
+ ...remainingEdges,
+ },
+ };
+ }
+ case 'REMOVE_EDGE': {
+ const { id, segmentIdxes } = action.payload;
+ const { [id]: removedEdge, ...remainingEdges } = state.edges;
+ let updatedEdges = {};
+ if (
+ removedEdge.bendingPoints.length >= segmentIdxes.length &&
+ removedEdge.bendingPoints.length > 0
+ ) {
+ const newBendPoints = removedEdge.bendingPoints.filter(
+ (_, idx) => {
+ //INFO: bendingPoints와 segmentIdxes의 수가 동일시 되지 않아 조정이 필요
+ //마지막 선분이 아닌 첫번째 선분을 따로 처리하게 되면 선분이 삭제되고 다음 선분으로 선택됨
+ //따라서 첫번째 선분이 아닌 마지막 선분을 따로 처리하는 방안으로 변경
+ if (
+ segmentIdxes.includes(
+ removedEdge.bendingPoints.length,
+ ) &&
+ idx === removedEdge.bendingPoints.length - 1
+ ) {
+ return false;
+ }
+
+ return !segmentIdxes.includes(idx);
+ },
+ );
+
+ updatedEdges = {
+ [id]: {
+ ...removedEdge,
+ bendingPoints: newBendPoints,
+ },
+ };
+ }
+
+ return {
+ ...state,
+ edges: {
+ ...remainingEdges,
+ ...updatedEdges,
+ },
+ };
+ }
+ case 'SPLIT_EDGE': {
+ const { id, point, insertAfter } = action.payload;
+ const edge = state.edges[id];
+ if (!edge) return state;
+
+ const newBendPoints = [...edge.bendingPoints];
+ newBendPoints.splice(insertAfter, 0, point);
+ return {
+ ...state,
+ edges: {
+ ...state.edges,
+ [id]: {
+ ...edge,
+ bendingPoints: newBendPoints,
+ },
+ },
+ };
+ }
+ case 'MOVE_BENDING_POINTER': {
+ const { id, bendingPointer, connector } = action.payload;
+ const edge = state.edges[id];
+ const { index, point } = bendingPointer;
+ if (!edge || index < 0 || index >= edge.bendingPoints.length)
+ return state;
+
+ const updatedBendPoints = [...edge.bendingPoints];
+ updatedBendPoints[index] = point;
+
+ let updatedConnector = {};
+ if (connector) updatedConnector = connector;
+ return {
+ ...state,
+ edges: {
+ ...state.edges,
+ [id]: {
+ ...edge,
+ bendingPoints: updatedBendPoints,
+ ...updatedConnector,
+ },
+ },
+ };
+ }
+ default:
+ return state;
+ }
+};
diff --git a/apps/client/src/contexts/GraphConetxt/index.tsx b/apps/client/src/contexts/GraphConetxt/index.tsx
new file mode 100644
index 00000000..8509303a
--- /dev/null
+++ b/apps/client/src/contexts/GraphConetxt/index.tsx
@@ -0,0 +1,41 @@
+import {
+ GraphAction,
+ graphReducer,
+ GraphState,
+} from '@contexts/GraphConetxt/reducer';
+import {
+ createContext,
+ Dispatch,
+ ReactNode,
+ useContext,
+ useReducer,
+} from 'react';
+
+type GraphContextProps = {
+ state: GraphState;
+ dispatch: Dispatch;
+};
+
+const CanvasContext = createContext(undefined);
+
+const initialState = {
+ viewBox: { x: 0, y: 0, width: 1000, height: 1000 },
+};
+
+export const GraphProvider = ({ children }: { children: ReactNode }) => {
+ const [state, dispatch] = useReducer(graphReducer, initialState);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useGraphContext = () => {
+ const context = useContext(CanvasContext);
+ if (!context) {
+ throw new Error('GraphContext: context is undefined');
+ }
+ return context;
+};
diff --git a/apps/client/src/contexts/GraphConetxt/reducer.ts b/apps/client/src/contexts/GraphConetxt/reducer.ts
new file mode 100644
index 00000000..f4e5563b
--- /dev/null
+++ b/apps/client/src/contexts/GraphConetxt/reducer.ts
@@ -0,0 +1,22 @@
+import { ViewBox } from '@types';
+
+export type GraphState = {
+ viewBox: ViewBox;
+};
+
+export type GraphAction = {
+ type: 'SET_VIEWBOX';
+ payload: GraphState['viewBox'];
+};
+
+export const graphReducer = (
+ state: GraphState,
+ action: GraphAction,
+): GraphState => {
+ switch (action.type) {
+ case 'SET_VIEWBOX':
+ return { ...state, viewBox: action.payload };
+ default:
+ return state;
+ }
+};
diff --git a/apps/client/src/contexts/GroupContext/index.tsx b/apps/client/src/contexts/GroupContext/index.tsx
new file mode 100644
index 00000000..034e3a3c
--- /dev/null
+++ b/apps/client/src/contexts/GroupContext/index.tsx
@@ -0,0 +1,41 @@
+import { mockInitialState } from '@/src/mocks';
+import {
+ GroupAction,
+ groupReducer,
+ GroupState,
+} from '@contexts/GroupContext/reducer';
+import {
+ createContext,
+ PropsWithChildren,
+ useContext,
+ useReducer,
+} from 'react';
+
+type GroupContextProps = {
+ state: GroupState;
+ dispatch: React.Dispatch;
+};
+
+const GroupContext = createContext(undefined);
+
+const initialState: GroupState = {
+ groups: mockInitialState.groups,
+};
+
+export const GroupProvider = ({ children }: PropsWithChildren) => {
+ const [state, dispatch] = useReducer(groupReducer, initialState);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useGroupContext = () => {
+ const context = useContext(GroupContext);
+ if (!context) {
+ throw new Error('useGroupContext must be used within a GroupProvider');
+ }
+ return context;
+};
diff --git a/apps/client/src/contexts/GroupContext/reducer.ts b/apps/client/src/contexts/GroupContext/reducer.ts
new file mode 100644
index 00000000..8b825c0b
--- /dev/null
+++ b/apps/client/src/contexts/GroupContext/reducer.ts
@@ -0,0 +1,66 @@
+import { Group } from '@types';
+
+export type GroupState = {
+ groups: Record;
+};
+
+export type GroupAction =
+ | { type: 'ADD_GROUP'; payload: Group }
+ | { type: 'UPDATE_GROUP'; payload: Partial & { id: string } }
+ | { type: 'REMOVE_GROUP'; payload: { id: string } }
+ | {
+ type: 'REMOVE_NODE_FROM_GROUP';
+ payload: { groupId: string; nodeId: string };
+ };
+
+export const groupReducer = (
+ state: GroupState,
+ action: GroupAction,
+): GroupState => {
+ switch (action.type) {
+ case 'ADD_GROUP':
+ return {
+ ...state,
+ groups: {
+ ...state.groups,
+ [action.payload.id]: action.payload,
+ },
+ };
+ case 'UPDATE_GROUP':
+ return {
+ ...state,
+ groups: {
+ ...state.groups,
+ [action.payload.id]: {
+ ...state.groups[action.payload.id],
+ ...action.payload,
+ },
+ },
+ };
+ case 'REMOVE_GROUP': {
+ const { id } = action.payload;
+ const { [id]: removedGroup, ...remainingGroups } = state.groups;
+ return {
+ ...state,
+ groups: remainingGroups,
+ };
+ }
+ case 'REMOVE_NODE_FROM_GROUP': {
+ const { groupId, nodeId } = action.payload;
+ return {
+ ...state,
+ groups: {
+ ...state.groups,
+ [groupId]: {
+ ...state.groups[groupId],
+ nodeIds: state.groups[groupId].nodeIds.filter(
+ (id) => id !== nodeId,
+ ),
+ },
+ },
+ };
+ }
+ default:
+ return state;
+ }
+};
diff --git a/apps/client/src/contexts/NodeContext/index.tsx b/apps/client/src/contexts/NodeContext/index.tsx
new file mode 100644
index 00000000..e31dc22a
--- /dev/null
+++ b/apps/client/src/contexts/NodeContext/index.tsx
@@ -0,0 +1,42 @@
+import { mockInitialState } from '@/src/mocks';
+import {
+ NodeAction,
+ nodeReducer,
+ NodeState,
+} from '@contexts/NodeContext/reducer';
+import {
+ createContext,
+ Dispatch,
+ ReactNode,
+ useContext,
+ useReducer,
+} from 'react';
+
+type NodeContextProps = {
+ state: NodeState;
+ dispatch: Dispatch;
+};
+
+const NodeContext = createContext(undefined);
+
+const initialState: NodeState = {
+ nodes: mockInitialState.nodes,
+};
+
+export const NodeProvider = ({ children }: { children: ReactNode }) => {
+ const [state, dispatch] = useReducer(nodeReducer, initialState);
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useNodeContext = () => {
+ const context = useContext(NodeContext);
+ if (!context) {
+ throw new Error('NodeContext: context is undefined');
+ }
+ return context;
+};
diff --git a/apps/client/src/contexts/NodeContext/reducer.ts b/apps/client/src/contexts/NodeContext/reducer.ts
new file mode 100644
index 00000000..4f73c84f
--- /dev/null
+++ b/apps/client/src/contexts/NodeContext/reducer.ts
@@ -0,0 +1,94 @@
+import { ConnectorMap, Node, Point } from '@types';
+
+export type NodeState = {
+ nodes: Record;
+};
+
+export type NodeAction =
+ | { type: 'ADD_NODE'; payload: Node }
+ | { type: 'UPDATE_NODE'; payload: Partial & { id: string } }
+ | { type: 'UPDATE_NODES'; payload: Record }
+ | { type: 'REMOVE_NODE'; payload: { id: string } }
+ | {
+ type: 'MOVE_NODE';
+ payload: { id: string; point: Point; connectors: ConnectorMap };
+ };
+
+export const nodeReducer = (
+ state: NodeState,
+ action: NodeAction,
+): NodeState => {
+ switch (action.type) {
+ case 'ADD_NODE':
+ return {
+ ...state,
+ nodes: {
+ ...state.nodes,
+ [action.payload.id]: action.payload,
+ },
+ };
+ case 'UPDATE_NODE':
+ return {
+ ...state,
+ nodes: {
+ ...state.nodes,
+ [action.payload.id]: {
+ ...state.nodes[action.payload.id],
+ ...action.payload,
+ },
+ },
+ };
+ case 'REMOVE_NODE': {
+ const { id } = action.payload;
+ const { [id]: removedNode, ...remainingNodes } = state.nodes;
+ return {
+ ...state,
+ nodes: remainingNodes,
+ };
+ }
+ case 'MOVE_NODE': {
+ const { id, point, connectors } = action.payload;
+ const node = state.nodes[id];
+ if (!node) return state;
+
+ const updatedNodes = Object.values({
+ ...state.nodes,
+ [id]: {
+ ...node,
+ point,
+ connectors,
+ },
+ })
+ .sort((a, b) => {
+ if (a.point.y === b.point.y) {
+ return a.point.x - b.point.x;
+ }
+ return a.point.y - b.point.y;
+ })
+ .reduce((acc, cur) => {
+ return {
+ ...acc,
+ [cur.id]: {
+ ...cur,
+ },
+ };
+ }, {});
+
+ return {
+ ...state,
+ nodes: updatedNodes,
+ };
+ }
+ case 'UPDATE_NODES': {
+ return {
+ ...state,
+ nodes: {
+ ...state.nodes,
+ ...action.payload,
+ },
+ };
+ }
+ default:
+ return state;
+ }
+};
diff --git a/apps/client/src/contexts/SelectionContext/index.tsx b/apps/client/src/contexts/SelectionContext/index.tsx
new file mode 100644
index 00000000..2e76c072
--- /dev/null
+++ b/apps/client/src/contexts/SelectionContext/index.tsx
@@ -0,0 +1,48 @@
+import {
+ SelectionAction,
+ SelectionState,
+ selectionReducer,
+} from '@contexts/SelectionContext/reducer';
+import {
+ createContext,
+ Dispatch,
+ ReactNode,
+ useContext,
+ useReducer,
+} from 'react';
+
+type SelectionContextProps = {
+ state: SelectionState;
+ dispatch: Dispatch;
+};
+
+const SelectionContext = createContext(
+ undefined,
+);
+
+export const SelectionProvider = ({ children }: { children: ReactNode }) => {
+ const [state, dispatch] = useReducer(selectionReducer, {
+ selectedNodeId: undefined,
+ selectedEdge: undefined,
+ selectedGroupId: undefined,
+ });
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useSelectionContext = () => {
+ const context = useContext(SelectionContext);
+ if (!context) {
+ throw new Error('SelectionContext: context is undefined');
+ }
+ return context;
+};
diff --git a/apps/client/src/contexts/SelectionContext/reducer.ts b/apps/client/src/contexts/SelectionContext/reducer.ts
new file mode 100644
index 00000000..8bf0ac23
--- /dev/null
+++ b/apps/client/src/contexts/SelectionContext/reducer.ts
@@ -0,0 +1,69 @@
+export type SelectionState = {
+ selectedNodeId?: string;
+ selectedEdge?: {
+ id: string;
+ segmentIdxes: number[];
+ };
+ selectedGroupId?: string;
+};
+
+export type SelectionAction =
+ | { type: 'SELECT_NODE'; payload: { id: string } }
+ | { type: 'SELECT_EDGE'; payload: { id: string; segmentIdxes: number[] } }
+ | { type: 'SELECT_GROUP'; payload: { id: string } }
+ | { type: 'DESELECT_NODE' }
+ | { type: 'DESELECT_EDGE' }
+ | { type: 'DESELECT_GROUP'; payload: { id: string } }
+ | { type: 'CLEAR_SELECTION' };
+
+export const selectionReducer = (
+ state: SelectionState,
+ action: SelectionAction,
+): SelectionState => {
+ switch (action.type) {
+ case 'SELECT_NODE':
+ return {
+ ...state,
+ selectedNodeId: action.payload.id,
+ selectedEdge: undefined,
+ };
+ case 'SELECT_EDGE':
+ return {
+ ...state,
+ selectedEdge: {
+ id: action.payload.id,
+ segmentIdxes: [...action.payload.segmentIdxes],
+ },
+ selectedNodeId: undefined,
+ };
+ case 'SELECT_GROUP':
+ return {
+ ...state,
+ selectedGroupId: action.payload.id,
+ selectedEdge: undefined,
+ };
+ case 'DESELECT_NODE':
+ return {
+ ...state,
+ selectedNodeId: undefined,
+ };
+ case 'DESELECT_EDGE':
+ return {
+ ...state,
+ selectedEdge: undefined,
+ };
+ case 'DESELECT_GROUP':
+ return {
+ ...state,
+ selectedGroupId: undefined,
+ };
+ case 'CLEAR_SELECTION':
+ return {
+ selectedNodeId: undefined,
+ selectedEdge: undefined,
+ selectedGroupId: undefined,
+ };
+ default:
+ return state;
+ }
+};
diff --git a/apps/client/src/contexts/SvgContext.tsx b/apps/client/src/contexts/SvgContext.tsx
new file mode 100644
index 00000000..ba5d8534
--- /dev/null
+++ b/apps/client/src/contexts/SvgContext.tsx
@@ -0,0 +1,29 @@
+import {
+ createContext,
+ PropsWithChildren,
+ RefObject,
+ useContext,
+ useRef,
+} from 'react';
+
+type SvgContextProps = {
+ svgRef: RefObject;
+};
+
+const SvgContext = createContext(undefined);
+
+export const SvgProvider = ({ children }: PropsWithChildren) => {
+ const svgRef = useRef(null);
+
+ return (
+ {children}
+ );
+};
+
+export const useSvgContext = () => {
+ const context = useContext(SvgContext);
+ if (!context) {
+ throw new Error('SvgContext: context is undefined');
+ }
+ return context;
+};
diff --git a/apps/client/src/helpers/edge.ts b/apps/client/src/helpers/edge.ts
new file mode 100644
index 00000000..f5b624a2
--- /dev/null
+++ b/apps/client/src/helpers/edge.ts
@@ -0,0 +1,178 @@
+import { Connector, ConnectorMap, Dimension, Edge, Node, Point } from '@types';
+import { getDistance, getDistanceToSegment } from '@utils';
+
+export const getClosestSegEdgeIdx = (bendPoints: Point[], point: Point) => {
+ let closestDistance = Infinity;
+ let closestSegmentIndex = -1;
+
+ for (let i = 0; i < bendPoints.length - 1; i++) {
+ const p1 = bendPoints[i];
+ const p2 = bendPoints[i + 1];
+ const distance = getDistanceToSegment(point, p1, p2);
+ if (distance < closestDistance) {
+ closestDistance = distance;
+ closestSegmentIndex = i;
+ }
+ }
+
+ if (closestSegmentIndex !== -1) {
+ return closestSegmentIndex;
+ }
+
+ return bendPoints.length - 1;
+};
+
+export const findNearestConnectorPair = (
+ movingConnectors: Connector[],
+ connectedConnectors: Connector[],
+): {
+ movingConnector: Connector;
+ connectedConnector: Connector;
+ distance: number;
+} => {
+ let nearestPair: {
+ movingConnector: Connector;
+ connectedConnector: Connector;
+ distance: number;
+ } = {
+ movingConnector: movingConnectors[0],
+ connectedConnector: connectedConnectors[0],
+ distance: Infinity,
+ };
+
+ movingConnectors.forEach((mConnector) => {
+ connectedConnectors.forEach((cConnector) => {
+ const distance = getDistance(mConnector.point, cConnector.point);
+
+ if (distance < nearestPair.distance) {
+ nearestPair = {
+ movingConnector: mConnector,
+ connectedConnector: cConnector,
+ distance,
+ };
+ }
+ });
+ });
+
+ return nearestPair!;
+};
+
+const generateNodeConnectors = (connectors: ConnectorMap): Connector[] => {
+ return Object.entries(connectors).map(([connectorType, point]) => ({
+ type: 'node',
+ connectorType: connectorType,
+ point,
+ }));
+};
+
+const generateBendConnector = (bendPoint: Point): Connector => {
+ return {
+ type: 'bend',
+ point: bendPoint,
+ connectorType: 'center',
+ };
+};
+
+export const updateNearestConnectorPair = (
+ node: Node,
+ nodes: Record,
+ edges: Edge[],
+) => {
+ const movingConnectors = generateNodeConnectors(node.connectors);
+
+ const updatedConnectorPair = edges.reduce((acc, edge) => {
+ const sourceIsDragging = edge.source.id === node.id;
+ const connectedNodeId = sourceIsDragging
+ ? edge.target.id
+ : edge.source.id;
+
+ const connectedNode = nodes[connectedNodeId];
+
+ const connectedConnectors = generateNodeConnectors(
+ connectedNode.connectors,
+ );
+
+ let updatedEdge = {};
+ if (edge.bendingPoints.length > 0) {
+ if (sourceIsDragging) {
+ const firstBendPoint = edge.bendingPoints[0];
+ const bendConnector = generateBendConnector(firstBendPoint);
+ const { movingConnector } = findNearestConnectorPair(
+ movingConnectors,
+ [bendConnector],
+ );
+ updatedEdge = {
+ ...edge,
+ source: {
+ ...edge.source,
+ connectorType: movingConnector.connectorType,
+ },
+ };
+ } else {
+ const lastBendPoint =
+ edge.bendingPoints[edge.bendingPoints.length - 1];
+ const bendConnector = generateBendConnector(lastBendPoint);
+ const { movingConnector } = findNearestConnectorPair(
+ movingConnectors,
+ [bendConnector],
+ );
+ updatedEdge = {
+ ...edge,
+ target: {
+ ...edge.target,
+ connectorType: movingConnector.connectorType,
+ },
+ };
+ }
+ } else {
+ const { movingConnector, connectedConnector } =
+ findNearestConnectorPair(movingConnectors, connectedConnectors);
+ if (sourceIsDragging) {
+ updatedEdge = {
+ ...edge,
+ source: {
+ ...edge.source,
+ connectorType: movingConnector.connectorType,
+ },
+ target: {
+ ...edge.target,
+ connectorType: connectedConnector.connectorType,
+ },
+ };
+ } else {
+ updatedEdge = {
+ ...edge,
+ target: {
+ ...edge.target,
+ connectorType: movingConnector.connectorType,
+ },
+ source: {
+ ...edge.source,
+ connectorType: connectedConnector.connectorType,
+ },
+ };
+ }
+ }
+
+ return {
+ ...acc,
+ [edge.id]: updatedEdge,
+ };
+ }, {});
+
+ return updatedConnectorPair;
+};
+
+export const findNearestConnectorForBendPoint = (node: Node, point: Point) => {
+ const nodeConnectors = generateNodeConnectors(node.connectors);
+ const bendConnector = generateBendConnector(point);
+
+ const connectorPair = findNearestConnectorPair(
+ [bendConnector],
+ nodeConnectors,
+ );
+ if (!connectorPair) {
+ return null;
+ }
+ return connectorPair.connectedConnector.connectorType;
+};
diff --git a/apps/client/src/helpers/group.ts b/apps/client/src/helpers/group.ts
new file mode 100644
index 00000000..675a3382
--- /dev/null
+++ b/apps/client/src/helpers/group.ts
@@ -0,0 +1,43 @@
+import { GRID_2D_SIZE } from '@constants';
+import { Bounds, Dimension } from '@types';
+import { convert2dTo3dPoint, convert3dTo2dPoint } from '@utils';
+
+export const computeBounds = (_bounds: Bounds[], dimension: Dimension) => {
+ const padding = GRID_2D_SIZE * 2;
+ let bounds = _bounds;
+ if (dimension === '3d') {
+ bounds = bounds.map((bound) => ({
+ ...bound,
+ ...convert3dTo2dPoint({
+ x: bound.x,
+ y: bound.y,
+ }),
+ }));
+ }
+
+ const minX = Math.min(...bounds.map((bounds) => bounds.x));
+ const minY = Math.min(...bounds.map((bounds) => bounds.y));
+ const maxX = Math.max(...bounds.map((bounds) => bounds.x + bounds.width));
+ const maxY = Math.max(...bounds.map((bounds) => bounds.y + bounds.height));
+
+ let x = minX - padding;
+ let y = minY - padding;
+ let width = maxX - minX + padding * 2;
+ let height = maxY - minY + padding * 2;
+
+ if (dimension === '3d') {
+ const minPoint = convert2dTo3dPoint({
+ x: minX - padding,
+ y: minY - padding,
+ });
+ x = minPoint.x;
+ y = minPoint.y;
+ }
+
+ return {
+ x,
+ y,
+ width,
+ height,
+ };
+};
diff --git a/apps/client/src/helpers/node.ts b/apps/client/src/helpers/node.ts
new file mode 100644
index 00000000..a99ab6d9
--- /dev/null
+++ b/apps/client/src/helpers/node.ts
@@ -0,0 +1,76 @@
+import { NODE_BASE_SIZE } from '@constants';
+import { Dimension, Node, Point, Size } from '@types';
+import {
+ alignPoint2d,
+ alignPoint3d,
+ convert2dTo3dPoint,
+ convert3dTo2dPoint,
+} from '@utils';
+
+const getNodeOffsetForDimension = (nodeSize: Size, baseSize: Size) => {
+ return {
+ x: (baseSize.width - nodeSize.width) / 2,
+ y: baseSize.height - nodeSize.height - (nodeSize.offset || 0),
+ };
+};
+
+//INFO: 처음이 2d로 시작하기 때문에 nodeSize : 3d , baseSize : 3d로 해야함. 다른 방법은 잘 모르곘음.
+//2d에서 3d로 변환할 때는 3d에서 2d로 변환할 때와 달리 baseSize와 nodeSize가 2d 사이즈 들어가야 할 것 같음
+export const adjustNodePointForDimension = (
+ node: Node,
+ dimension: Dimension,
+) => {
+ const { point, size } = node;
+
+ const offset = getNodeOffsetForDimension(size['3d'], NODE_BASE_SIZE['3d']);
+ let result;
+ if (dimension === '2d') {
+ result = convert3dTo2dPoint({
+ x: point.x - offset.x,
+ y: point.y - offset.y,
+ });
+ } else {
+ result = convert2dTo3dPoint(point);
+ result = {
+ x: result.x + offset.x,
+ y: result.y + offset.y,
+ };
+ }
+
+ return result;
+};
+
+export const alignNodePoint = (
+ node: Node,
+ newPoint: Point,
+ dimension: Dimension,
+) => {
+ let result = newPoint;
+ if (dimension === '2d') {
+ result = alignPoint2d(result);
+ } else {
+ const adjustPoint = {
+ x: result.x + node.size[dimension].width / 2,
+ y: result.y + node.size[dimension].height,
+ };
+ result = alignPoint3d(adjustPoint);
+ result = {
+ x: result.x - node.size[dimension].width / 2,
+ y:
+ result.y -
+ node.size[dimension].height -
+ (node.size[dimension].offset || 0),
+ };
+ }
+
+ return result;
+};
+
+export const getNodeBounds = (node: Node, dimension: Dimension) => {
+ return {
+ x: node.point.x,
+ y: node.point.y,
+ width: node.size[dimension].width,
+ height: node.size[dimension].height,
+ };
+};
diff --git a/apps/client/src/hooks/useConnection.ts b/apps/client/src/hooks/useConnection.ts
new file mode 100644
index 00000000..d8afb030
--- /dev/null
+++ b/apps/client/src/hooks/useConnection.ts
@@ -0,0 +1,110 @@
+import { useNodeContext } from '@contexts/NodeContext';
+import { useSvgContext } from '@contexts/SvgContext';
+import { Connection, Node, Point } from '@types';
+import { getDistance, getSvgPoint } from '@utils';
+import { useRef, useState } from 'react';
+
+type Props = {
+ updateEdgeFn: (source: Connection, target: Connection) => void;
+};
+
+export default ({ updateEdgeFn }: Props) => {
+ const { svgRef } = useSvgContext();
+ const {
+ state: { nodes },
+ } = useNodeContext();
+
+ const [isConnecting, setIsConnecting] = useState(false);
+ const [connection, setConnection] = useState<{
+ source: Point;
+ target: Point;
+ } | null>(null);
+
+ const sourceRef = useRef(null);
+ const targetRef = useRef(null);
+
+ const getNearestConnector = (nodes: Node[], point: Point) => {
+ let minDistance = Infinity;
+ let newPoint = point;
+ let target = {
+ id: '',
+ connectorType: '',
+ };
+
+ nodes.forEach((node) => {
+ Object.entries(node.connectors).forEach(
+ ([connectorType, connectorPoint]) => {
+ const distance = getDistance(point, connectorPoint);
+ const threshold = 30;
+ if (distance < threshold && distance < minDistance) {
+ target = {
+ id: node.id,
+ connectorType,
+ };
+ newPoint = connectorPoint;
+ minDistance = distance;
+ }
+ },
+ );
+ });
+
+ return { target, point: newPoint };
+ };
+
+ const openConnection = (from: Connection) => {
+ if (!svgRef.current) return;
+
+ setConnection({
+ source: from.point,
+ target: from.point,
+ });
+
+ sourceRef.current = from;
+ setIsConnecting(true);
+ };
+
+ const connectConnection = (point: Point) => {
+ if (!isConnecting || !svgRef.current) return;
+ const svgPoint = getSvgPoint(svgRef.current, point);
+
+ const { target, point: newPoint } = getNearestConnector(
+ Object.values(nodes),
+ svgPoint,
+ );
+
+ setConnection((prev) => {
+ if (!prev) return prev;
+ return {
+ ...prev,
+ target: newPoint,
+ };
+ });
+
+ if (target.id) {
+ targetRef.current = {
+ ...target,
+ point: newPoint,
+ };
+ }
+ };
+
+ const closeConnection = () => {
+ setIsConnecting(false);
+ setConnection(null);
+ if (
+ sourceRef.current &&
+ targetRef.current &&
+ targetRef.current.id !== sourceRef.current.id
+ ) {
+ updateEdgeFn(sourceRef.current, targetRef.current);
+ }
+ };
+
+ return {
+ connection,
+ isConnecting,
+ openConnection,
+ connectConnection,
+ closeConnection,
+ };
+};
diff --git a/apps/client/src/hooks/useDrag.ts b/apps/client/src/hooks/useDrag.ts
new file mode 100644
index 00000000..605020e6
--- /dev/null
+++ b/apps/client/src/hooks/useDrag.ts
@@ -0,0 +1,50 @@
+import { useSvgContext } from '@contexts/SvgContext';
+import { Point } from '@types';
+import { getSvgPoint } from '@utils';
+import { useState } from 'react';
+
+type Props = {
+ initialPoint: Point;
+ updateFn: (point: Point) => void;
+};
+
+export default ({ initialPoint, updateFn }: Props) => {
+ const { svgRef } = useSvgContext();
+ const [isDragging, setIsDragging] = useState(false);
+ const [startDragPoint, setStartDragPoint] = useState(null);
+
+ const startDrag = (point: Point) => {
+ if (!svgRef.current) return;
+
+ setIsDragging(true);
+ const startSvgPoint = getSvgPoint(svgRef.current, point);
+ setStartDragPoint(startSvgPoint);
+ };
+
+ const drag = (point: Point) => {
+ if (!(isDragging && startDragPoint && svgRef.current)) return;
+
+ const curSvgPoint = getSvgPoint(svgRef.current, point);
+ const offset = {
+ x: curSvgPoint.x - startDragPoint.x,
+ y: curSvgPoint.y - startDragPoint.y,
+ };
+
+ updateFn({
+ x: initialPoint.x + offset.x,
+ y: initialPoint.y + offset.y,
+ });
+ };
+
+ const stopDrag = () => {
+ setIsDragging(false);
+ setStartDragPoint(null);
+ };
+
+ return {
+ isDragging,
+ startDrag,
+ drag,
+ stopDrag,
+ };
+};
diff --git a/apps/client/src/hooks/useGraphActions.ts b/apps/client/src/hooks/useGraphActions.ts
new file mode 100644
index 00000000..68319098
--- /dev/null
+++ b/apps/client/src/hooks/useGraphActions.ts
@@ -0,0 +1,352 @@
+import { NcloudNodeFactory } from '@/src/models/ncloud';
+import { GROUP_TYPES } from '@constants';
+import { useDimensionContext } from '@contexts/DimensionContext';
+import { useEdgeContext } from '@contexts/EdgeContext';
+import { useGroupContext } from '@contexts/GroupContext';
+import { useNodeContext } from '@contexts/NodeContext';
+import { useSvgContext } from '@contexts/SvgContext';
+import {
+ findNearestConnectorForBendPoint,
+ getClosestSegEdgeIdx,
+ updateNearestConnectorPair,
+} from '@helpers/edge';
+import { computeBounds } from '@helpers/group';
+import {
+ adjustNodePointForDimension,
+ alignNodePoint,
+ getNodeBounds,
+} from '@helpers/node';
+import { Connection, Edge, Point } from '@types';
+import {
+ alignPoint2d,
+ alignPoint3d,
+ convert2dTo3dPoint,
+ convert3dTo2dPoint,
+ getConnectorPoints,
+ getSvgPoint,
+} from '@utils';
+import { nanoid } from 'nanoid';
+import { useEffect } from 'react';
+
+export default () => {
+ const {
+ state: { nodes },
+ dispatch: nodeDispatch,
+ } = useNodeContext();
+ const {
+ state: { edges },
+ dispatch: edgeDispatch,
+ } = useEdgeContext();
+ const {
+ state: { groups },
+ dispatch: groupDispatch,
+ } = useGroupContext();
+
+ const { dimension, prevDimension } = useDimensionContext();
+ const { svgRef } = useSvgContext();
+
+ //INFO: Node
+
+ const addNode = (type: string) => {
+ const node = NcloudNodeFactory(type);
+ //TODO: Focus 된 그룹에 추가하도록 수정해야함
+ nodeDispatch({
+ type: 'ADD_NODE',
+ payload: {
+ ...node,
+ id: `node-${nanoid()}`,
+ point: { x: 0, y: 0 },
+ connectors: getConnectorPoints(node, dimension),
+ },
+ });
+ };
+
+ const moveNode = (id: string, point: Point) => {
+ if (!svgRef.current) return;
+ const node = nodes[id];
+ const newPoint = alignNodePoint(node, point, dimension);
+ const connectors = getConnectorPoints(
+ { ...node, point: newPoint },
+ dimension,
+ );
+
+ nodeDispatch({
+ type: 'MOVE_NODE',
+ payload: { id, point: newPoint, connectors },
+ });
+
+ const connectedEdges = Object.values(edges).filter(
+ (edge) => edge.source.id === id || edge.target.id === id,
+ );
+
+ const updatedEdges = updateNearestConnectorPair(
+ { ...node, point: newPoint, connectors },
+ nodes,
+ connectedEdges,
+ );
+
+ updateEdges(updatedEdges);
+ };
+
+ const removeNode = (id: string) => {
+ const node = nodes[id];
+ nodeDispatch({
+ type: 'REMOVE_NODE',
+ payload: { id },
+ });
+
+ const groupIds = GROUP_TYPES.map(
+ (type) => node.properties[type],
+ ).filter(Boolean);
+
+ if (groupIds.length > 0) {
+ groupIds.forEach((groupId) => {
+ removeNodeFromGroup(groupId, id);
+ });
+ }
+
+ const connectedEdgeIds = Object.values(edges)
+ .filter((edge) => edge.source.id === id || edge.target.id === id)
+ .map((edge) => edge.id);
+
+ edgeDispatch({
+ type: 'REMOVE_EDGES',
+ payload: connectedEdgeIds,
+ });
+ };
+
+ const updateNodePointForDimension = () => {
+ const updatedNodes = Object.entries(nodes).reduce((acc, [id, node]) => {
+ const adjustedPoint = adjustNodePointForDimension(node, dimension);
+ const connectors = getConnectorPoints(
+ { ...node, point: adjustedPoint },
+ dimension,
+ );
+ return {
+ ...acc,
+ [id]: {
+ ...node,
+ point: adjustedPoint,
+ connectors,
+ },
+ };
+ }, {});
+
+ nodeDispatch({
+ type: 'UPDATE_NODES',
+ payload: updatedNodes,
+ });
+ };
+
+ //INFO: Edge
+
+ const addEdge = (
+ source: Required,
+ target: Required,
+ ) => {
+ edgeDispatch({
+ type: 'ADD_EDGE',
+ payload: {
+ id: `edge-${nanoid()}`,
+ type: 'arrow',
+ source: {
+ id: source.id,
+ connectorType: source.connectorType,
+ },
+ target: {
+ id: target.id,
+ connectorType: target.connectorType,
+ },
+ },
+ });
+ };
+
+ const removeEdge = (id: string, segmentIdxes: number[]) =>
+ edgeDispatch({
+ type: 'REMOVE_EDGE',
+ payload: {
+ id,
+ segmentIdxes,
+ },
+ });
+
+ const updateEdges = (edges: Record) => {
+ edgeDispatch({
+ type: 'UPDATE_EDGES',
+ payload: edges,
+ });
+ };
+
+ const splitEdge = (id: string, point: Point, bendingPoints: Point[]) => {
+ if (!svgRef.current) return;
+
+ const svgPoint = getSvgPoint(svgRef.current, point);
+ const closestSegmentIdx = getClosestSegEdgeIdx(bendingPoints, svgPoint);
+
+ edgeDispatch({
+ type: 'SPLIT_EDGE',
+ payload: {
+ id,
+ point: svgPoint,
+ insertAfter: closestSegmentIdx,
+ },
+ });
+ };
+
+ const moveBendingPointer = (
+ edgeId: string,
+ index: number,
+ point: Point,
+ ) => {
+ if (!svgRef.current) return;
+
+ const newPoint =
+ dimension === '2d' ? alignPoint2d(point) : alignPoint3d(point);
+
+ const edge = edges[edgeId];
+ const { source, target } = edge;
+
+ let connector:
+ | {
+ [key: string]: {
+ id: string;
+ connectorType: string;
+ };
+ }
+ | undefined;
+
+ if (index === 0) {
+ const sourceNode = nodes[source.id];
+ const connectorType = findNearestConnectorForBendPoint(
+ sourceNode,
+ point,
+ ) as string;
+ connector = {
+ ['source']: {
+ id: source.id,
+ connectorType,
+ },
+ };
+ }
+ if (index === edge.bendingPoints.length - 1) {
+ const targetNode = nodes[target.id];
+ const connectorType = findNearestConnectorForBendPoint(
+ targetNode,
+ point,
+ ) as string;
+ connector = {
+ ...connector,
+ ['target']: {
+ id: target.id,
+ connectorType,
+ },
+ };
+ }
+
+ edgeDispatch({
+ type: 'MOVE_BENDING_POINTER',
+ payload: {
+ id: edgeId,
+ bendingPointer: { index, point: newPoint },
+ connector,
+ },
+ });
+ };
+
+ const updateEdgePointForDimension = () => {
+ const updatedEdges = Object.entries(edges).reduce((acc, [id, edge]) => {
+ const adjustedBendingPoints = edge.bendingPoints.map((point) =>
+ dimension === '2d'
+ ? convert3dTo2dPoint(point)
+ : convert2dTo3dPoint(point),
+ );
+
+ return {
+ ...acc,
+ [id]: {
+ ...edge,
+ bendingPoints: adjustedBendingPoints,
+ },
+ };
+ }, {});
+
+ edgeDispatch({
+ type: 'UPDATE_EDGES',
+ payload: updatedEdges,
+ });
+ };
+
+ //INFO: Group
+
+ const addGroup = (type: string, groupId: string, nodeId: string) => {};
+
+ const updateGroup = (type: string, groupId: string, nodeId: string) => {};
+
+ const removeNodeFromGroup = (groupId: string, nodeId: string) => {
+ groupDispatch({
+ type: 'REMOVE_NODE_FROM_GROUP',
+ payload: { groupId, nodeId },
+ });
+ };
+
+ //INFO: Node만 움직여도 자동으로 그룹이 움직여짐, 따라서 Offset을 받아서 처리함
+ const moveGroup = (groupId: string, offset: Point) => {
+ const group = groups[groupId];
+ if (!group) return;
+
+ group.nodeIds.forEach((nodeId) => {
+ const node = nodes[nodeId];
+ const newPoint = {
+ x: node.point.x + offset.x,
+ y: node.point.y + offset.y,
+ };
+ moveNode(nodeId, newPoint);
+ });
+ };
+
+ const getGroupBounds = (groupId: string) => {
+ const group = groups[groupId];
+
+ const childGroupNodeIds = group.childGroupIds.map((groupId) => {
+ const childGroup = groups[groupId];
+ return childGroup.nodeIds;
+ });
+
+ const childGroupsBounds = childGroupNodeIds.map((nodeIds) => {
+ const childGroupNodeBounds = nodeIds.map((nodeId) =>
+ getNodeBounds(nodes[nodeId], dimension),
+ );
+ return computeBounds(childGroupNodeBounds, dimension);
+ });
+
+ const currentGroupNodeBounds = group.nodeIds.map((nodeId) => {
+ return getNodeBounds(nodes[nodeId], dimension);
+ });
+
+ return computeBounds(
+ [...childGroupsBounds, ...currentGroupNodeBounds],
+ dimension,
+ );
+ };
+
+ useEffect(() => {
+ if (dimension === prevDimension) return;
+
+ updateNodePointForDimension();
+ updateEdgePointForDimension();
+ }, [dimension]);
+
+ return {
+ addNode,
+ moveNode,
+ removeNode,
+ addEdge,
+ removeEdge,
+ splitEdge,
+ moveBendingPointer,
+ updateGroup,
+ removeNodeFromGroup,
+ getGroupBounds,
+ moveGroup,
+ };
+};
diff --git a/apps/client/src/cloud-graph/hooks/useKey.ts b/apps/client/src/hooks/useKey.ts
similarity index 100%
rename from apps/client/src/cloud-graph/hooks/useKey.ts
rename to apps/client/src/hooks/useKey.ts
diff --git a/apps/client/src/hooks/useSelection.ts b/apps/client/src/hooks/useSelection.ts
new file mode 100644
index 00000000..350f9fe4
--- /dev/null
+++ b/apps/client/src/hooks/useSelection.ts
@@ -0,0 +1,53 @@
+import { useSelectionContext } from '@contexts/SelectionContext/index';
+import { useSvgContext } from '@contexts/SvgContext';
+import { getClosestSegEdgeIdx } from '@helpers/edge';
+import { Point } from '@types';
+import { getSvgPoint } from '@utils';
+
+export default () => {
+ const { svgRef } = useSvgContext();
+ const { state, dispatch } = useSelectionContext();
+
+ const selectNode = (id: string) =>
+ dispatch({ type: 'SELECT_NODE', payload: { id } });
+
+ const selectEntireEdge = (id: string, segmentIdxes: number[]) =>
+ dispatch({
+ type: 'SELECT_EDGE',
+ payload: { id, segmentIdxes },
+ });
+
+ const selectSegEdge = (id: string, bendingPoint: Point[], point: Point) => {
+ const svgPoint = getSvgPoint(svgRef.current!, point);
+ const closestSegmentIdx = getClosestSegEdgeIdx(bendingPoint, svgPoint);
+
+ dispatch({
+ type: 'SELECT_EDGE',
+ payload: { id, segmentIdxes: [closestSegmentIdx] },
+ });
+ };
+
+ const selectGroup = (id: string) =>
+ dispatch({ type: 'SELECT_GROUP', payload: { id } });
+
+ const deselectNode = () => dispatch({ type: 'DESELECT_NODE' });
+
+ const deselectEdge = () => dispatch({ type: 'DESELECT_EDGE' });
+
+ const deselectGroup = (id: string) =>
+ dispatch({ type: 'DESELECT_GROUP', payload: { id } });
+
+ const clearSelection = () => dispatch({ type: 'CLEAR_SELECTION' });
+
+ return {
+ ...state,
+ selectNode,
+ selectSegEdge,
+ selectEntireEdge,
+ selectGroup,
+ deselectNode,
+ deselectEdge,
+ deselectGroup,
+ clearSelection,
+ };
+};
diff --git a/apps/client/src/hooks/useVisible.ts b/apps/client/src/hooks/useVisible.ts
new file mode 100644
index 00000000..f6b753f9
--- /dev/null
+++ b/apps/client/src/hooks/useVisible.ts
@@ -0,0 +1,74 @@
+// TODO: 리팩토링 필요
+// import {
+// GRID_2D_SIZE,
+// GRID_3D_DEPTH_SIZE,
+// GRID_3D_HEIGHT_SIZE,
+// GRID_3D_WIDTH_SIZE,
+// } from '@cloud-graph/constants';
+// import { Dimension, Edge, Node, ViewBox } from '@cloud-graph/types';
+// import { useMemo } from 'react';
+//
+// type Props = {
+// nodes: Node[];
+// edges: Edge[];
+// viewBox: ViewBox;
+// dimension: Dimension;
+// };
+//
+// export default ({ nodes, edges, viewBox, dimension }: Props) => {
+// const calculateOffset = () => {
+// return dimension === '2d'
+// ? { width: GRID_2D_SIZE, height: GRID_2D_SIZE }
+// : {
+// width: GRID_3D_WIDTH_SIZE,
+// height: GRID_3D_HEIGHT_SIZE + GRID_3D_DEPTH_SIZE,
+// };
+// };
+//
+// const isNodeVisible = (node: Node) => {
+// const { x, y } = node.point;
+// const offset = calculateOffset();
+//
+// return (
+// x >= viewBox.x - offset.width &&
+// x <= viewBox.x + viewBox.width + offset.width &&
+// y >= viewBox.y - offset.height &&
+// y <= viewBox.y + viewBox.height + offset.height
+// );
+// };
+//
+// const visibleNodes: Node[] = useMemo(
+// () => nodes.filter(isNodeVisible),
+// [nodes, isNodeVisible],
+// );
+//
+// const mapEdgeToVisibleNodes = (edge: Edge) => {
+// const sourceNode = visibleNodes.find(
+// (node) => node.id === edge.source.node.id,
+// );
+// const targetNode = nodes.find(
+// (node) => node.id === edge.target.node.id,
+// );
+//
+// return sourceNode && targetNode
+// ? {
+// ...edge,
+// source: {
+// node: sourceNode,
+// anchorType: edge.source.anchorType,
+// },
+// target: {
+// node: targetNode,
+// anchorType: edge.target.anchorType,
+// },
+// }
+// : null;
+// };
+//
+// const visibleEdges = useMemo(
+// () => edges.map(mapEdgeToVisibleNodes).filter((node) => node !== null),
+// [edges, mapEdgeToVisibleNodes],
+// );
+//
+// return { visibleNodes, visibleEdges };
+// };
diff --git a/apps/client/src/main.tsx b/apps/client/src/main.tsx
index e9cd1ac3..fa5241c8 100644
--- a/apps/client/src/main.tsx
+++ b/apps/client/src/main.tsx
@@ -1,17 +1,35 @@
+import { DimensionProvider } from '@contexts/DimensionContext';
+import { EdgeProvider } from '@contexts/EdgeContext/index.tsx';
+import { GraphProvider } from '@contexts/GraphConetxt';
+import { GroupProvider } from '@contexts/GroupContext/index.tsx';
+import { NodeProvider } from '@contexts/NodeContext/index.tsx';
+import { SelectionProvider } from '@contexts/SelectionContext/index.tsx';
+import { SvgProvider } from '@contexts/SvgContext.tsx';
+import { CssBaseline, ThemeProvider } from '@mui/material';
+import theme from '@theme';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App.tsx';
-import { ThemeProvider, CssBaseline } from '@mui/material';
-import theme from '@theme';
-import { CloudGraphProvider } from '@cloud-graph/index.tsx';
createRoot(document.getElementById('root')!).render(
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
,
);
diff --git a/apps/client/src/mocks.ts b/apps/client/src/mocks.ts
new file mode 100644
index 00000000..9c56dcd4
--- /dev/null
+++ b/apps/client/src/mocks.ts
@@ -0,0 +1,155 @@
+import { Group, Node } from '@types';
+import { nanoid } from 'nanoid';
+
+const CloudFunctionNode: Node = {
+ id: `node-${nanoid()}`,
+ type: 'cloud-function',
+ name: 'CloudFunction1',
+ point: { x: 270, y: 270 },
+ size: {
+ '2d': { width: 90, height: 90 },
+ '3d': { width: 96, height: 113.438, offset: 10 },
+ },
+ properties: {
+ vpc: '',
+ subnet: '',
+ region: '',
+ },
+ connectors: {},
+};
+const ObjectStorageNode: Node = {
+ id: `node-${nanoid()}`,
+ type: 'object-storage',
+ name: 'ObjectStorage1',
+ point: { x: 100, y: 0 },
+ size: {
+ '2d': { width: 90, height: 90 },
+ '3d': { width: 100.626, height: 115.695, offset: 20 },
+ },
+ properties: {
+ vpc: '',
+ subnet: '',
+ region: '',
+ },
+ connectors: {},
+};
+const MySQLDBNode: Node = {
+ id: `node-${nanoid()}`,
+ type: 'db-mysql',
+ name: 'MySQLDB1',
+ point: { x: 0, y: 0 },
+ size: {
+ '2d': { width: 90, height: 90 },
+ '3d': { width: 128, height: 137.5 },
+ },
+ properties: {
+ vpc: '',
+ subnet: '',
+ region: '',
+ },
+ connectors: {},
+};
+const ServerNode: Node = {
+ id: `node-${nanoid()}`,
+ type: 'server',
+ name: 'WebServer1',
+ point: { x: 90, y: 90 },
+ size: {
+ '2d': { width: 90, height: 90 },
+ '3d': { width: 128, height: 111 },
+ },
+ properties: {
+ vpc: '',
+ subnet: '',
+ region: '',
+ },
+ connectors: {},
+};
+
+const ServerNode2: Node = {
+ id: `node-${nanoid()}`,
+ type: 'server',
+ name: 'WebServer2',
+ point: { x: 90, y: 90 },
+ size: {
+ '2d': { width: 90, height: 90 },
+ '3d': { width: 128, height: 111 },
+ },
+ properties: {
+ vpc: '',
+ subnet: '',
+ region: '',
+ },
+ connectors: {},
+};
+
+const SubnetGroup: Group = {
+ id: 'subnet1',
+ type: 'subnet',
+ name: 'Subnet-1',
+ nodeIds: [MySQLDBNode.id, ObjectStorageNode.id],
+ properties: {
+ cidr: '',
+ },
+ childGroupIds: [],
+};
+
+const VpcGroup: Group = {
+ id: 'vpc1',
+ type: 'vpc',
+ name: 'VPC-1',
+ nodeIds: [CloudFunctionNode.id, ServerNode.id],
+ properties: {
+ cidr: '',
+ },
+ childGroupIds: [],
+};
+
+const RegionGroup: Group = {
+ id: 'seoul',
+ type: 'region',
+ name: 'KR-1',
+ nodeIds: [
+ ServerNode2.id,
+ MySQLDBNode.id,
+ CloudFunctionNode.id,
+ ServerNode.id,
+ ObjectStorageNode.id,
+ ],
+ properties: {
+ regionCode: 'KR-1',
+ },
+ childGroupIds: [VpcGroup.id, SubnetGroup.id],
+};
+
+const mockNodes = [
+ ServerNode2,
+ CloudFunctionNode,
+ ObjectStorageNode,
+ MySQLDBNode,
+ MySQLDBNode,
+ ServerNode,
+];
+
+const mockGroups = [RegionGroup, VpcGroup, SubnetGroup];
+
+mockGroups.forEach((group) => {
+ // set properties for each group
+ group.nodeIds.forEach((nodeId) => {
+ const node = mockNodes.find((n) => n.id === nodeId);
+ if (node) {
+ node.properties[group.type] = group.id;
+ }
+ });
+});
+
+console.log(mockNodes);
+
+export const mockInitialState = {
+ nodes: mockNodes.reduce((acc, node) => ({ ...acc, [node.id]: node }), {}),
+ groups: mockGroups.reduce(
+ (acc, group) => ({ ...acc, [group.id]: group }),
+ {},
+ ),
+ edges: {},
+};
diff --git a/apps/client/src/models/ncloud/index.ts b/apps/client/src/models/ncloud/index.ts
new file mode 100644
index 00000000..a5a3815b
--- /dev/null
+++ b/apps/client/src/models/ncloud/index.ts
@@ -0,0 +1,87 @@
+import { Group, Node } from '@types';
+
+export const NcloudNodeFactory = (type: string) => {
+ switch (type) {
+ case 'server':
+ return Server;
+ case 'cloud-function':
+ return CloudFunction;
+ case 'db-mysql':
+ return MySQLDB;
+ default: {
+ throw new Error(`Unknown type: ${type}`);
+ }
+ }
+};
+
+export const NcloudGroupFactory = (type: string) => {
+ switch (type) {
+ case 'region':
+ return Region;
+ default: {
+ throw new Error(`Unknown type: ${type}`);
+ }
+ }
+};
+
+const Server: Node = {
+ id: '',
+ name: '',
+ type: 'server',
+ point: { x: 0, y: 0 },
+ size: {
+ '2d': { width: 90, height: 90 },
+ '3d': { width: 128, height: 111 },
+ },
+ properties: {
+ region: '',
+ subnet: '',
+ vpc: '',
+ },
+ connectors: {},
+};
+
+const CloudFunction: Node = {
+ id: '',
+ type: 'cloud-function',
+ name: '',
+ point: { x: 0, y: 0 },
+ size: {
+ '2d': { width: 90, height: 90 },
+ '3d': { width: 96, height: 113.438, offset: 10 },
+ },
+ properties: {
+ region: '',
+ subnet: '',
+ vpc: '',
+ },
+ connectors: {},
+};
+
+const MySQLDB: Node = {
+ id: '',
+ type: 'db-mysql',
+ name: '',
+ point: { x: 0, y: 0 },
+ size: {
+ '2d': { width: 90, height: 90 },
+ '3d': { width: 128, height: 137.5 },
+ },
+ properties: {
+ vpc: '',
+ subnet: '',
+ region: '',
+ },
+ connectors: {},
+};
+
+const Region: Group = {
+ id: 'region1',
+ type: 'region',
+ name: 'KR-1',
+ nodeIds: [],
+ properties: {
+ regionCode: '',
+ },
+ childGroupIds: [],
+};
diff --git a/apps/client/src/types/index.ts b/apps/client/src/types/index.ts
new file mode 100644
index 00000000..a99e14a8
--- /dev/null
+++ b/apps/client/src/types/index.ts
@@ -0,0 +1,61 @@
+export type Dimension = '2d' | '3d';
+
+export type Point = { x: number; y: number };
+
+export type GridPoint = { col: number; row: number };
+
+export type Size = { width: number; height: number; offset?: number };
+
+export type ViewBox = Point & Size;
+
+export type Bounds = Point & Size;
+
+export type Node = {
+ id: string;
+ type: string;
+ name: string;
+ point: Point;
+ size: {
+ '2d': Size;
+ '3d': Size;
+ };
+ properties: { [key: string]: any };
+ connectors: { [key: string]: Point };
+};
+
+export type Edge = {
+ id: string;
+ type: 'arrow' | 'line';
+ source: {
+ id: string;
+ connectorType: string;
+ };
+ target: {
+ id: string;
+ connectorType: string;
+ };
+ bendingPoints: Point[];
+};
+
+export type Group = {
+ id: string;
+ type: string;
+ name: string;
+ nodeIds: string[];
+ properties: { [key: string]: any };
+ childGroupIds: string[];
+};
+
+export type Connection = {
+ id: string;
+ point: Point;
+ connectorType: string;
+};
+
+export type ConnectorType = 'top' | 'right' | 'bottom' | 'left' | 'center';
+export type ConnectorMap = Record;
+export interface Connector {
+ type: 'node' | 'bend' | string;
+ point: Point;
+ connectorType?: string;
+}
diff --git a/apps/client/src/utils/index.ts b/apps/client/src/utils/index.ts
index 8ad1dfb5..d894f785 100644
--- a/apps/client/src/utils/index.ts
+++ b/apps/client/src/utils/index.ts
@@ -1 +1,153 @@
-//TODO
+import {
+ GRID_2D_SIZE,
+ GRID_3D_HEIGHT_SIZE,
+ GRID_3D_WIDTH_SIZE,
+} from '@constants';
+import { ConnectorMap, Dimension, GridPoint, Node, Point } from '@types';
+
+export const getDistance = (point1: Point, point2: Point) => {
+ return Math.sqrt((point1.x - point2.x) ** 2 + (point1.y - point2.y) ** 2);
+};
+
+export const getSvgPoint = (svg: SVGSVGElement, point: Point) => {
+ const svgPoint = svg.createSVGPoint();
+ svgPoint.x = point.x;
+ svgPoint.y = point.y;
+ const screenCTM = svg.getScreenCTM();
+ return svgPoint.matrixTransform(screenCTM!.inverse());
+};
+
+export const gridToScreen3d = (gridPoint: GridPoint): Point => {
+ const { col, row } = gridPoint;
+
+ const x = (col - row) * (GRID_3D_WIDTH_SIZE / 2);
+ const y = (col + row) * (GRID_3D_HEIGHT_SIZE / 2);
+
+ return { x, y };
+};
+
+export const screenToGrid3d = (point: Point) => {
+ const { x, y } = point;
+
+ const col =
+ (x / (GRID_3D_WIDTH_SIZE / 2) + y / (GRID_3D_HEIGHT_SIZE / 2)) / 2;
+ const row =
+ (y / (GRID_3D_HEIGHT_SIZE / 2) - x / (GRID_3D_WIDTH_SIZE / 2)) / 2;
+
+ return { col, row };
+};
+
+export const screenToGrid2d = (point: Point) => {
+ const { x, y } = point;
+ const col = x / GRID_2D_SIZE;
+ const row = y / GRID_2D_SIZE;
+
+ return { col, row };
+};
+
+export const gridToScreen2d = (gridPoint: GridPoint): Point => {
+ const { col, row } = gridPoint;
+ const x = col * GRID_2D_SIZE;
+ const y = row * GRID_2D_SIZE;
+ return { x, y };
+};
+
+export const alignPoint2d = (point: Point) => {
+ const snappedSize = GRID_2D_SIZE / 4;
+ const gridAlignedX = Math.round(point.x / snappedSize) * snappedSize;
+ const gridAlignedY = Math.round(point.y / snappedSize) * snappedSize;
+
+ return {
+ x: gridAlignedX,
+ y: gridAlignedY,
+ };
+};
+
+export const alignPoint3d = (point: Point) => {
+ const { col, row } = screenToGrid3d(point);
+
+ const snappedSize = 1 / 4;
+ const snappedCol = Math.round(col / snappedSize) * snappedSize;
+ const snappedRow = Math.round(row / snappedSize) * snappedSize;
+
+ return gridToScreen3d({
+ col: snappedCol,
+ row: snappedRow,
+ });
+};
+
+export const convert3dTo2dPoint = (point: Point) => {
+ return gridToScreen2d(screenToGrid3d(point));
+};
+
+export const convert2dTo3dPoint = (point: Point) => {
+ return gridToScreen3d(screenToGrid2d(point));
+};
+
+export const generateRandomRGB = () => {
+ const r = Math.floor(Math.random() * 255);
+ const g = Math.floor(Math.random() * 255);
+ const b = Math.floor(Math.random() * 255);
+ return `rgb(${r},${g},${b})`;
+};
+
+export const getConnectorPoints = (
+ node: Node,
+ dimension: Dimension,
+): Omit => {
+ const point = node.point;
+ const { width, height } = node.size[dimension];
+ const depth = GRID_3D_HEIGHT_SIZE / 2;
+ return {
+ top: { x: point.x + width / 2, y: point.y },
+ right:
+ dimension === '2d'
+ ? { x: point.x + width, y: point.y + height / 2 }
+ : {
+ x: point.x + width,
+ y: point.y + (height - depth) / 2,
+ },
+ left:
+ dimension === '2d'
+ ? { x: point.x, y: point.y + height / 2 }
+ : {
+ x: point.x,
+ y: point.y + (height - depth) / 2,
+ },
+ bottom: { x: point.x + width / 2, y: point.y + height },
+ };
+};
+
+//INFO: 선분과 내적/외적 사이의 최단 거리를 계산(For Bend Point)
+export const getDistanceToSegment = (
+ p: Point,
+ p1: Point,
+ p2: Point,
+): number => {
+ const A = p.x - p1.x;
+ const B = p.y - p1.y;
+ const C = p2.x - p1.x;
+ const D = p2.y - p1.y;
+
+ const dot = A * C + B * D;
+ const len_sq = C * C + D * D;
+ let param = -1;
+ if (len_sq !== 0) param = dot / len_sq;
+
+ let xx, yy;
+
+ if (param < 0) {
+ xx = p1.x;
+ yy = p1.y;
+ } else if (param > 1) {
+ xx = p2.x;
+ yy = p2.y;
+ } else {
+ xx = p1.x + param * C;
+ yy = p1.y + param * D;
+ }
+
+ const dx = p.x - xx;
+ const dy = p.y - yy;
+ return Math.sqrt(dx * dx + dy * dy);
+};
diff --git a/apps/client/tsconfig.json b/apps/client/tsconfig.json
index 304ca2cb..6eca6178 100644
--- a/apps/client/tsconfig.json
+++ b/apps/client/tsconfig.json
@@ -7,15 +7,16 @@
"allowImportingTsExtensions": true, // ts 파일을 import 할 수 있도록 설정
"noEmit": true, // 컴파일러가 javascript 출력 파일을 생성하지 않음
"allowSyntheticDefaultImports": true, // default import 허용
+ "moduleResolution": "node",
"baseUrl": ".",
"paths": {
+ "@helpers/*": ["src/helpers/*"],
"@cloud-graph/*": ["src/cloud-graph/*"],
"@cloud-graph": ["src/cloud-graph/index.ts"],
"@cloudflow": ["src/cloudflow/index.tsx"],
- "@cloudflow/*": ["src/cloudflow/*"],
"@components/*": ["src/components/*"],
"@hooks/*": ["src/hooks/*"],
- "@utils/*": ["src/utils/*"],
+ "@utils": ["src/utils/index.ts"],
"@contexts/*": ["src/contexts/*"],
"@types": ["src/types/index.ts"],
"@theme": ["src/theme/index.ts"],
diff --git a/apps/terraform/convertor/TerraformConvertor.ts b/apps/terraform/convertor/TerraformConvertor.ts
index d2ffdd76..fd52074e 100644
--- a/apps/terraform/convertor/TerraformConvertor.ts
+++ b/apps/terraform/convertor/TerraformConvertor.ts
@@ -16,7 +16,7 @@ export class TerraformConvertor {
}
addResourceFromJson(jsonData: { nodes?: CloudCanvasNode[] }): void {
- jsonData.nodes?.forEach(node => {
+ jsonData.nodes?.forEach((node) => {
try {
const resource = parseToNCloudModel(node);
this.resourceManager.addResource(resource);
@@ -29,4 +29,4 @@ export class TerraformConvertor {
generate(): string {
return this.codeGenerator.generateCode(this.provider);
}
-}
\ No newline at end of file
+}
diff --git a/apps/terraform/enum/ResourcePriority.ts b/apps/terraform/enum/ResourcePriority.ts
index f1f46f34..c4b494ad 100644
--- a/apps/terraform/enum/ResourcePriority.ts
+++ b/apps/terraform/enum/ResourcePriority.ts
@@ -7,5 +7,5 @@ export enum ResourcePriority {
LOGIN_KEY = 6,
NETWORK_INTERFACE = 7,
SERVER = 8,
- PUBLIC_IP = 9
-}
\ No newline at end of file
+ PUBLIC_IP = 9,
+}
diff --git a/apps/terraform/interface/ACG.ts b/apps/terraform/interface/ACG.ts
index 7775794c..bdf4fdc6 100644
--- a/apps/terraform/interface/ACG.ts
+++ b/apps/terraform/interface/ACG.ts
@@ -3,4 +3,4 @@ export interface ACG {
name: string;
vpcNo: string;
description: string;
-}
\ No newline at end of file
+}
diff --git a/apps/terraform/interface/ACGRule.ts b/apps/terraform/interface/ACGRule.ts
index 62cfac62..fb82ad01 100644
--- a/apps/terraform/interface/ACGRule.ts
+++ b/apps/terraform/interface/ACGRule.ts
@@ -3,4 +3,4 @@ export interface ACGRule {
ipBlock: string;
portRange: string;
description: string;
-}
\ No newline at end of file
+}
diff --git a/apps/terraform/interface/CloudCanvasNode.ts b/apps/terraform/interface/CloudCanvasNode.ts
index 69a47251..a0761dda 100644
--- a/apps/terraform/interface/CloudCanvasNode.ts
+++ b/apps/terraform/interface/CloudCanvasNode.ts
@@ -3,4 +3,4 @@ export interface CloudCanvasNode {
type: string;
name: string;
properties: { [key: string]: any };
-}
\ No newline at end of file
+}
diff --git a/apps/terraform/interface/FileOption.ts b/apps/terraform/interface/FileOption.ts
index 9eee3c0f..4c36f0d1 100644
--- a/apps/terraform/interface/FileOption.ts
+++ b/apps/terraform/interface/FileOption.ts
@@ -1,3 +1,3 @@
export interface FileOption {
log?: boolean;
-}
\ No newline at end of file
+}
diff --git a/apps/terraform/interface/NCloudModel.ts b/apps/terraform/interface/NCloudModel.ts
index fb7956c6..c66d20a4 100644
--- a/apps/terraform/interface/NCloudModel.ts
+++ b/apps/terraform/interface/NCloudModel.ts
@@ -5,4 +5,4 @@ export interface NCloudModel {
serviceType: string;
priority: ResourcePriority;
getProperties(): { [key: string]: any };
-}
\ No newline at end of file
+}
diff --git a/apps/terraform/interface/NetworkACL.ts b/apps/terraform/interface/NetworkACL.ts
index e83d9808..5a94cd74 100644
--- a/apps/terraform/interface/NetworkACL.ts
+++ b/apps/terraform/interface/NetworkACL.ts
@@ -2,4 +2,4 @@ export interface NetworkACL {
id: string;
name: string;
vpcNo: string;
-}
\ No newline at end of file
+}
diff --git a/apps/terraform/interface/NetworkInterface.ts b/apps/terraform/interface/NetworkInterface.ts
index cabcd519..9afb6c03 100644
--- a/apps/terraform/interface/NetworkInterface.ts
+++ b/apps/terraform/interface/NetworkInterface.ts
@@ -3,4 +3,4 @@ export interface NetworkInterface {
name: string;
subnetNo: string;
accessControlGroups: string[];
-}
\ No newline at end of file
+}
diff --git a/apps/terraform/interface/Provider.ts b/apps/terraform/interface/Provider.ts
index 0f2a984d..54a998d0 100644
--- a/apps/terraform/interface/Provider.ts
+++ b/apps/terraform/interface/Provider.ts
@@ -3,4 +3,4 @@ export interface Provider {
secretKey: string;
region: string;
site: string;
-}
\ No newline at end of file
+}
diff --git a/apps/terraform/interface/PublicIp.ts b/apps/terraform/interface/PublicIp.ts
index 0f265b85..e8d13c49 100644
--- a/apps/terraform/interface/PublicIp.ts
+++ b/apps/terraform/interface/PublicIp.ts
@@ -2,4 +2,4 @@ export interface PublicIp {
id: string;
publicIp: string;
serverInstanceNo: string;
-}
\ No newline at end of file
+}
diff --git a/apps/terraform/interface/Server.ts b/apps/terraform/interface/Server.ts
index fb1940ad..221cc43e 100644
--- a/apps/terraform/interface/Server.ts
+++ b/apps/terraform/interface/Server.ts
@@ -6,4 +6,4 @@ export interface Server {
serverProductCode: string;
loginKeyName: string;
networkInterfaceNo: string;
-}
\ No newline at end of file
+}
diff --git a/apps/terraform/interface/Subnet.ts b/apps/terraform/interface/Subnet.ts
index 2d3ef458..388a8956 100644
--- a/apps/terraform/interface/Subnet.ts
+++ b/apps/terraform/interface/Subnet.ts
@@ -7,4 +7,4 @@ export interface Subnet {
networkAclNo: string;
subnetType: string;
usageType: string;
-}
\ No newline at end of file
+}
diff --git a/apps/terraform/interface/VPC.ts b/apps/terraform/interface/VPC.ts
index 9500e524..252e67a3 100644
--- a/apps/terraform/interface/VPC.ts
+++ b/apps/terraform/interface/VPC.ts
@@ -7,4 +7,4 @@ export interface VPC {
defaultAccessControlGroupNo: string;
defaultPublicRouteTableNo: string;
defaultPrivateRouteTableNo: string;
-}
\ No newline at end of file
+}
diff --git a/apps/terraform/main.ts b/apps/terraform/main.ts
index f65cf53d..ae2a9b11 100644
--- a/apps/terraform/main.ts
+++ b/apps/terraform/main.ts
@@ -6,10 +6,10 @@ import { saveTerraformFiles } from './util/file';
async function generateTerraformCode(): Promise {
const provider = new NCloudProvider({
- accessKey: "var.access_key",
- secretKey: "var.secret_key",
- region: "var.region",
- site: "public"
+ accessKey: 'var.access_key',
+ secretKey: 'var.secret_key',
+ region: 'var.region',
+ site: 'public',
});
const converter = new TerraformConvertor(provider);
@@ -22,10 +22,12 @@ async function main() {
try {
const terraformCode = await generateTerraformCode();
await saveTerraformFiles(terraformCode, { log: true });
-
} catch (error) {
if (error instanceof Error) {
- console.error('Error generating Terraform configuration:', error.message);
+ console.error(
+ 'Error generating Terraform configuration:',
+ error.message,
+ );
} else {
console.error('An unknown error occurred');
}
diff --git a/apps/terraform/model/NCloudACG.ts b/apps/terraform/model/NCloudACG.ts
index 6a7c1c20..290c1d72 100644
--- a/apps/terraform/model/NCloudACG.ts
+++ b/apps/terraform/model/NCloudACG.ts
@@ -19,8 +19,8 @@ export class NCloudACG implements ACG, NCloudModel {
getProperties() {
return {
name: this.name,
- vpc_no: "VPC_ID_PLACEHOLDER",
- description: this.description
+ vpc_no: 'VPC_ID_PLACEHOLDER',
+ description: this.description,
};
}
}
diff --git a/apps/terraform/model/NCloudACGRule.ts b/apps/terraform/model/NCloudACGRule.ts
index ea3b7092..77771f46 100644
--- a/apps/terraform/model/NCloudACGRule.ts
+++ b/apps/terraform/model/NCloudACGRule.ts
@@ -22,13 +22,13 @@ export class NCloudACGRule implements NCloudModel {
getProperties() {
return {
- access_control_group_no: "ACG_ID_PLACEHOLDER",
+ access_control_group_no: 'ACG_ID_PLACEHOLDER',
inbound: {
protocol: this.protocol,
ip_block: this.ipBlock,
port_range: this.portRange,
- description: this.description
- }
+ description: this.description,
+ },
};
}
}
diff --git a/apps/terraform/model/NCloudLoginKey.ts b/apps/terraform/model/NCloudLoginKey.ts
index 0dd88052..08586d33 100644
--- a/apps/terraform/model/NCloudLoginKey.ts
+++ b/apps/terraform/model/NCloudLoginKey.ts
@@ -14,7 +14,7 @@ export class NCloudLoginKey implements NCloudModel {
getProperties() {
return {
- key_name: this.name
+ key_name: this.name,
};
}
}
diff --git a/apps/terraform/model/NCloudNetworkACL.ts b/apps/terraform/model/NCloudNetworkACL.ts
index 8bef2f72..db76739e 100644
--- a/apps/terraform/model/NCloudNetworkACL.ts
+++ b/apps/terraform/model/NCloudNetworkACL.ts
@@ -19,7 +19,7 @@ export class NCloudNetworkACL implements NetworkACL, NCloudModel {
getProperties() {
return {
- vpc_no: "VPC_ID_PLACEHOLDER"
+ vpc_no: 'VPC_ID_PLACEHOLDER',
};
}
}
diff --git a/apps/terraform/model/NCloudNetworkInterface.ts b/apps/terraform/model/NCloudNetworkInterface.ts
index aa55f485..3fee062e 100644
--- a/apps/terraform/model/NCloudNetworkInterface.ts
+++ b/apps/terraform/model/NCloudNetworkInterface.ts
@@ -20,8 +20,7 @@ export class NCloudNetworkInterface implements NetworkInterface, NCloudModel {
return {
subnet_no: 'SUBNET_ID_PLACEHOLDER',
name: this.name,
- access_control_groups: ['ACG_ID_PLACEHOLDER']
+ access_control_groups: ['ACG_ID_PLACEHOLDER'],
};
}
}
-
diff --git a/apps/terraform/model/NCloudProvider.ts b/apps/terraform/model/NCloudProvider.ts
index 67f5b152..a616c6f2 100644
--- a/apps/terraform/model/NCloudProvider.ts
+++ b/apps/terraform/model/NCloudProvider.ts
@@ -26,19 +26,18 @@ export class NCloudProvider implements Provider {
terraform: {
required_providers: {
ncloud: {
- source: this.source
- }
+ source: this.source,
+ },
},
- required_version: this.requiredVersion
+ required_version: this.requiredVersion,
},
provider: {
- access_key: "var.access_key",
- secret_key: "var.secret_key",
- region: "var.region",
+ access_key: 'var.access_key',
+ secret_key: 'var.secret_key',
+ region: 'var.region',
site: this.site,
- support_vpc: true
- }
+ support_vpc: true,
+ },
};
}
-
}
diff --git a/apps/terraform/model/NCloudPublicIP.ts b/apps/terraform/model/NCloudPublicIP.ts
index 9685fd3e..9b9282df 100644
--- a/apps/terraform/model/NCloudPublicIP.ts
+++ b/apps/terraform/model/NCloudPublicIP.ts
@@ -14,7 +14,7 @@ export class NCloudPublicIP implements NCloudModel {
getProperties() {
return {
- server_instance_no: "SERVER_ID_PLACEHOLDER"
+ server_instance_no: 'SERVER_ID_PLACEHOLDER',
};
}
}
diff --git a/apps/terraform/model/NCloudServer.ts b/apps/terraform/model/NCloudServer.ts
index d79d7818..9d8ca3a0 100644
--- a/apps/terraform/model/NCloudServer.ts
+++ b/apps/terraform/model/NCloudServer.ts
@@ -27,16 +27,15 @@ export class NCloudServer implements Server, NCloudModel {
getProperties() {
return {
- subnet_no: "SUBNET_ID_PLACEHOLDER",
+ subnet_no: 'SUBNET_ID_PLACEHOLDER',
name: this.name,
server_image_product_code: this.serverImageProductCode,
server_product_code: this.serverProductCode,
- login_key_name: "LOGIN_KEY_NAME_PLACEHOLDER",
+ login_key_name: 'LOGIN_KEY_NAME_PLACEHOLDER',
network_interface: {
- network_interface_no: "NIC_ID_PLACEHOLDER",
- order: 0
- }
+ network_interface_no: 'NIC_ID_PLACEHOLDER',
+ order: 0,
+ },
};
}
}
-
diff --git a/apps/terraform/model/NCloudSubnet.ts b/apps/terraform/model/NCloudSubnet.ts
index 643c25b5..9cdca9cc 100644
--- a/apps/terraform/model/NCloudSubnet.ts
+++ b/apps/terraform/model/NCloudSubnet.ts
@@ -23,14 +23,13 @@ export class NCloudSubnet implements NCloudModel {
getProperties() {
return {
- vpc_no: "VPC_ID_PLACEHOLDER",
+ vpc_no: 'VPC_ID_PLACEHOLDER',
subnet: this.subnet,
zone: this.zone,
- network_acl_no: "VPC_ACL_PLACEHOLDER",
+ network_acl_no: 'VPC_ACL_PLACEHOLDER',
subnet_type: this.subnetType,
name: this.name,
- usage_type: this.usageType
+ usage_type: this.usageType,
};
}
}
-
diff --git a/apps/terraform/model/NCloudVPC.ts b/apps/terraform/model/NCloudVPC.ts
index c2b92d63..a682d9c6 100644
--- a/apps/terraform/model/NCloudVPC.ts
+++ b/apps/terraform/model/NCloudVPC.ts
@@ -14,7 +14,6 @@ export class NCloudVPC implements VPC, NCloudModel {
serviceType: string;
priority: ResourcePriority;
-
constructor(json: any) {
this.serviceType = 'ncloud_vpc';
this.priority = ResourcePriority.VPC;
@@ -31,7 +30,7 @@ export class NCloudVPC implements VPC, NCloudModel {
getProperties() {
return {
name: this.name,
- ipv4_cidr_block: this.ipv4CidrBlock
+ ipv4_cidr_block: this.ipv4CidrBlock,
};
}
}
diff --git a/apps/terraform/sample/sampleData.ts b/apps/terraform/sample/sampleData.ts
index 2a1a81a0..22c79d97 100644
--- a/apps/terraform/sample/sampleData.ts
+++ b/apps/terraform/sample/sampleData.ts
@@ -2,76 +2,74 @@ import { CloudCanvasNode } from '../interface/CloudCanvasNode';
export const sampleNodes: CloudCanvasNode[] = [
{
- id: "vpc1",
- type: "VPC",
- name: "my-vpc",
+ id: 'vpc1',
+ type: 'VPC',
+ name: 'my-vpc',
properties: {
- cidrBlock: "172.16.0.0/16"
- }
+ cidrBlock: '172.16.0.0/16',
+ },
},
{
- id: "nacl1",
- type: "NetworkACL",
- name: "my-nacl",
- properties: {
- }
+ id: 'nacl1',
+ type: 'NetworkACL',
+ name: 'my-nacl',
+ properties: {},
},
{
- id: "subnet1",
- type: "Subnet",
- name: "my-subnet",
+ id: 'subnet1',
+ type: 'Subnet',
+ name: 'my-subnet',
properties: {
- subnet: "172.16.10.0/24",
- zone: "KR-2",
- subnetType: "PUBLIC",
- usageType: "GEN"
- }
+ subnet: '172.16.10.0/24',
+ zone: 'KR-2',
+ subnetType: 'PUBLIC',
+ usageType: 'GEN',
+ },
},
{
- id: "acg1",
- type: "ACG",
- name: "my-acg",
+ id: 'acg1',
+ type: 'ACG',
+ name: 'my-acg',
properties: {
- description: "My ACG"
- }
+ description: 'My ACG',
+ },
},
{
- id: "acgrule1",
- type: "ACGRule",
- name: "",
+ id: 'acgrule1',
+ type: 'ACGRule',
+ name: '',
properties: {
- protocol: "TCP",
- ipBlock: "0.0.0.0/0",
- portRange: "80",
- description: "HTTP"
- }
+ protocol: 'TCP',
+ ipBlock: '0.0.0.0/0',
+ portRange: '80',
+ description: 'HTTP',
+ },
},
{
- id: "loginkey1",
- type: "LoginKey",
- name: "my-key",
- properties: {
- }
+ id: 'loginkey1',
+ type: 'LoginKey',
+ name: 'my-key',
+ properties: {},
},
{
- id: "nic1",
- type: "NetworkInterface",
- name: "my-nic",
- properties: {}
+ id: 'nic1',
+ type: 'NetworkInterface',
+ name: 'my-nic',
+ properties: {},
},
{
- id: "server1",
- type: "Server",
- name: "my-server",
+ id: 'server1',
+ type: 'Server',
+ name: 'my-server',
properties: {
- serverImageProductCode: "SW.VSVR.OS.LNX64.CNTOS.0708.B050",
- serverProductCode: "SVR.VSVR.HICPU.C002.M004.NET.HDD.B050.G002"
- }
+ serverImageProductCode: 'SW.VSVR.OS.LNX64.CNTOS.0708.B050',
+ serverProductCode: 'SVR.VSVR.HICPU.C002.M004.NET.HDD.B050.G002',
+ },
},
{
- id: "publicip1",
- type: "PublicIP",
- name: "my-public-ip",
- properties: {}
- }
+ id: 'publicip1',
+ type: 'PublicIP',
+ name: 'my-public-ip',
+ properties: {},
+ },
];
diff --git a/apps/terraform/type/TerraformGenerator.ts b/apps/terraform/type/TerraformGenerator.ts
index ed4ce4fd..0f2687fb 100644
--- a/apps/terraform/type/TerraformGenerator.ts
+++ b/apps/terraform/type/TerraformGenerator.ts
@@ -1,5 +1,9 @@
import { replaceReferences } from '../util/reference';
-import { generateProviderBlock, generateResourceBlock, generateTerraformBlock } from '../util/generator';
+import {
+ generateProviderBlock,
+ generateResourceBlock,
+ generateTerraformBlock,
+} from '../util/generator';
import { ResourceManager } from './ResourceManager';
import { NCloudProvider } from '../model/NCloudProvider';
@@ -16,30 +20,27 @@ export class CodeGenerator {
const blocks = [
generateTerraformBlock(
providerProperties.terraform.required_providers.ncloud.source,
- providerProperties.terraform.required_version
+ providerProperties.terraform.required_version,
),
- generateProviderBlock(
- provider.name,
- providerProperties.provider
- ),
- ...this.generateResourceBlocks()
+ generateProviderBlock(provider.name, providerProperties.provider),
+ ...this.generateResourceBlocks(),
];
return blocks.join('\n');
}
private generateResourceBlocks(): string[] {
- return this.resourceManager.getResources().map(resource => {
+ return this.resourceManager.getResources().map((resource) => {
const properties = replaceReferences(
resource.getProperties(),
- this.resourceManager.getNameMap()
+ this.resourceManager.getNameMap(),
);
return generateResourceBlock(
resource.serviceType,
resource.name,
- properties
+ properties,
);
});
}
-}
\ No newline at end of file
+}
diff --git a/apps/terraform/util/file.ts b/apps/terraform/util/file.ts
index 2ff83b7e..b158639f 100644
--- a/apps/terraform/util/file.ts
+++ b/apps/terraform/util/file.ts
@@ -3,7 +3,7 @@ import { FileOption } from '../interface/FileOption';
export const writeFile = async (
filePath: string,
content: string,
- options: FileOption = {}
+ options: FileOption = {},
): Promise => {
const fs = require('fs').promises;
await fs.writeFile(filePath, content);
@@ -37,7 +37,7 @@ region = "KR"`;
export const saveTerraformFiles = async (
terraformCode: string,
- options: FileOption = {}
+ options: FileOption = {},
): Promise => {
try {
await writeFile('main.tf', terraformCode, options);
@@ -50,8 +50,9 @@ export const saveTerraformFiles = async (
console.log('\n테라폼 파일 생성 완료');
console.log('terraform.tfvars 파일을 생성하고 key를 넣어주세요');
-
} catch (error) {
- throw new Error(`Failed to save Terraform files: ${error instanceof Error ? error.message : 'Unknown error'}`);
+ throw new Error(
+ `Failed to save Terraform files: ${error instanceof Error ? error.message : 'Unknown error'}`,
+ );
}
};
diff --git a/apps/terraform/util/formatter.ts b/apps/terraform/util/formatter.ts
index 43a74927..acbb7667 100644
--- a/apps/terraform/util/formatter.ts
+++ b/apps/terraform/util/formatter.ts
@@ -10,7 +10,7 @@ export const isVariableReference = (value: string): boolean => {
export const formatValue = (value: any): string => {
if (Array.isArray(value)) {
- return `[${value.map(item => formatValue(item)).join(', ')}]`;
+ return `[${value.map((item) => formatValue(item)).join(', ')}]`;
}
if (typeof value === 'string') {
@@ -25,16 +25,22 @@ export const formatValue = (value: any): string => {
export const formatProperties = (
properties: { [key: string]: any },
- indentLevel: number = 1
+ indentLevel: number = 1,
): string => {
const indent = ' '.repeat(indentLevel);
- const maxKeyLength = Math.max(...Object.keys(properties).map(key => key.length));
+ const maxKeyLength = Math.max(
+ ...Object.keys(properties).map((key) => key.length),
+ );
return Object.entries(properties)
.map(([key, value]) => {
const padding = ' '.repeat(maxKeyLength - key.length);
- if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
+ if (
+ typeof value === 'object' &&
+ value !== null &&
+ !Array.isArray(value)
+ ) {
return `${indent}${key} {
${formatProperties(value, indentLevel + 1)}
${indent}}`;
diff --git a/apps/terraform/util/generator.ts b/apps/terraform/util/generator.ts
index 09fdcfc8..eb2da04e 100644
--- a/apps/terraform/util/generator.ts
+++ b/apps/terraform/util/generator.ts
@@ -1,6 +1,9 @@
import { formatProperties } from './formatter';
-export const generateTerraformBlock = (providerSource: string, version: string): string => `
+export const generateTerraformBlock = (
+ providerSource: string,
+ version: string,
+): string => `
terraform {
required_providers {
ncloud = {
@@ -12,7 +15,7 @@ terraform {
export const generateProviderBlock = (
name: string,
- properties: { [key: string]: any }
+ properties: { [key: string]: any },
): string => `
provider "${name}" {
${formatProperties(properties)}
@@ -21,8 +24,8 @@ ${formatProperties(properties)}
export const generateResourceBlock = (
serviceType: string,
name: string,
- properties: { [key: string]: any }
+ properties: { [key: string]: any },
): string => `
resource "${serviceType}" "${name}" {
${formatProperties(properties)}
-}`;
\ No newline at end of file
+}`;
diff --git a/apps/terraform/util/reference.ts b/apps/terraform/util/reference.ts
index 0599f1eb..a6872d58 100644
--- a/apps/terraform/util/reference.ts
+++ b/apps/terraform/util/reference.ts
@@ -2,16 +2,16 @@ type ReferenceMap = Map;
export const resolveReference = (
placeholder: string,
- resourceNameMap: ReferenceMap
+ resourceNameMap: ReferenceMap,
): string => {
const references: { [key: string]: string } = {
- 'VPC_ID_PLACEHOLDER': `ncloud_vpc.${resourceNameMap.get('ncloud_vpc')}.id`,
- 'VPC_ACL_PLACEHOLDER': `ncloud_vpc.${resourceNameMap.get('ncloud_vpc')}.default_network_acl_no`,
- 'SUBNET_ID_PLACEHOLDER': `ncloud_subnet.${resourceNameMap.get('ncloud_subnet')}.id`,
- 'ACG_ID_PLACEHOLDER': `ncloud_access_control_group.${resourceNameMap.get('ncloud_access_control_group')}.id`,
- 'LOGIN_KEY_NAME_PLACEHOLDER': `ncloud_login_key.${resourceNameMap.get('ncloud_login_key')}.key_name`,
- 'NIC_ID_PLACEHOLDER': `ncloud_network_interface.${resourceNameMap.get('ncloud_network_interface')}.id`,
- 'SERVER_ID_PLACEHOLDER': `ncloud_server.${resourceNameMap.get('ncloud_server')}.id`
+ VPC_ID_PLACEHOLDER: `ncloud_vpc.${resourceNameMap.get('ncloud_vpc')}.id`,
+ VPC_ACL_PLACEHOLDER: `ncloud_vpc.${resourceNameMap.get('ncloud_vpc')}.default_network_acl_no`,
+ SUBNET_ID_PLACEHOLDER: `ncloud_subnet.${resourceNameMap.get('ncloud_subnet')}.id`,
+ ACG_ID_PLACEHOLDER: `ncloud_access_control_group.${resourceNameMap.get('ncloud_access_control_group')}.id`,
+ LOGIN_KEY_NAME_PLACEHOLDER: `ncloud_login_key.${resourceNameMap.get('ncloud_login_key')}.key_name`,
+ NIC_ID_PLACEHOLDER: `ncloud_network_interface.${resourceNameMap.get('ncloud_network_interface')}.id`,
+ SERVER_ID_PLACEHOLDER: `ncloud_server.${resourceNameMap.get('ncloud_server')}.id`,
};
return references[placeholder] || placeholder;
@@ -19,7 +19,7 @@ export const resolveReference = (
export const replaceReferences = (
properties: { [key: string]: any },
- resourceNameMap: ReferenceMap
+ resourceNameMap: ReferenceMap,
): { [key: string]: any } => {
const result = { ...properties };
@@ -27,10 +27,10 @@ export const replaceReferences = (
if (typeof value === 'string') {
result[key] = resolveReference(value, resourceNameMap);
} else if (Array.isArray(value)) {
- result[key] = value.map(item =>
+ result[key] = value.map((item) =>
typeof item === 'string'
? resolveReference(item, resourceNameMap)
- : replaceReferences({ value: item }, resourceNameMap).value
+ : replaceReferences({ value: item }, resourceNameMap).value,
);
} else if (typeof value === 'object' && value !== null) {
result[key] = replaceReferences(value, resourceNameMap);
diff --git a/apps/terraform/util/resourceParser.ts b/apps/terraform/util/resourceParser.ts
index f4236cee..a97f6440 100644
--- a/apps/terraform/util/resourceParser.ts
+++ b/apps/terraform/util/resourceParser.ts
@@ -10,20 +10,19 @@ import { NCloudNetworkInterface } from '../model/NCloudNetworkInterface';
import { NCloudServer } from '../model/NCloudServer';
import { NCloudPublicIP } from '../model/NCloudPublicIP';
-
-export function parseToNCloudModel(resource: CloudCanvasNode ): NCloudModel {
+export function parseToNCloudModel(resource: CloudCanvasNode): NCloudModel {
const { type, name, properties } = resource;
switch (type.toLowerCase()) {
case 'vpc':
return new NCloudVPC({
name: name || 'vpc',
- ipv4CidrBlock: properties.cidrBlock
+ ipv4CidrBlock: properties.cidrBlock,
});
case 'networkacl':
return new NCloudNetworkACL({
- name: name || 'nacl'
+ name: name || 'nacl',
});
case 'subnet':
@@ -32,14 +31,14 @@ export function parseToNCloudModel(resource: CloudCanvasNode ): NCloudModel {
subnet: properties.subnet,
zone: properties.zone,
subnetType: properties.subnetType,
- usageType: properties.usageType
+ usageType: properties.usageType,
});
case 'acg':
case 'accesscontrolgroup':
return new NCloudACG({
name: name || 'acg',
- description: properties.description
+ description: properties.description,
});
case 'acgrule':
@@ -48,32 +47,32 @@ export function parseToNCloudModel(resource: CloudCanvasNode ): NCloudModel {
protocol: properties.protocol,
ipBlock: properties.ipBlock,
portRange: properties.portRange,
- description: properties.description
+ description: properties.description,
});
case 'loginkey':
return new NCloudLoginKey({
- name: name || 'login-key'
+ name: name || 'login-key',
});
case 'networkinterface':
return new NCloudNetworkInterface({
- name: name || 'nic'
+ name: name || 'nic',
});
case 'server':
return new NCloudServer({
name: name || 'server',
serverImageProductCode: properties.serverImageProductCode,
- serverProductCode: properties.serverProductCode
+ serverProductCode: properties.serverProductCode,
});
case 'publicip':
return new NCloudPublicIP({
- name: name || 'public-ip'
+ name: name || 'public-ip',
});
default:
throw new Error(`Unsupported resource type: ${type}`);
}
-}
\ No newline at end of file
+}