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 -cicd +cicd # 팀 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 ( - - - {children} - - ); - }, -); - -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 ( + + {children} + + ); +}; 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 ( <> { fill="#ffffff" style={{ userSelect: 'none' }} > - {label} + M5 + {/* {node.properties.instanceType} */} ); }; -const Node2D = ({ label }: NodeProps) => { +const Node2D = () => { return ( <> @@ -87,17 +83,14 @@ const Node2D = ({ label }: NodeProps) => { fill="#d86613" style={{ userSelect: 'none' }} > - {label} + M5 ); }; -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 +}