├── .eslintrc.cjs ├── .gitignore ├── LICENSE ├── README.md ├── index.html ├── package.json ├── pnpm-lock.yaml ├── public └── vite.svg ├── src ├── App.tsx ├── components │ ├── ControlPanel.tsx │ ├── Edges │ │ ├── BaseEdge │ │ │ ├── index.tsx │ │ │ └── useRebuildEdge.tsx │ │ ├── EdgeController │ │ │ ├── index.tsx │ │ │ ├── smart-edge.ts │ │ │ └── useEdgeDraggable.tsx │ │ ├── Marker.tsx │ │ └── index.tsx │ ├── Nodes │ │ ├── BaseNode │ │ │ ├── index.tsx │ │ │ └── styles.css │ │ └── index.tsx │ └── ReactflowInstance.tsx ├── data │ ├── convert.ts │ ├── data.json │ └── types.ts ├── index.css ├── layout │ ├── edge │ │ ├── algorithms │ │ │ ├── a-star.ts │ │ │ ├── index.ts │ │ │ └── simple.ts │ │ ├── edge.ts │ │ ├── index.ts │ │ ├── point.ts │ │ └── style.ts │ ├── metadata.ts │ ├── node │ │ ├── algorithms │ │ │ ├── d3-dag.ts │ │ │ ├── d3-hierarchy.ts │ │ │ ├── dagre-tree.ts │ │ │ ├── elk.ts │ │ │ └── origin.ts │ │ └── index.ts │ └── useAutoLayout.ts ├── main.tsx ├── states │ └── reactflow.ts ├── utils │ ├── base.ts │ ├── diff.ts │ └── uuid.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { browser: true, es2020: true }, 4 | extends: [ 5 | "eslint:recommended", 6 | "plugin:@typescript-eslint/recommended", 7 | "plugin:react-hooks/recommended", 8 | ], 9 | ignorePatterns: ["dist", ".eslintrc.cjs"], 10 | parser: "@typescript-eslint/parser", 11 | plugins: ["react-refresh"], 12 | rules: { 13 | "@typescript-eslint/ban-ts-comment": "off", 14 | "@typescript-eslint/no-explicit-any": "off", 15 | "@typescript-eslint/no-non-null-assertion": "off", 16 | "@typescript-eslint/explicit-module-boundary-types": "off", 17 | "@typescript-eslint/no-unused-vars": [ 18 | "error", 19 | { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, 20 | ], 21 | "react/react-in-jsx-scope": "off", 22 | "react/display-name": "off", 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | .vercel 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Del Wang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ReactFlow Auto Layout Demo 2 | 3 | A demo showcasing the auto layout and Figma-like edge editing capabilities of [ReactFlow](https://reactflow.dev). 4 | 5 | 👉 View Demo: [https://reactflow-auto-layout.vercel.app](https://reactflow-auto-layout.vercel.app/) 6 | 7 | # ✨ Highlights 8 | 9 | ### 1. Node Auto Layout 10 | 11 | - Supports various auto layout algorithms like [Dagre](https://github.com/dagrejs/dagre), [ELK](https://github.com/kieler/elkjs), [D3-hierarchy](https://github.com/d3/d3-hierarchy), [D3-dag](https://github.com/erikbrinkman/d3-dag) and more. 12 | - Enables automatic layout of nodes with dynamic sizing. 13 | - Supports automatic layout for multiple subflows. 14 | - Allows dynamic adjustment of layout direction, node spacing, port sorting, and other layout parameters. 15 | 16 | https://github.com/idootop/reactflow-auto-layout/assets/35302658/952f5021-1cd0-49bf-8dd8-b12521e2a7ce 17 | 18 | ### 2. Edge Auto Routing 19 | 20 | - Utilizes the [A\* search algorithm](https://en.wikipedia.org/wiki/A*_search_algorithm) combined with [Manhattan Distance](https://simple.wikipedia.org/wiki/Manhattan_distance) to find the optimal path for edges. 21 | - Ensures minimal overlap and intersections between edges and nodes at both ends. 22 | 23 | https://github.com/idootop/reactflow-auto-layout/assets/35302658/ea9a3657-b1d2-47c8-9a13-3727dfc31d48 24 | 25 | ### 3. Edge Polyline Drag Editing 26 | 27 | - Edges are drawn as right-angled polylines with rounded corners. 28 | - Edges consist of control points, and the line segments between control points can be moved by dragging the control handles. 29 | - During dragging, nearby control points and line segments are automatically merged, and new control points can be automatically split out. 30 | 31 | https://github.com/idootop/reactflow-auto-layout/assets/35302658/01f1c5c5-f224-4d12-9a31-bca45a0d5a56 32 | 33 | # 🌲 Introduction 34 | 35 | This demo is divided into several modules based on functionality, most of which can be directly copied and used. Let's break it down: 36 | 37 | ### Basic Types: 38 | 39 | - [src/data/types.ts](./src/data/types.ts): Contains type definitions for node and edge data. Reviewing this will help you understand the rest of the code. 40 | 41 | ### Node Auto Layout: 42 | 43 | - [src/layout/node/algorithms](./src/layout/node/algorithms): Contains implementations of various node layout algorithms. 44 | - [src/layout/useAutoLayout.ts](./src/layout/useAutoLayout.ts): Handles the auto layout process, including logic for dynamically adapting to node sizes. 45 | 46 | ### Edge Editing Functionality: 47 | 48 | - [src/layout/edge/index.ts](./src/layout/edge/index.ts): Start here to explore the control point generation algorithms and logic for drawing rounded corner edge paths. 49 | - [src/layout/edge/algorithms/index.ts](./src/layout/edge/algorithms/index.ts): Core of the edge auto-routing algorithm. Refer to the [LogicFlow 边的绘制与交互](https://juejin.cn/post/6942727734518874142) article for more details. 50 | - [src/components/Edges/EdgeController/index.tsx](./src/components/Edges/EdgeController/index.tsx): Follow this to understand how edge segment drag events are handled. 51 | - [src/components/Edges/EdgeController/smart-edge.ts](./src/components/Edges/EdgeController/smart-edge.ts): Manages logic for edge auto-merging/splitting, similar to Figma. 52 | 53 | These are the key modules of the project. While it might seem complex at first, the overall logic is straightforward. If you have any questions, feel free to raise an [issue](https://github.com/idootop/reactflow-auto-layout/issues). 54 | 55 | # ❤️ Acknowledgement 56 | 57 | 1. [ReactFlow](https://reactflow.dev/): The core diagrams engine empowering this project. 58 | 2. The [D3-hierarchy](https://github.com/d3/d3-hierarchy) auto layout approach primarily referenced from: [flanksource-ui](https://github.com/flanksource/flanksource-ui/blob/75b35591d3bbc7d446fa326d0ca7536790f38d88/src/ui/Graphs/Layouts/algorithms/d3-hierarchy.ts) 59 | 3. The Edge auto-routing approach mainly referred to: [LogicFlow 边的绘制与交互](https://juejin.cn/post/6942727734518874142) 60 | 4. Special thanks to [a3ng7n ](https://github.com/a3ng7n) for the invaluable [English comment translations](https://github.com/idootop/reactflow-auto-layout/pull/1). -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Vite + React + TS 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reactflow-auto-layout", 3 | "private": true, 4 | "version": "2.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@dagrejs/dagre": "^1.1.2", 13 | "@xyflow/react": "^12.3.6", 14 | "d3-dag": "^1.1.0", 15 | "d3-hierarchy": "^3.1.2", 16 | "elkjs": "^0.9.3", 17 | "leva": "^0.9.35", 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0", 20 | "xsta": "^2.0.0" 21 | }, 22 | "devDependencies": { 23 | "@types/d3-hierarchy": "^3.1.7", 24 | "@types/node": "^20.12.8", 25 | "@types/react": "^18.2.66", 26 | "@types/react-dom": "^18.2.22", 27 | "@typescript-eslint/eslint-plugin": "^7.2.0", 28 | "@typescript-eslint/parser": "^7.2.0", 29 | "@vitejs/plugin-react": "^4.2.1", 30 | "eslint": "^8.57.0", 31 | "eslint-plugin-react-hooks": "^4.6.0", 32 | "eslint-plugin-react-refresh": "^0.4.6", 33 | "typescript": "^5.2.2", 34 | "vite": "^5.2.0" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import "@xyflow/react/dist/style.css"; 2 | 3 | import { 4 | Background, 5 | BackgroundVariant, 6 | Controls, 7 | MiniMap, 8 | ReactFlow, 9 | ReactFlowProvider, 10 | useEdgesState, 11 | useNodesState, 12 | } from "@xyflow/react"; 13 | import { useEffect } from "react"; 14 | 15 | import { jsonDecode } from "@/utils/base"; 16 | 17 | import { ControlPanel } from "./components/ControlPanel"; 18 | import { kEdgeTypes } from "./components/Edges"; 19 | import { ColorfulMarkerDefinitions } from "./components/Edges/Marker"; 20 | import { kNodeTypes } from "./components/Nodes"; 21 | import { ReactflowInstance } from "./components/ReactflowInstance"; 22 | import { workflow2reactflow } from "./data/convert"; 23 | import defaultWorkflow from "./data/data.json"; 24 | import { kDefaultLayoutConfig, ReactflowLayoutConfig } from "./layout/node"; 25 | import { useAutoLayout } from "./layout/useAutoLayout"; 26 | 27 | const EditWorkFlow = () => { 28 | const [nodes, _setNodes, onNodesChange] = useNodesState([]); 29 | const [edges, _setEdges, onEdgesChange] = useEdgesState([]); 30 | 31 | const { layout, isDirty } = useAutoLayout(); 32 | 33 | const layoutReactflow = async ( 34 | props: ReactflowLayoutConfig & { 35 | workflow: string; 36 | } 37 | ) => { 38 | if (isDirty) { 39 | return; 40 | } 41 | const input = props.workflow; 42 | const data = jsonDecode(input); 43 | if (!data) { 44 | alert("Invalid workflow JSON data"); 45 | return; 46 | } 47 | const workflow = workflow2reactflow(data); 48 | layout({ ...workflow, ...props }); 49 | }; 50 | 51 | useEffect(() => { 52 | const { nodes, edges } = workflow2reactflow(defaultWorkflow as any); 53 | layout({ nodes, edges, ...kDefaultLayoutConfig }); 54 | }, []); 55 | 56 | return ( 57 |
66 | 67 | 75 | 76 | 77 | 78 | 79 | 80 | 81 |
82 | ); 83 | }; 84 | 85 | export const WorkFlow = () => { 86 | return ( 87 | 88 | 89 | 90 | ); 91 | }; 92 | -------------------------------------------------------------------------------- /src/components/ControlPanel.tsx: -------------------------------------------------------------------------------- 1 | import { button, Leva, useControls } from "leva"; 2 | import defaultWorkflow from "../data/data.json"; 3 | import { kDefaultLayoutConfig, ReactflowLayoutConfig } from "../layout/node"; 4 | import { jsonEncode } from "@/utils/base"; 5 | 6 | export const kReactflowLayoutConfig: { 7 | setState: any; 8 | state: ReactflowLayoutConfig; 9 | } = {} as any; 10 | 11 | export const workflowInputHint = jsonEncode(defaultWorkflow)!; 12 | 13 | const algorithms = [ 14 | "elk-mr-tree", 15 | "d3-hierarchy", 16 | "d3-dag", 17 | "ds-dag(s)", 18 | "elk-layered", 19 | "dagre-tree", 20 | ].reduce( 21 | (pre, algorithm) => { 22 | pre[algorithm] = algorithm; 23 | return pre; 24 | }, 25 | { 26 | [kDefaultLayoutConfig.algorithm]: kDefaultLayoutConfig.algorithm, 27 | } as any 28 | ); 29 | 30 | const directions = Object.entries({ 31 | vertical: "vertical", 32 | horizontal: "horizontal", 33 | }).reduce( 34 | (pre, [key, value]) => { 35 | pre[key] = value; 36 | return pre; 37 | }, 38 | { 39 | [kDefaultLayoutConfig.direction]: kDefaultLayoutConfig.direction, 40 | } as any 41 | ); 42 | 43 | const reverseSourceHandlesKeyMap: Record = { 44 | false: "asc", 45 | true: "desc", 46 | }; 47 | const reverseSourceHandles = Object.entries({ 48 | asc: false, 49 | desc: true, 50 | }).reduce( 51 | (pre, [key, value]) => { 52 | pre[key] = value; 53 | return pre; 54 | }, 55 | { 56 | [reverseSourceHandlesKeyMap[ 57 | kDefaultLayoutConfig.reverseSourceHandles.toString() 58 | ]]: kDefaultLayoutConfig.reverseSourceHandles, 59 | } as any 60 | ); 61 | 62 | export const ControlPanel = (props: { layoutReactflow: any }) => { 63 | const { layoutReactflow } = props; 64 | 65 | const [state, setState] = useControls(() => { 66 | return { 67 | workflow: { 68 | order: 1, 69 | label: "Workflow", 70 | rows: 3, 71 | value: workflowInputHint, 72 | }, 73 | algorithm: { 74 | order: 2, 75 | label: "Algorithms", 76 | options: algorithms, 77 | }, 78 | direction: { 79 | order: 3, 80 | label: "Direction", 81 | options: directions, 82 | }, 83 | spacing: { 84 | order: 4, 85 | label: "Spacing", 86 | value: kDefaultLayoutConfig.spacing as any, 87 | joystick: false, 88 | }, 89 | reverseSourceHandles: { 90 | order: 5, 91 | label: "Order", 92 | options: reverseSourceHandles, 93 | }, 94 | layout: { 95 | order: 6, 96 | label: "Layout", 97 | ...button((get) => { 98 | layoutReactflow({ 99 | workflow: get("workflow"), 100 | algorithm: get("algorithm"), 101 | direction: get("direction"), 102 | spacing: get("spacing"), 103 | reverseSourceHandles: get("reverseSourceHandles"), 104 | }); 105 | }), 106 | }, 107 | }; 108 | }); 109 | 110 | kReactflowLayoutConfig.state = state as any; 111 | kReactflowLayoutConfig.setState = setState; 112 | 113 | return ; 114 | }; 115 | -------------------------------------------------------------------------------- /src/components/Edges/BaseEdge/index.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentType, memo } from "react"; 2 | import { EdgeProps, BaseEdge as _BaseEdge } from "@xyflow/react"; 3 | 4 | import { ReactflowEdgeWithData } from "@/data/types"; 5 | import { isConnectionBackward } from "@/layout/edge/edge"; 6 | import { getEdgeStyles, layoutEdge } from "@/layout/edge/style"; 7 | import { kReactflow } from "@/states/reactflow"; 8 | import { EdgeControllers } from "../EdgeController"; 9 | import { useRebuildEdge } from "./useRebuildEdge"; 10 | 11 | export const BaseEdge: ComponentType< 12 | EdgeProps & { 13 | data: any; 14 | type: any; 15 | } 16 | > = memo( 17 | ({ 18 | id, 19 | selected, 20 | source, 21 | target, 22 | sourceX, 23 | sourceY, 24 | targetX, 25 | targetY, 26 | label, 27 | labelStyle, 28 | labelShowBg, 29 | labelBgStyle, 30 | labelBgPadding, 31 | labelBgBorderRadius, 32 | style, 33 | sourcePosition, 34 | targetPosition, 35 | markerStart, 36 | interactionWidth, 37 | }) => { 38 | useRebuildEdge(id); 39 | 40 | const isBackward = isConnectionBackward({ 41 | source: { 42 | id, 43 | x: sourceX, 44 | y: sourceY, 45 | position: sourcePosition, 46 | }, 47 | target: { 48 | id, 49 | x: targetX, 50 | y: targetY, 51 | position: targetPosition, 52 | }, 53 | }); 54 | 55 | const { color, edgeType, pathType } = getEdgeStyles({ id, isBackward }); 56 | 57 | const edge = kReactflow.instance!.getEdge(id)! as ReactflowEdgeWithData; 58 | 59 | const offset = 20; 60 | const borderRadius = 12; 61 | const handlerWidth = 24; 62 | const handlerThickness = 6; 63 | 64 | edge.data!.layout = layoutEdge({ 65 | layout: edge.data!.layout, 66 | id, 67 | offset, 68 | borderRadius, 69 | pathType, 70 | source, 71 | target, 72 | sourceX, 73 | sourceY, 74 | targetX, 75 | targetY, 76 | sourcePosition, 77 | targetPosition, 78 | }); 79 | 80 | const { path, points, labelPosition } = edge.data!.layout; 81 | 82 | return ( 83 | <> 84 | <_BaseEdge 85 | path={path} 86 | labelX={labelPosition.x} 87 | labelY={labelPosition.y} 88 | label={label} 89 | labelStyle={labelStyle} 90 | labelShowBg={labelShowBg} 91 | labelBgStyle={labelBgStyle} 92 | labelBgPadding={labelBgPadding} 93 | labelBgBorderRadius={labelBgBorderRadius} 94 | style={{ 95 | ...style, 96 | stroke: color, 97 | opacity: selected ? 1 : 0.5, 98 | strokeWidth: selected ? 2 : 1.5, 99 | strokeDasharray: edgeType === "dashed" ? "10,10" : undefined, 100 | }} 101 | markerEnd={`url('#${color.replace("#", "")}')`} 102 | markerStart={markerStart} 103 | interactionWidth={interactionWidth} 104 | /> 105 | {selected && ( 106 | 115 | )} 116 | 117 | ); 118 | } 119 | ); 120 | 121 | BaseEdge.displayName = "BaseEdge"; 122 | -------------------------------------------------------------------------------- /src/components/Edges/BaseEdge/useRebuildEdge.tsx: -------------------------------------------------------------------------------- 1 | import { useXState, XSta } from "xsta"; 2 | 3 | export const rebuildEdge = (id: string) => { 4 | XSta.set("rebuildEdge-" + id, (e: any) => !e); 5 | }; 6 | 7 | export const useRebuildEdge = (id: string) => { 8 | useXState("rebuildEdge-" + id, false); 9 | }; 10 | -------------------------------------------------------------------------------- /src/components/Edges/EdgeController/index.tsx: -------------------------------------------------------------------------------- 1 | import { EdgeLabelRenderer, Position } from "@xyflow/react"; 2 | 3 | import { getLineCenter, ILine } from "@/layout/edge/edge"; 4 | import { ControlPoint } from "@/layout/edge/point"; 5 | import { kReactflow } from "@/states/reactflow"; 6 | import { uuid } from "@/utils/uuid"; 7 | import { getEdgeContext, SmartEdge } from "./smart-edge"; 8 | import { useEdgeDraggable } from "./useEdgeDraggable"; 9 | 10 | export interface EdgeControllersParams { 11 | id: string; 12 | points: ControlPoint[]; 13 | sourcePosition: Position; 14 | targetPosition: Position; 15 | offset: number; 16 | handlerWidth: number; 17 | handlerThickness: number; 18 | } 19 | 20 | export const EdgeControllers = (props: EdgeControllersParams) => { 21 | const { points } = props; 22 | const edges: ILine[] = []; 23 | for (let i = 0; i < points.length - 1; i++) { 24 | edges.push({ start: points[i], end: points[i + 1] }); 25 | } 26 | const edgeContext = getEdgeContext(props); 27 | const smartEdges = edges.map((e, idx) => { 28 | return new SmartEdge({ idx, start: e.start, end: e.end, ctx: edgeContext }); 29 | }); 30 | smartEdges.forEach((e, idx) => { 31 | e.previous = smartEdges[idx - 2]; 32 | e.next = smartEdges[idx + 2]; 33 | }); 34 | 35 | return ( 36 | <> 37 | {edges.map((_, idx) => { 38 | const edge = smartEdges[idx]; 39 | return edge.canDrag && ; // use uuid to force rebuild EdgeController 40 | })} 41 | 42 | ); 43 | }; 44 | 45 | export const EdgeController = ({ edge }: { edge: SmartEdge }) => { 46 | const { start, end, onDragging } = edge; 47 | const { handlerWidth, handlerThickness } = edge.ctx; 48 | const center = getLineCenter(start, end); 49 | const isHorizontal = start.y === end.y; 50 | 51 | const { dragRef } = useEdgeDraggable({ 52 | edge, 53 | onDragging(dragId, dragFrom, position, delta) { 54 | const oldFlowPosition = kReactflow.instance!.screenToFlowPosition({ 55 | x: position.x - delta.x, 56 | y: position.y - delta.y, 57 | }); 58 | const newFlowPosition = 59 | kReactflow.instance!.screenToFlowPosition(position); 60 | const flowDelta = { 61 | x: newFlowPosition.x - oldFlowPosition.x, 62 | y: newFlowPosition.y - oldFlowPosition.y, 63 | }; 64 | const newStart = { ...start }; 65 | const newEnd = { ...end }; 66 | if (isHorizontal) { 67 | newStart.y += flowDelta.y; 68 | newEnd.y += flowDelta.y; 69 | } else { 70 | newStart.x += flowDelta.x; 71 | newEnd.x += flowDelta.x; 72 | } 73 | onDragging({ 74 | dragId, 75 | dragFrom, 76 | from: { start, end }, 77 | to: { start: newStart, end: newEnd }, 78 | }); 79 | }, 80 | }); 81 | 82 | return ( 83 | 84 |
99 | 100 | ); 101 | }; 102 | -------------------------------------------------------------------------------- /src/components/Edges/EdgeController/smart-edge.ts: -------------------------------------------------------------------------------- 1 | import { uuid } from "@/utils/uuid"; 2 | 3 | import { ReactflowEdgeWithData } from "@/data/types"; 4 | import { 5 | areLinesReverseDirection, 6 | distance, 7 | ILine, 8 | isHorizontalFromPosition, 9 | isLineContainsPoint, 10 | } from "@/layout/edge/edge"; 11 | import { 12 | ControlPoint, 13 | getOffsetPoint, 14 | reducePoints, 15 | } from "@/layout/edge/point"; 16 | import { kReactflow } from "@/states/reactflow"; 17 | import { EdgeControllersParams } from "."; 18 | import { rebuildEdge } from "../BaseEdge/useRebuildEdge"; 19 | 20 | interface EdgeContext extends EdgeControllersParams { 21 | source: ControlPoint; 22 | target: ControlPoint; 23 | sourceOffset: ControlPoint; 24 | targetOffset: ControlPoint; 25 | } 26 | 27 | export const getEdgeContext = (props: EdgeControllersParams): EdgeContext => { 28 | const { points, offset, sourcePosition, targetPosition } = props; 29 | const source = points[0]; 30 | const target = points[points.length - 1]; 31 | const sourceOffset = getOffsetPoint( 32 | { ...source, position: sourcePosition }, 33 | offset 34 | ); 35 | const targetOffset = getOffsetPoint( 36 | { ...target, position: targetPosition }, 37 | offset 38 | ); 39 | return { ...props, source, target, sourceOffset, targetOffset }; 40 | }; 41 | 42 | export class SmartEdge { 43 | static draggingEdge?: { 44 | dragId: string; 45 | dragFrom?: string; 46 | target?: { 47 | start: ControlPoint; 48 | end: ControlPoint; 49 | }; 50 | start: ControlPoint; 51 | end: ControlPoint; 52 | }; 53 | 54 | public idx: number; 55 | public start: ControlPoint; 56 | public end: ControlPoint; 57 | public distance: number; 58 | public ctx: EdgeContext; 59 | 60 | public previous?: SmartEdge; 61 | public next?: SmartEdge; 62 | 63 | constructor(options: { 64 | idx: number; 65 | start: ControlPoint; 66 | end: ControlPoint; 67 | ctx: EdgeContext; 68 | previous?: SmartEdge; 69 | next?: SmartEdge; 70 | }) { 71 | this.idx = options.idx; 72 | this.start = options.start; 73 | this.end = options.end; 74 | this.ctx = options.ctx; 75 | this.previous = options.previous; 76 | this.next = options.next; 77 | this.distance = distance(this.start, this.end); 78 | } 79 | 80 | get isHorizontalLayout() { 81 | return isHorizontalFromPosition(this.ctx.sourcePosition); 82 | } 83 | 84 | get isHorizontalLine() { 85 | return this.start.y === this.end.y; 86 | } 87 | 88 | get isSource() { 89 | return this.idx === 0; 90 | } 91 | 92 | get isSourceOffset() { 93 | return this.idx === 1; 94 | } 95 | 96 | get isTarget() { 97 | return this.idx === this.ctx.points.length - 2; 98 | } 99 | 100 | get isTargetOffset() { 101 | return this.idx === this.ctx.points.length - 3; 102 | } 103 | 104 | get isStartFixed() { 105 | return this.isSource || this.isSourceOffset; 106 | } 107 | 108 | get isEndFixed() { 109 | return this.isTarget || this.isTargetOffset; 110 | } 111 | 112 | get minHandlerWidth() { 113 | return this.ctx.handlerWidth + 2 * this.ctx.offset; 114 | } 115 | 116 | /** 117 | * Whether the edge can be dragged. 118 | */ 119 | get canDrag() { 120 | if (this.isStartFixed || this.isEndFixed) { 121 | // The connection lines on both ends of the node should not be dragged unless the edge can be split. 122 | return this.canSplit; 123 | } 124 | return this.distance >= this.minHandlerWidth; 125 | } 126 | 127 | /** 128 | * Whether the edge can be split. 129 | */ 130 | get canSplit() { 131 | return this.distance >= this.minHandlerWidth + 2 * this.ctx.offset; 132 | } 133 | 134 | /** 135 | * Splits the edge automatically when fixed endpoints exist at both ends. 136 | */ 137 | splitPoints( 138 | dragId: string, 139 | from: ILine, 140 | _to: ILine, 141 | minGap = 10 142 | ): ControlPoint[] | undefined { 143 | const startPoints = this.ctx.points.slice(0, this.idx + 1); 144 | const endPoints = this.ctx.points.slice(this.idx + 1); 145 | 146 | let to = { ..._to }; 147 | const isTempDraggingEdge = 148 | SmartEdge.draggingEdge?.dragId === dragId && 149 | SmartEdge.draggingEdge?.target; 150 | if (isTempDraggingEdge) { 151 | // Adjust the offset based on the previous dragging edge position. 152 | to = { 153 | start: { 154 | id: uuid(), 155 | x: 156 | SmartEdge.draggingEdge!.target!.start.x + 157 | (to.start.x - from.start.x), 158 | y: 159 | SmartEdge.draggingEdge!.target!.start.y + 160 | (to.start.y - from.start.y), 161 | }, 162 | end: { 163 | id: uuid(), 164 | x: SmartEdge.draggingEdge!.target!.end.x + (to.end.x - from.end.x), 165 | y: SmartEdge.draggingEdge!.target!.end.y + (to.end.y - from.end.y), 166 | }, 167 | }; 168 | } 169 | 170 | // Whether the edge needs to be split. 171 | let needSplit = false; 172 | // Whether to initiate the splitting of this edge. 173 | let startSplit = false; 174 | 175 | const sourceDelta = this.isHorizontalLine 176 | ? Math.abs(this.ctx.source.y - to.start.y) 177 | : Math.abs(this.ctx.source.x - to.start.x); 178 | const targetDelta = this.isHorizontalLine 179 | ? Math.abs(this.ctx.target.y - to.end.y) 180 | : Math.abs(this.ctx.target.x - to.end.x); 181 | const moveDelta = this.isHorizontalLine 182 | ? Math.abs(from.start.y - to.start.y) 183 | : Math.abs(from.start.x - to.start.x); 184 | 185 | if (this.isSource) { 186 | needSplit = true; 187 | if (sourceDelta > minGap) { 188 | startSplit = true; 189 | } 190 | } else if (this.isTarget) { 191 | needSplit = true; 192 | if (targetDelta > minGap) { 193 | startSplit = true; 194 | } 195 | } else { 196 | if (this.isSourceOffset && sourceDelta < this.ctx.offset) { 197 | needSplit = true; 198 | if (moveDelta > minGap) { 199 | startSplit = true; 200 | } 201 | } else if (this.isTargetOffset && targetDelta < this.ctx.offset) { 202 | needSplit = true; 203 | if (moveDelta > minGap) { 204 | startSplit = true; 205 | } 206 | } 207 | } 208 | 209 | if (!needSplit) { 210 | return; 211 | } 212 | 213 | const _offset = (distance(from.start, from.end) - this.minHandlerWidth) / 2; 214 | if (this.isHorizontalLine) { 215 | const direction = from.start.x < from.end.x ? 1 : -1; 216 | const offset = _offset * direction; 217 | if (!startSplit) { 218 | SmartEdge.draggingEdge = { 219 | dragId, 220 | start: from.start, 221 | end: from.end, 222 | target: { start: to.start, end: to.end }, 223 | }; 224 | return this.ctx.points; 225 | } 226 | SmartEdge.draggingEdge = { 227 | dragId, 228 | start: { id: uuid(), x: from.start.x + offset, y: to.start.y }, 229 | end: { id: uuid(), x: from.end.x - offset, y: to.start.y }, 230 | }; 231 | return [ 232 | ...startPoints, 233 | { id: uuid(), x: from.start.x + offset, y: from.start.y }, 234 | SmartEdge.draggingEdge.start, 235 | SmartEdge.draggingEdge.end, 236 | { id: uuid(), x: from.end.x - offset, y: from.start.y }, 237 | ...endPoints, 238 | ]; 239 | } else { 240 | const direction = from.start.y < from.end.y ? 1 : -1; 241 | const offset = _offset * direction; 242 | if (!startSplit) { 243 | SmartEdge.draggingEdge = { 244 | dragId, 245 | start: from.start, 246 | end: from.end, 247 | target: { start: to.start, end: to.end }, 248 | }; 249 | return this.ctx.points; 250 | } 251 | SmartEdge.draggingEdge = { 252 | dragId, 253 | start: { id: uuid(), x: to.start.x, y: from.start.y + offset }, 254 | end: { id: uuid(), x: to.start.x, y: from.end.y - offset }, 255 | }; 256 | return [ 257 | ...startPoints, 258 | { id: uuid(), x: from.start.x, y: from.start.y + offset }, 259 | SmartEdge.draggingEdge.start, 260 | SmartEdge.draggingEdge.end, 261 | { id: uuid(), x: from.start.x, y: from.end.y - offset }, 262 | ...endPoints, 263 | ]; 264 | } 265 | } 266 | 267 | /** 268 | * Merges endpoints of edges with close distances. 269 | */ 270 | mergePoints( 271 | dragId: string, 272 | from: ILine, 273 | to: ILine, 274 | minGap = 10 275 | ): ControlPoint[] | undefined { 276 | const startPoints = this.previous 277 | ? this.ctx.points.slice(0, this.previous.idx) 278 | : []; 279 | const endPoints = this.next 280 | ? this.ctx.points.slice(this.next.idx + 1 + 1) 281 | : []; 282 | if (this.isHorizontalLine) { 283 | const fromY = from.start.y; 284 | const toY = to.start.y; 285 | const preY = this.previous?.start.y; 286 | const nextY = this.next?.start.y; 287 | // Find the edge closest to the target coordinates 288 | const targetY = preY 289 | ? nextY 290 | ? Math.abs(toY - preY) < Math.abs(toY - nextY) 291 | ? preY 292 | : nextY 293 | : preY 294 | : nextY!; 295 | // Determine whether merging is necessary (1. Near adsorption objects 2. Close enough) 296 | const currentDistance = Math.abs(toY - targetY); 297 | const needMerge = 298 | Math.abs(fromY - targetY) > currentDistance && currentDistance < minGap; 299 | if (needMerge) { 300 | // Merge to new endpoint 301 | if (preY === nextY && preY === targetY) { 302 | // Previous, Current, Next merged into a straight line 303 | SmartEdge.draggingEdge = { 304 | dragId, 305 | start: this.previous!.start, 306 | end: this.next!.end, 307 | }; 308 | return [ 309 | ...startPoints, 310 | SmartEdge.draggingEdge.start, 311 | SmartEdge.draggingEdge.end, 312 | ...endPoints, 313 | ]; 314 | } else if (preY === targetY) { 315 | if (this.next) { 316 | // Previous, Current merged into a straight line 317 | SmartEdge.draggingEdge = { 318 | dragId, 319 | start: this.previous!.start, 320 | end: { id: uuid(), x: from.end.x, y: preY }, // New endpoint (projection) 321 | }; 322 | return [ 323 | ...startPoints, 324 | SmartEdge.draggingEdge.start, 325 | SmartEdge.draggingEdge.end, 326 | this.next!.start, 327 | this.next!.end, 328 | ...endPoints, 329 | ]; 330 | } else { 331 | // The next edge is empty 332 | SmartEdge.draggingEdge = { 333 | dragId, 334 | start: this.previous!.start, 335 | end: { id: uuid(), x: from.end.x, y: preY }, // New endpoint (projection) 336 | }; 337 | return [ 338 | ...startPoints, 339 | SmartEdge.draggingEdge.start, 340 | SmartEdge.draggingEdge.end, 341 | this.ctx.target, 342 | ...endPoints, 343 | ]; 344 | } 345 | } else { 346 | if (this.previous) { 347 | // Current, Next merged into a straight line 348 | SmartEdge.draggingEdge = { 349 | dragId, 350 | start: { id: uuid(), x: from.start.x, y: nextY! }, // New endpoint (projection) 351 | end: this.next!.end, 352 | }; 353 | return [ 354 | ...startPoints, 355 | this.previous!.start, 356 | this.previous!.end, 357 | SmartEdge.draggingEdge.start, 358 | SmartEdge.draggingEdge.end, 359 | ...endPoints, 360 | ]; 361 | } else { 362 | // The previous edge is empty 363 | SmartEdge.draggingEdge = { 364 | dragId, 365 | start: { id: uuid(), x: from.start.x, y: nextY! }, // New endpoint (projection) 366 | end: this.next!.end, 367 | }; 368 | return [ 369 | ...startPoints, 370 | this.ctx.source, 371 | SmartEdge.draggingEdge.start, 372 | SmartEdge.draggingEdge.end, 373 | ...endPoints, 374 | ]; 375 | } 376 | } 377 | } 378 | } else { 379 | const fromX = from.start.x; 380 | const toX = to.start.x; 381 | const preX = this.previous?.start.x; 382 | const nextX = this.next?.start.x; 383 | // Find the edge closest to the target coordinates 384 | const targetX = preX 385 | ? nextX 386 | ? Math.abs(toX - preX) < Math.abs(toX - nextX) 387 | ? preX 388 | : nextX 389 | : preX 390 | : nextX!; 391 | // Determine whether merging is necessary (1. Near adsorption objects 2. Close enough) 392 | const currentDistance = Math.abs(toX - targetX); 393 | const needMerge = 394 | Math.abs(fromX - targetX) > currentDistance && currentDistance < minGap; 395 | if (needMerge) { 396 | // Merge to new endpoint 397 | if (preX === nextX && preX === targetX) { 398 | // Previous, Current, Next merged into a straight line 399 | SmartEdge.draggingEdge = { 400 | dragId, 401 | start: this.previous!.start, 402 | end: this.next!.end, 403 | }; 404 | return [ 405 | ...startPoints, 406 | SmartEdge.draggingEdge.start, 407 | SmartEdge.draggingEdge.end, 408 | ...endPoints, 409 | ]; 410 | } else if (preX === targetX) { 411 | if (this.next) { 412 | // Previous, Current merged into a straight line 413 | SmartEdge.draggingEdge = { 414 | dragId, 415 | start: this.previous!.start, 416 | end: { id: uuid(), x: preX, y: from.end.y }, // New endpoint (projection) 417 | }; 418 | return [ 419 | ...startPoints, 420 | SmartEdge.draggingEdge.start, 421 | SmartEdge.draggingEdge.end, 422 | this.next!.start, 423 | this.next!.end, 424 | ...endPoints, 425 | ]; 426 | } else { 427 | // The next edge is empty 428 | SmartEdge.draggingEdge = { 429 | dragId, 430 | start: this.previous!.start, 431 | end: { id: uuid(), x: preX, y: from.end.y }, // New endpoint (projection) 432 | }; 433 | return [ 434 | ...startPoints, 435 | SmartEdge.draggingEdge.start, 436 | SmartEdge.draggingEdge.end, 437 | this.ctx.target, 438 | ...endPoints, 439 | ]; 440 | } 441 | } else { 442 | if (this.previous) { 443 | // Current, Next merged into a straight line 444 | SmartEdge.draggingEdge = { 445 | dragId, 446 | start: { id: uuid(), x: nextX!, y: from.start.y }, // New endpoint (projection) 447 | end: this.next!.end, 448 | }; 449 | return [ 450 | ...startPoints, 451 | this.previous!.start, 452 | this.previous!.end, 453 | SmartEdge.draggingEdge.start, 454 | SmartEdge.draggingEdge.end, 455 | ...endPoints, 456 | ]; 457 | } else { 458 | // The previous edge is empty 459 | SmartEdge.draggingEdge = { 460 | dragId, 461 | start: { id: uuid(), x: nextX!, y: from.start.y }, // New endpoint (projection) 462 | end: this.next!.end, 463 | }; 464 | return [ 465 | ...startPoints, 466 | this.ctx.source, 467 | SmartEdge.draggingEdge.start, 468 | SmartEdge.draggingEdge.end, 469 | ...endPoints, 470 | ]; 471 | } 472 | } 473 | } 474 | } 475 | } 476 | 477 | /** 478 | * Check whether the path is valid. 479 | * 480 | * 1. Invalid if there is an overlapping path. 481 | * 2. Invalid if it does not contain node offset endpoints. 482 | */ 483 | isValidPoints = (points: ControlPoint[]): boolean => { 484 | if (points.length < 4) { 485 | // Paths with less than 3 edges are always valid. 486 | return true; 487 | } 488 | const edges: ILine[] = [ 489 | { start: points[0], end: points[1] }, 490 | { start: points[1], end: points[2] }, 491 | { start: points[points.length - 3], end: points[points.length - 2] }, 492 | { start: points[points.length - 2], end: points[points.length - 1] }, 493 | ]; 494 | // Invalid if it does not contain node offset endpoints. 495 | if ( 496 | !isLineContainsPoint( 497 | edges[0].start, 498 | edges[0].end, 499 | this.ctx.sourceOffset 500 | ) || 501 | !isLineContainsPoint(edges[3].start, edges[3].end, this.ctx.targetOffset) 502 | ) { 503 | return false; 504 | } 505 | // Invalid if there is an overlapping path. 506 | if ( 507 | areLinesReverseDirection( 508 | edges[0].start, 509 | edges[0].end, 510 | edges[1].start, 511 | edges[1].end 512 | ) || 513 | areLinesReverseDirection( 514 | edges[2].start, 515 | edges[2].end, 516 | edges[3].start, 517 | edges[3].end 518 | ) 519 | ) { 520 | return false; 521 | } 522 | return true; 523 | }; 524 | 525 | rebuildEdge = (points: ControlPoint[]) => { 526 | const edge: ReactflowEdgeWithData = kReactflow.instance!.getEdge( 527 | this.ctx.id 528 | )!; 529 | edge.data!.layout!.points = reducePoints(points); 530 | rebuildEdge(this.ctx.id); 531 | }; 532 | 533 | onDragging = ({ 534 | dragId, 535 | from, 536 | to, 537 | }: { 538 | dragId: string; 539 | dragFrom: string; 540 | from: ILine; 541 | to: ILine; 542 | }) => { 543 | if (distance(from.start, to.start) < 0.00001) { 544 | // The drag distance is very small, only refresh 545 | return this.rebuildEdge(this.ctx.points); 546 | } 547 | // Automatically split edges if there are fixed endpoints at both ends 548 | if (this.isStartFixed || this.isEndFixed) { 549 | const splittedPoints = this.splitPoints(dragId, from, to); 550 | if (splittedPoints) { 551 | return this.rebuildEdge(splittedPoints); 552 | } 553 | } 554 | // Merge nearby edges 555 | const mergedPoints = this.mergePoints(dragId, from, to); 556 | if (mergedPoints && this.isValidPoints(mergedPoints)) { 557 | return this.rebuildEdge(mergedPoints); 558 | } 559 | // Update current edge coordinates 560 | const { x: targetX, y: targetY } = to.start; 561 | if (this.isHorizontalLine) { 562 | this.start.y = targetY; 563 | this.end.y = targetY; 564 | } else { 565 | this.start.x = targetX; 566 | this.end.x = targetX; 567 | } 568 | SmartEdge.draggingEdge = { dragId, start: this.start, end: this.end }; 569 | this.rebuildEdge(this.ctx.points); 570 | }; 571 | } 572 | -------------------------------------------------------------------------------- /src/components/Edges/EdgeController/useEdgeDraggable.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react-hooks/exhaustive-deps */ 2 | import { useEffect, useRef } from "react"; 3 | import { XYPosition } from "@xyflow/react"; 4 | import { XSta, useXState } from "xsta"; 5 | 6 | import { isEqualPoint } from "../../../layout/edge/point"; 7 | import { SmartEdge } from "./smart-edge"; 8 | 9 | interface UseDraggableParams { 10 | edge: SmartEdge; 11 | dragId?: string; 12 | onDragStart?: VoidFunction; 13 | onDragging?: ( 14 | dragId: string, 15 | dragFrom: string, 16 | position: XYPosition, 17 | delta: XYPosition 18 | ) => void; 19 | onDragEnd?: VoidFunction; 20 | } 21 | 22 | let _id = 0; 23 | export const useEdgeDraggable = (props: UseDraggableParams) => { 24 | const dragRef = useRef() as any; 25 | const propsRef = useRef(props); 26 | propsRef.current = props; 27 | 28 | const isDraggingEdge = () => 29 | SmartEdge.draggingEdge && 30 | isEqualPoint(SmartEdge.draggingEdge?.start, propsRef.current.edge.start) && 31 | isEqualPoint(SmartEdge.draggingEdge?.end, propsRef.current.edge.end); 32 | 33 | const dragFrom = useRef((_id++).toString()).current; 34 | const dragId = isDraggingEdge() ? SmartEdge.draggingEdge!.dragId : dragFrom; 35 | const isDraggingKey = "isDragging" + dragId; 36 | const startPositionKey = "startPositionKey-" + dragId; 37 | const [_, setIsDragging] = useXState(isDraggingKey, false); 38 | const [__, setStartPosition] = useXState(startPositionKey, [0, 0]); 39 | const getIsDragging = () => XSta.get(isDraggingKey); 40 | const getStartPosition = () => XSta.get(startPositionKey); 41 | 42 | useEffect(() => { 43 | return () => { 44 | if (SmartEdge.draggingEdge?.dragId !== dragId) { 45 | // dispose states 46 | XSta.delete(isDraggingKey); 47 | XSta.delete(startPositionKey); 48 | } 49 | }; 50 | }, []); 51 | 52 | const onDragStart = (event: MouseEvent) => { 53 | if (getIsDragging()) { 54 | return; 55 | } 56 | if (event.button !== 0) { 57 | // Not a left mouse button click event 58 | return; 59 | } 60 | if (event.target !== dragRef.current) { 61 | // Not clicked on the current element 62 | return; 63 | } 64 | SmartEdge.draggingEdge = { 65 | dragId, 66 | dragFrom, 67 | start: propsRef.current.edge.start, 68 | end: propsRef.current.edge.end, 69 | }; 70 | setIsDragging(true); 71 | setStartPosition([event.clientX, event.clientY]); 72 | propsRef.current.onDragStart?.(); 73 | }; 74 | 75 | const onDragEnd = () => { 76 | if (!getIsDragging()) { 77 | return; 78 | } 79 | SmartEdge.draggingEdge = undefined; 80 | setIsDragging(false); 81 | propsRef.current.onDragEnd?.(); 82 | }; 83 | 84 | const onDragging = (event: MouseEvent) => { 85 | if (!getIsDragging()) { 86 | return; 87 | } 88 | if (event.buttons !== 1) { 89 | // Ending drag because it's not a left mouse button drag event 90 | return onDragEnd(); 91 | } 92 | propsRef.current.onDragging?.( 93 | dragId, 94 | dragFrom, 95 | { 96 | x: event.clientX, 97 | y: event.clientY, 98 | }, 99 | { 100 | x: event.clientX - getStartPosition()[0], 101 | y: event.clientY - getStartPosition()[1], 102 | } 103 | ); 104 | setStartPosition([event.clientX, event.clientY]); 105 | }; 106 | 107 | useEffect(() => { 108 | document.addEventListener("mousedown", onDragStart); 109 | document.addEventListener("mousemove", onDragging); 110 | document.addEventListener("mouseup", onDragEnd); 111 | return () => { 112 | document.removeEventListener("mousedown", onDragStart); 113 | document.removeEventListener("mousemove", onDragging); 114 | document.removeEventListener("mouseup", onDragEnd); 115 | }; 116 | }, []); 117 | 118 | return { dragRef, isDragging: getIsDragging() }; 119 | }; 120 | -------------------------------------------------------------------------------- /src/components/Edges/Marker.tsx: -------------------------------------------------------------------------------- 1 | export const kBaseMarkerColor = "#000000"; 2 | export const kYesMarkerColor = "#64ba5e"; 3 | export const kNoMarkerColor = "#ff0000"; 4 | export const kBaseMarkerColors = ["#9b5de5", "#ff758f", "#ff9f1c", "#3579f6"]; 5 | export const kAllMarkerColors = [ 6 | kBaseMarkerColor, 7 | kYesMarkerColor, 8 | ...kBaseMarkerColors, 9 | ]; 10 | 11 | export const ColorfulMarkerDefinitions = () => { 12 | return ( 13 | 14 | 15 | {kAllMarkerColors.map((color) => ( 16 | 17 | ))} 18 | 19 | 20 | ); 21 | }; 22 | 23 | const Marker = ({ 24 | id, 25 | color, 26 | strokeWidth = 1, 27 | width = 12.5, 28 | height = 12.5, 29 | markerUnits = "strokeWidth", 30 | orient = "auto-start-reverse", 31 | }: any) => { 32 | return ( 33 | 43 | 53 | 54 | ); 55 | }; 56 | -------------------------------------------------------------------------------- /src/components/Edges/index.tsx: -------------------------------------------------------------------------------- 1 | import { EdgeTypes } from '@xyflow/react'; 2 | 3 | import { BaseEdge } from './BaseEdge'; 4 | 5 | export const kEdgeTypes: EdgeTypes = { 6 | base: BaseEdge, 7 | }; 8 | -------------------------------------------------------------------------------- /src/components/Nodes/BaseNode/index.tsx: -------------------------------------------------------------------------------- 1 | import "./styles.css"; 2 | 3 | import { ComponentType, memo } from "react"; 4 | import { Handle, NodeProps, Position } from "@xyflow/react"; 5 | 6 | import { ReactflowBaseNode } from "@/data/types"; 7 | import { kReactflowLayoutConfig } from "@/components/ControlPanel"; 8 | 9 | export const BaseNode: ComponentType> = memo( 10 | ({ data }) => { 11 | const { direction, reverseSourceHandles } = kReactflowLayoutConfig.state; 12 | const isHorizontal = direction === "horizontal"; 13 | const targetHandlesFlexDirection: any = isHorizontal ? "column" : "row"; 14 | const sourceHandlesFlexDirection: any = 15 | targetHandlesFlexDirection + (reverseSourceHandles ? "-reverse" : ""); 16 | return ( 17 | <> 18 |
24 | {data.targetHandles.map((id) => ( 25 | 32 | ))} 33 |
34 |
{data.id}
35 |
41 | {data.sourceHandles.map((id) => ( 42 | 49 | ))} 50 |
51 | 52 | ); 53 | } 54 | ); 55 | 56 | BaseNode.displayName = "BaseNode"; 57 | -------------------------------------------------------------------------------- /src/components/Nodes/BaseNode/styles.css: -------------------------------------------------------------------------------- 1 | .react-flow__node-base { 2 | background: #f8f8f8; 3 | border-radius: 4px; 4 | border: 1px solid rgba(0, 0, 0, 50%); 5 | } 6 | 7 | .react-flow__handle { 8 | position: relative; 9 | transform: none; 10 | top: auto; 11 | left: auto; 12 | } 13 | 14 | .handle { 15 | background: #224466; 16 | width: 8px; 17 | height: 8px; 18 | } 19 | 20 | .handle-horizontal { 21 | top: auto; 22 | } 23 | 24 | .handle-vertical { 25 | left: auto; 26 | } 27 | 28 | .label { 29 | max-width: 240px; 30 | padding: 16px 32px; 31 | font-size: 12px; 32 | text-align: center; 33 | word-break: break-all; 34 | } 35 | 36 | .label { 37 | flex-grow: 1; 38 | } 39 | 40 | .handles { 41 | display: flex; 42 | position: absolute; 43 | justify-content: space-around; 44 | } 45 | 46 | .handles-horizontal { 47 | width: 10px; 48 | height: 100%; 49 | } 50 | 51 | .handles-vertical { 52 | width: 100%; 53 | height: 10px; 54 | } 55 | 56 | .handles-horizontal.targets { 57 | left: -4px; 58 | top: 0px; 59 | } 60 | 61 | .handles-horizontal.sources { 62 | right: -2px; 63 | top: 0px; 64 | } 65 | 66 | .handles-vertical.targets { 67 | top: -4px; 68 | left: 0px; 69 | } 70 | 71 | .handles-vertical.sources { 72 | bottom: -2px; 73 | left: 0px; 74 | } 75 | -------------------------------------------------------------------------------- /src/components/Nodes/index.tsx: -------------------------------------------------------------------------------- 1 | import { NodeTypes } from '@xyflow/react'; 2 | import { BaseNode } from './BaseNode'; 3 | 4 | export const kNodeTypes: NodeTypes = { 5 | base: BaseNode, 6 | }; 7 | -------------------------------------------------------------------------------- /src/components/ReactflowInstance.tsx: -------------------------------------------------------------------------------- 1 | import { useReactFlow, useStoreApi } from "@xyflow/react"; 2 | 3 | import { kReactflow } from "../states/reactflow"; 4 | 5 | // Used to mount onto the ReactFlow component to get the corresponding ReactFlowInstance 6 | export const ReactflowInstance = (): any => { 7 | kReactflow.instance = useReactFlow(); 8 | kReactflow.store = useStoreApi(); 9 | return undefined; 10 | }; 11 | -------------------------------------------------------------------------------- /src/data/convert.ts: -------------------------------------------------------------------------------- 1 | import { lastOf } from "@/utils/base"; 2 | import { Reactflow, Workflow } from "./types"; 3 | 4 | export const workflow2reactflow = (workflow: Workflow): Reactflow => { 5 | const { nodes = [], edges = [] } = workflow ?? {}; 6 | const edgesCount: Record = {}; 7 | const edgesIndex: Record = {}; 8 | const nodeHandles: Record< 9 | string, 10 | { 11 | sourceHandles: Record; 12 | targetHandles: Record; 13 | } 14 | > = {}; 15 | 16 | for (const edge of edges) { 17 | const { source, target, sourceHandle, targetHandle } = edge; 18 | if (!edgesCount[sourceHandle]) { 19 | edgesCount[sourceHandle] = 1; 20 | } else { 21 | edgesCount[sourceHandle] += 1; 22 | } 23 | if (!edgesCount[targetHandle]) { 24 | edgesCount[targetHandle] = 1; 25 | } else { 26 | edgesCount[targetHandle] += 1; 27 | } 28 | if (!edgesCount[`source-${source}`]) { 29 | edgesCount[`source-${source}`] = 1; 30 | } else { 31 | edgesCount[`source-${source}`] += 1; 32 | } 33 | if (!edgesCount[`target-${target}`]) { 34 | edgesCount[`target-${target}`] = 1; 35 | } else { 36 | edgesCount[`target-${target}`] += 1; 37 | } 38 | edgesIndex[edge.id] = { 39 | source: edgesCount[sourceHandle] - 1, 40 | target: edgesCount[targetHandle] - 1, 41 | }; 42 | if (!nodeHandles[source]) { 43 | nodeHandles[source] = { sourceHandles: {}, targetHandles: {} }; 44 | } 45 | if (!nodeHandles[target]) { 46 | nodeHandles[target] = { sourceHandles: {}, targetHandles: {} }; 47 | } 48 | if (!nodeHandles[source].sourceHandles[sourceHandle]) { 49 | nodeHandles[source].sourceHandles[sourceHandle] = 1; 50 | } else { 51 | nodeHandles[source].sourceHandles[sourceHandle] += 1; 52 | } 53 | if (!nodeHandles[target].targetHandles[targetHandle]) { 54 | nodeHandles[target].targetHandles[targetHandle] = 1; 55 | } else { 56 | nodeHandles[target].targetHandles[targetHandle] += 1; 57 | } 58 | } 59 | 60 | return { 61 | nodes: nodes.map((node) => ({ 62 | ...node, 63 | data: { 64 | ...node, 65 | sourceHandles: Object.keys(nodeHandles[node.id].sourceHandles) ?? [], 66 | targetHandles: Object.keys(nodeHandles[node.id].targetHandles) ?? [], 67 | }, 68 | position: { x: 0, y: 0 }, 69 | })), 70 | edges: edges.map((edge) => ({ 71 | ...edge, 72 | data: { 73 | sourcePort: { 74 | edges: edgesCount[`source-${edge.source}`], 75 | portIndex: parseInt(lastOf(edge.sourceHandle.split("#"))!, 10), 76 | portCount: Object.keys(nodeHandles[edge.source].sourceHandles).length, 77 | edgeIndex: edgesIndex[edge.id].source, 78 | edgeCount: edgesCount[edge.sourceHandle], 79 | }, 80 | targetPort: { 81 | edges: edgesCount[`target-${edge.target}`], 82 | portIndex: parseInt(lastOf(edge.targetHandle.split("#"))!, 10), 83 | portCount: Object.keys(nodeHandles[edge.target].targetHandles).length, 84 | edgeIndex: edgesIndex[edge.id].target, 85 | edgeCount: edgesCount[edge.targetHandle], 86 | }, 87 | }, 88 | })), 89 | }; 90 | }; 91 | -------------------------------------------------------------------------------- /src/data/data.json: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": [ 3 | { 4 | "id": "A", 5 | "type": "base" 6 | }, 7 | { 8 | "id": "B", 9 | "type": "base" 10 | }, 11 | { 12 | "id": "C", 13 | "type": "base" 14 | }, 15 | { 16 | "id": "D", 17 | "type": "base" 18 | }, 19 | { 20 | "id": "E", 21 | "type": "base" 22 | }, 23 | { 24 | "id": "F", 25 | "type": "base" 26 | }, 27 | { 28 | "id": "G", 29 | "type": "base" 30 | } 31 | ], 32 | "edges": [ 33 | { 34 | "id": "A#B#0", 35 | "source": "A", 36 | "target": "B", 37 | "sourceHandle": "A#source#0", 38 | "targetHandle": "B#target#0" 39 | }, 40 | { 41 | "id": "A#D#1", 42 | "source": "A", 43 | "target": "D", 44 | "sourceHandle": "A#source#1", 45 | "targetHandle": "D#target#0" 46 | }, 47 | { 48 | "id": "A#C#2", 49 | "source": "A", 50 | "target": "C", 51 | "sourceHandle": "A#source#2", 52 | "targetHandle": "C#target#0" 53 | }, 54 | { 55 | "id": "B#E#0", 56 | "source": "B", 57 | "target": "E", 58 | "sourceHandle": "B#source#0", 59 | "targetHandle": "E#target#0" 60 | }, 61 | { 62 | "id": "D#E#0", 63 | "source": "D", 64 | "target": "E", 65 | "sourceHandle": "D#source#0", 66 | "targetHandle": "E#target#0" 67 | }, 68 | { 69 | "id": "E#F#0", 70 | "source": "E", 71 | "target": "F", 72 | "sourceHandle": "E#source#0", 73 | "targetHandle": "F#target#0" 74 | }, 75 | { 76 | "id": "E#G#1", 77 | "source": "E", 78 | "target": "G", 79 | "sourceHandle": "E#source#0", 80 | "targetHandle": "G#target#0" 81 | } 82 | ] 83 | } 84 | -------------------------------------------------------------------------------- /src/data/types.ts: -------------------------------------------------------------------------------- 1 | import { Edge, Node, XYPosition } from "@xyflow/react"; 2 | 3 | import { ControlPoint } from "../layout/edge/point"; 4 | 5 | interface WorkflowNode extends Record { 6 | id: string; 7 | type: string; 8 | } 9 | 10 | interface WorkflowEdge { 11 | id: string; 12 | source: string; 13 | target: string; 14 | sourceHandle: string; 15 | targetHandle: string; 16 | } 17 | 18 | export interface Workflow { 19 | nodes: WorkflowNode[]; 20 | edges: WorkflowEdge[]; 21 | } 22 | 23 | export type ReactflowNodeData = WorkflowNode & { 24 | /** 25 | * The output ports of the current node. 26 | * 27 | * Format of Port ID: `nodeID#source#idx` 28 | */ 29 | sourceHandles: string[]; 30 | /** 31 | * The input port of the current node (only one). 32 | * 33 | * Format of Port ID: `nodeID#target#idx` 34 | */ 35 | targetHandles: string[]; 36 | }; 37 | 38 | export interface ReactflowEdgePort { 39 | /** 40 | * Total number of edges in this direction (source or target). 41 | */ 42 | edges: number; 43 | /** 44 | * Number of ports 45 | */ 46 | portCount: number; 47 | /** 48 | * Port's index. 49 | */ 50 | portIndex: number; 51 | /** 52 | * Total number of Edges under the current port. 53 | */ 54 | edgeCount: number; 55 | /** 56 | * Index of the Edge under the current port. 57 | */ 58 | edgeIndex: number; 59 | } 60 | 61 | export interface EdgeLayout { 62 | /** 63 | * SVG path for edge rendering 64 | */ 65 | path: string; 66 | /** 67 | * Control points on the edge. 68 | */ 69 | points: ControlPoint[]; 70 | labelPosition: XYPosition; 71 | /** 72 | * Current layout dependent variables (re-layout when changed). 73 | */ 74 | deps?: any; 75 | /** 76 | * Potential control points on the edge, for debugging purposes only. 77 | */ 78 | inputPoints: ControlPoint[]; 79 | } 80 | 81 | export interface ReactflowEdgeData extends Record { 82 | /** 83 | * Data related to the current edge's layout, such as control points. 84 | */ 85 | layout?: EdgeLayout; 86 | sourcePort: ReactflowEdgePort; 87 | targetPort: ReactflowEdgePort; 88 | } 89 | 90 | export type ReactflowBaseNode = Node; 91 | export type ReactflowStartNode = Node; 92 | export type ReactflowNodeWithData = ReactflowBaseNode | ReactflowStartNode; 93 | 94 | export type ReactflowBaseEdge = Edge; 95 | export type ReactflowEdgeWithData = ReactflowBaseEdge; 96 | 97 | export interface Reactflow { 98 | nodes: ReactflowNodeWithData[]; 99 | edges: ReactflowEdgeWithData[]; 100 | } 101 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | color: #213547; 6 | background-color: #ffffff; 7 | font-synthesis: none; 8 | text-rendering: optimizeLegibility; 9 | -webkit-font-smoothing: antialiased; 10 | -moz-osx-font-smoothing: grayscale; 11 | } 12 | 13 | body { 14 | margin: 0; 15 | display: flex; 16 | place-items: center; 17 | } -------------------------------------------------------------------------------- /src/layout/edge/algorithms/a-star.ts: -------------------------------------------------------------------------------- 1 | import { areLinesReverseDirection, areLinesSameDirection } from "../edge"; 2 | import { 3 | ControlPoint, 4 | NodeRect, 5 | isEqualPoint, 6 | isSegmentCrossingRect, 7 | } from "../point"; 8 | 9 | interface GetAStarPathParams { 10 | /** 11 | * Collection of potential control points between `sourceOffset` and `targetOffset`, excluding the `source` and `target` points. 12 | */ 13 | points: ControlPoint[]; 14 | source: ControlPoint; 15 | target: ControlPoint; 16 | /** 17 | * Node size information for the `source` and `target`, used to optimize edge routing without intersecting nodes. 18 | */ 19 | sourceRect: NodeRect; 20 | targetRect: NodeRect; 21 | } 22 | 23 | /** 24 | * Utilizes the [A\* search algorithm](https://en.wikipedia.org/wiki/A*_search_algorithm) combined with 25 | * [Manhattan Distance](https://simple.wikipedia.org/wiki/Manhattan_distance) to find the optimal path for edges. 26 | * 27 | * @returns Control points including sourceOffset and targetOffset (not including source and target points). 28 | */ 29 | export const getAStarPath = ({ 30 | points, 31 | source, 32 | target, 33 | sourceRect, 34 | targetRect, 35 | }: GetAStarPathParams): ControlPoint[] => { 36 | if (points.length < 3) { 37 | return points; 38 | } 39 | const start = points[0]; 40 | const end = points[points.length - 1]; 41 | const openSet: ControlPoint[] = [start]; 42 | const closedSet: Set = new Set(); 43 | const cameFrom: Map = new Map(); 44 | const gScore: Map = new Map().set(start, 0); 45 | const fScore: Map = new Map().set( 46 | start, 47 | heuristicCostEstimate({ 48 | from: start, 49 | to: start, 50 | start, 51 | end, 52 | source, 53 | target, 54 | }) 55 | ); 56 | 57 | while (openSet.length) { 58 | let current; 59 | let currentIdx; 60 | let lowestFScore = Infinity; 61 | openSet.forEach((p, idx) => { 62 | const score = fScore.get(p) ?? 0; 63 | if (score < lowestFScore) { 64 | lowestFScore = score; 65 | current = p; 66 | currentIdx = idx; 67 | } 68 | }); 69 | 70 | if (!current) { 71 | break; 72 | } 73 | 74 | if (current === end) { 75 | return buildPath(cameFrom, current); 76 | } 77 | 78 | openSet.splice(currentIdx!, 1); 79 | closedSet.add(current); 80 | 81 | const curFScore = fScore.get(current) ?? 0; 82 | const previous = cameFrom.get(current); 83 | const neighbors = getNextNeighborPoints({ 84 | points, 85 | previous, 86 | current, 87 | sourceRect, 88 | targetRect, 89 | }); 90 | for (const neighbor of neighbors) { 91 | if (closedSet.has(neighbor)) { 92 | continue; 93 | } 94 | const neighborGScore = gScore.get(neighbor) ?? 0; 95 | const tentativeGScore = curFScore + estimateDistance(current, neighbor); 96 | if (openSet.includes(neighbor) && tentativeGScore >= neighborGScore) { 97 | continue; 98 | } 99 | openSet.push(neighbor); 100 | cameFrom.set(neighbor, current); 101 | gScore.set(neighbor, tentativeGScore); 102 | fScore.set( 103 | neighbor, 104 | neighborGScore + 105 | heuristicCostEstimate({ 106 | from: current, 107 | to: neighbor, 108 | start, 109 | end, 110 | source, 111 | target, 112 | }) 113 | ); 114 | } 115 | } 116 | return [start, end]; 117 | }; 118 | 119 | const buildPath = ( 120 | cameFrom: Map, 121 | current: ControlPoint 122 | ): ControlPoint[] => { 123 | const path = [current]; 124 | 125 | let previous = cameFrom.get(current); 126 | while (previous) { 127 | path.push(previous); 128 | previous = cameFrom.get(previous); 129 | } 130 | 131 | return path.reverse(); 132 | }; 133 | 134 | interface GetNextNeighborPointsParams { 135 | points: ControlPoint[]; 136 | previous?: ControlPoint; 137 | current: ControlPoint; 138 | sourceRect: NodeRect; 139 | targetRect: NodeRect; 140 | } 141 | 142 | /** 143 | * Get the set of possible neighboring points for the current control point 144 | * 145 | * - The line is in a horizontal or vertical direction 146 | * - The line does not intersect with the two end nodes 147 | * - The line does not overlap with the previous line segment in reverse direction 148 | */ 149 | export const getNextNeighborPoints = ({ 150 | points, 151 | previous, 152 | current, 153 | sourceRect, 154 | targetRect, 155 | }: GetNextNeighborPointsParams): ControlPoint[] => { 156 | return points.filter((p) => { 157 | if (p === current) { 158 | return false; 159 | } 160 | // The connection is in the horizontal or vertical direction 161 | const rightDirection = p.x === current.x || p.y === current.y; 162 | // Reverse direction with the previous line segment (overlap) 163 | const reverseDirection = previous 164 | ? areLinesReverseDirection(previous, current, current, p) 165 | : false; 166 | return ( 167 | rightDirection && // The line is in a horizontal or vertical direction 168 | !reverseDirection && // The line does not overlap with the previous line segment in reverse direction 169 | !isSegmentCrossingRect(p, current, sourceRect) && // Does not intersect with sourceNode 170 | !isSegmentCrossingRect(p, current, targetRect) // Does not intersect with targetNode 171 | ); 172 | }); 173 | }; 174 | 175 | interface HeuristicCostParams { 176 | from: ControlPoint; 177 | to: ControlPoint; 178 | start: ControlPoint; 179 | end: ControlPoint; 180 | source: ControlPoint; 181 | target: ControlPoint; 182 | } 183 | 184 | /** 185 | * Connection point distance loss function 186 | * 187 | * - The smaller the sum of distances, the better 188 | * - The closer the start and end line segments are in direction, the better 189 | * - The better the inflection point is symmetric or centered in the line segment 190 | */ 191 | const heuristicCostEstimate = ({ 192 | from, 193 | to, 194 | start, 195 | end, 196 | source, 197 | target, 198 | }: HeuristicCostParams): number => { 199 | const base = estimateDistance(to, start) + estimateDistance(to, end); 200 | const startCost = isEqualPoint(from, start) 201 | ? areLinesSameDirection(from, to, source, start) 202 | ? -base / 2 203 | : 0 204 | : 0; 205 | const endCost = isEqualPoint(to, end) 206 | ? areLinesSameDirection(from, to, end, target) 207 | ? -base / 2 208 | : 0 209 | : 0; 210 | return base + startCost + endCost; 211 | }; 212 | 213 | /** 214 | * Calculate the estimated distance between two points 215 | * 216 | * Manhattan distance: the sum of horizontal and vertical distances, faster calculation speed 217 | */ 218 | const estimateDistance = (p1: ControlPoint, p2: ControlPoint): number => 219 | Math.abs(p1.x - p2.x) + Math.abs(p1.y - p2.y); 220 | -------------------------------------------------------------------------------- /src/layout/edge/algorithms/index.ts: -------------------------------------------------------------------------------- 1 | import { areLinesSameDirection, isHorizontalFromPosition } from "../edge"; 2 | import { 3 | ControlPoint, 4 | HandlePosition, 5 | NodeRect, 6 | getCenterPoints, 7 | getExpandedRect, 8 | getOffsetPoint, 9 | getSidesFromPoints, 10 | getVerticesFromRectVertex, 11 | optimizeInputPoints, 12 | reducePoints, 13 | } from "../point"; 14 | import { getAStarPath } from "./a-star"; 15 | import { getSimplePath } from "./simple"; 16 | 17 | export interface GetControlPointsParams { 18 | source: HandlePosition; 19 | target: HandlePosition; 20 | sourceRect: NodeRect; 21 | targetRect: NodeRect; 22 | /** 23 | * Minimum spacing between edges and nodes 24 | */ 25 | offset: number; 26 | } 27 | 28 | /** 29 | * Calculate control points on the optimal path of an edge. 30 | * 31 | * Reference article: https://juejin.cn/post/6942727734518874142 32 | */ 33 | export const getControlPoints = ({ 34 | source: oldSource, 35 | target: oldTarget, 36 | sourceRect, 37 | targetRect, 38 | offset = 20, 39 | }: GetControlPointsParams) => { 40 | const source: ControlPoint = oldSource; 41 | const target: ControlPoint = oldTarget; 42 | let edgePoints: ControlPoint[] = []; 43 | let optimized: ReturnType; 44 | 45 | // 1. Find the starting and ending points after applying the offset 46 | const sourceOffset = getOffsetPoint(oldSource, offset); 47 | const targetOffset = getOffsetPoint(oldTarget, offset); 48 | const expandedSource = getExpandedRect(sourceRect, offset); 49 | const expandedTarget = getExpandedRect(targetRect, offset); 50 | 51 | // 2. Determine if the two Rects are relatively close or should directly connected 52 | const minOffset = 2 * offset + 10; 53 | const isHorizontalLayout = isHorizontalFromPosition(oldSource.position); 54 | const isSameDirection = areLinesSameDirection( 55 | source, 56 | sourceOffset, 57 | targetOffset, 58 | target 59 | ); 60 | const sides = getSidesFromPoints([ 61 | source, 62 | target, 63 | sourceOffset, 64 | targetOffset, 65 | ]); 66 | const isTooClose = isHorizontalLayout 67 | ? sides.right - sides.left < minOffset 68 | : sides.bottom - sides.top < minOffset; 69 | const isDirectConnect = isHorizontalLayout 70 | ? isSameDirection && source.x < target.x 71 | : isSameDirection && source.y < target.y; 72 | 73 | if (isTooClose || isDirectConnect) { 74 | // 3. If the two Rects are relatively close or directly connected, return a simple Path 75 | edgePoints = getSimplePath({ 76 | source, 77 | target, 78 | sourceOffset, 79 | targetOffset, 80 | isDirectConnect, 81 | }); 82 | optimized = optimizeInputPoints({ 83 | source: oldSource, 84 | target: oldTarget, 85 | sourceOffset, 86 | targetOffset, 87 | edgePoints, 88 | }); 89 | edgePoints = optimized.edgePoints; 90 | } else { 91 | // 3. Find the vertices of the two expanded Rects 92 | edgePoints = [ 93 | ...getVerticesFromRectVertex(expandedSource, targetOffset), 94 | ...getVerticesFromRectVertex(expandedTarget, sourceOffset), 95 | ]; 96 | // 4. Find possible midpoints and intersections 97 | edgePoints = edgePoints.concat( 98 | getCenterPoints({ 99 | source: expandedSource, 100 | target: expandedTarget, 101 | sourceOffset, 102 | targetOffset, 103 | }) 104 | ); 105 | // 5. Merge nearby coordinate points and remove duplicate coordinate points 106 | optimized = optimizeInputPoints({ 107 | source: oldSource, 108 | target: oldTarget, 109 | sourceOffset, 110 | targetOffset, 111 | edgePoints, 112 | }); 113 | // 6. Find the optimal path 114 | edgePoints = getAStarPath({ 115 | points: optimized.edgePoints, 116 | source: optimized.source, 117 | target: optimized.target, 118 | sourceRect: getExpandedRect(sourceRect, offset / 2), 119 | targetRect: getExpandedRect(targetRect, offset / 2), 120 | }); 121 | } 122 | 123 | return { 124 | points: reducePoints([optimized.source, ...edgePoints, optimized.target]), 125 | inputPoints: optimized.edgePoints, 126 | }; 127 | }; 128 | -------------------------------------------------------------------------------- /src/layout/edge/algorithms/simple.ts: -------------------------------------------------------------------------------- 1 | import { uuid } from "@/utils/uuid"; 2 | 3 | import { LayoutDirection } from "../../node"; 4 | import { ControlPoint, isInLine, isOnLine } from "../point"; 5 | 6 | interface GetSimplePathParams { 7 | isDirectConnect?: boolean; 8 | source: ControlPoint; 9 | target: ControlPoint; 10 | sourceOffset: ControlPoint; 11 | targetOffset: ControlPoint; 12 | } 13 | 14 | const getLineDirection = ( 15 | start: ControlPoint, 16 | end: ControlPoint 17 | ): LayoutDirection => (start.x === end.x ? "vertical" : "horizontal"); 18 | 19 | /** 20 | * When two nodes are too close, use the simple path 21 | * 22 | * @returns Control points including sourceOffset and targetOffset (not including source and target points). 23 | */ 24 | export const getSimplePath = ({ 25 | isDirectConnect, 26 | source, 27 | target, 28 | sourceOffset, 29 | targetOffset, 30 | }: GetSimplePathParams): ControlPoint[] => { 31 | const points: ControlPoint[] = []; 32 | const sourceDirection = getLineDirection(source, sourceOffset); 33 | const targetDirection = getLineDirection(target, targetOffset); 34 | const isHorizontalLayout = sourceDirection === "horizontal"; 35 | if (isDirectConnect) { 36 | // Direct connection, return a simple Path 37 | if (isHorizontalLayout) { 38 | if (sourceOffset.x <= targetOffset.x) { 39 | const centerX = (sourceOffset.x + targetOffset.x) / 2; 40 | return [ 41 | { id: uuid(), x: centerX, y: sourceOffset.y }, 42 | { id: uuid(), x: centerX, y: targetOffset.y }, 43 | ]; 44 | } else { 45 | const centerY = (sourceOffset.y + targetOffset.y) / 2; 46 | return [ 47 | sourceOffset, 48 | { id: uuid(), x: sourceOffset.x, y: centerY }, 49 | { id: uuid(), x: targetOffset.x, y: centerY }, 50 | targetOffset, 51 | ]; 52 | } 53 | } else { 54 | if (sourceOffset.y <= targetOffset.y) { 55 | const centerY = (sourceOffset.y + targetOffset.y) / 2; 56 | return [ 57 | { id: uuid(), x: sourceOffset.x, y: centerY }, 58 | { id: uuid(), x: targetOffset.x, y: centerY }, 59 | ]; 60 | } else { 61 | const centerX = (sourceOffset.x + targetOffset.x) / 2; 62 | return [ 63 | sourceOffset, 64 | { id: uuid(), x: centerX, y: sourceOffset.y }, 65 | { id: uuid(), x: centerX, y: targetOffset.y }, 66 | targetOffset, 67 | ]; 68 | } 69 | } 70 | } 71 | if (sourceDirection === targetDirection) { 72 | // Same direction, add two points, two endpoints of parallel lines at half the vertical distance 73 | if (source.y === sourceOffset.y) { 74 | points.push({ 75 | id: uuid(), 76 | x: sourceOffset.x, 77 | y: (sourceOffset.y + targetOffset.y) / 2, 78 | }); 79 | points.push({ 80 | id: uuid(), 81 | x: targetOffset.x, 82 | y: (sourceOffset.y + targetOffset.y) / 2, 83 | }); 84 | } else { 85 | points.push({ 86 | id: uuid(), 87 | x: (sourceOffset.x + targetOffset.x) / 2, 88 | y: sourceOffset.y, 89 | }); 90 | points.push({ 91 | id: uuid(), 92 | x: (sourceOffset.x + targetOffset.x) / 2, 93 | y: targetOffset.y, 94 | }); 95 | } 96 | } else { 97 | // Different directions, add one point, ensure it's not on the current line segment (to avoid overlap), and there are no turns 98 | let point = { id: uuid(), x: sourceOffset.x, y: targetOffset.y }; 99 | const inStart = isInLine(point, source, sourceOffset); 100 | const inEnd = isInLine(point, target, targetOffset); 101 | if (inStart || inEnd) { 102 | point = { id: uuid(), x: targetOffset.x, y: sourceOffset.y }; 103 | } else { 104 | const onStart = isOnLine(point, source, sourceOffset); 105 | const onEnd = isOnLine(point, target, targetOffset); 106 | if (onStart && onEnd) { 107 | point = { id: uuid(), x: targetOffset.x, y: sourceOffset.y }; 108 | } 109 | } 110 | points.push(point); 111 | } 112 | return [sourceOffset, ...points, targetOffset]; 113 | }; 114 | -------------------------------------------------------------------------------- /src/layout/edge/edge.ts: -------------------------------------------------------------------------------- 1 | import { Position, XYPosition } from "@xyflow/react"; 2 | 3 | import { uuid } from "@/utils/uuid"; 4 | 5 | import { ControlPoint, HandlePosition } from "./point"; 6 | 7 | export interface ILine { 8 | start: ControlPoint; 9 | end: ControlPoint; 10 | } 11 | 12 | export const isHorizontalFromPosition = (position: Position) => { 13 | return [Position.Left, Position.Right].includes(position); 14 | }; 15 | 16 | export const isConnectionBackward = (props: { 17 | source: HandlePosition; 18 | target: HandlePosition; 19 | }) => { 20 | const { source, target } = props; 21 | const isHorizontal = isHorizontalFromPosition(source.position); 22 | let isBackward = false; 23 | if (isHorizontal) { 24 | if (source.x > target.x) { 25 | isBackward = true; 26 | } 27 | } else { 28 | if (source.y > target.y) { 29 | isBackward = true; 30 | } 31 | } 32 | return isBackward; 33 | }; 34 | 35 | /** 36 | * Get the distance between two points 37 | */ 38 | export const distance = (p1: ControlPoint, p2: ControlPoint) => { 39 | return Math.hypot(p2.x - p1.x, p2.y - p1.y); 40 | }; 41 | 42 | /** 43 | * Get the midpoint of the line segment 44 | */ 45 | export const getLineCenter = ( 46 | p1: ControlPoint, 47 | p2: ControlPoint 48 | ): ControlPoint => { 49 | return { 50 | id: uuid(), 51 | x: (p1.x + p2.x) / 2, 52 | y: (p1.y + p2.y) / 2, 53 | }; 54 | }; 55 | 56 | /** 57 | * Whether the line segment contains point 58 | */ 59 | export const isLineContainsPoint = ( 60 | start: ControlPoint, 61 | end: ControlPoint, 62 | p: ControlPoint 63 | ) => { 64 | return ( 65 | (start.x === end.x && 66 | p.x === start.x && 67 | p.y <= Math.max(start.y, end.y) && 68 | p.y >= Math.min(start.y, end.y)) || 69 | (start.y === end.y && 70 | p.y === start.y && 71 | p.x <= Math.max(start.x, end.x) && 72 | p.x >= Math.min(start.x, end.x)) 73 | ); 74 | }; 75 | 76 | /** 77 | * Generates an SVG path for an edge based on the control points. 78 | * 79 | * The line between two control points is straight, and each control point represents a turning point with rounded corners. 80 | * 81 | * @param points An array of points representing the endpoints and control points of the edge. 82 | * 83 | * - At least 2 points are required. 84 | * - The points should be ordered starting from the input endpoint and ending at the output endpoint. 85 | * 86 | * @param radius The radius of the rounded corners at each turning point. 87 | * 88 | */ 89 | export function getPathWithRoundCorners( 90 | points: ControlPoint[], 91 | radius: number 92 | ): string { 93 | if (points.length < 2) { 94 | throw new Error("At least 2 points are required."); 95 | } 96 | 97 | function getRoundCorner( 98 | center: ControlPoint, 99 | p1: ControlPoint, 100 | p2: ControlPoint, 101 | radius: number 102 | ) { 103 | const { x, y } = center; 104 | 105 | if (!areLinesPerpendicular(p1, center, center, p2)) { 106 | // The two line segments are not vertical, and return directly to the straight line 107 | return `L ${x} ${y}`; 108 | } 109 | 110 | const d1 = distance(center, p1); 111 | const d2 = distance(center, p2); 112 | // eslint-disable-next-line no-param-reassign 113 | radius = Math.min(d1 / 2, d2 / 2, radius); 114 | 115 | const isHorizontal = p1.y === y; 116 | 117 | const xDir = isHorizontal ? (p1.x < p2.x ? -1 : 1) : p1.x < p2.x ? 1 : -1; 118 | const yDir = isHorizontal ? (p1.y < p2.y ? 1 : -1) : p1.y < p2.y ? -1 : 1; 119 | 120 | if (isHorizontal) { 121 | return `L ${x + radius * xDir},${y}Q ${x},${y} ${x},${y + radius * yDir}`; 122 | } 123 | 124 | return `L ${x},${y + radius * yDir}Q ${x},${y} ${x + radius * xDir},${y}`; 125 | } 126 | 127 | const path: string[] = []; 128 | for (let i = 0; i < points.length; i++) { 129 | if (i === 0) { 130 | // Starting 131 | path.push(`M ${points[i].x} ${points[i].y}`); 132 | } else if (i === points.length - 1) { 133 | // Ending 134 | path.push(`L ${points[i].x} ${points[i].y}`); 135 | } else { 136 | path.push( 137 | getRoundCorner(points[i], points[i - 1], points[i + 1], radius) 138 | ); 139 | } 140 | } 141 | 142 | return path.join(" "); 143 | } 144 | 145 | /** 146 | * Get the longest line segment on the folding line 147 | */ 148 | export function getLongestLine( 149 | points: ControlPoint[] 150 | ): [ControlPoint, ControlPoint] { 151 | let longestLine: [ControlPoint, ControlPoint] = [points[0], points[1]]; 152 | let longestDistance = distance(...longestLine); 153 | for (let i = 1; i < points.length - 1; i++) { 154 | const _distance = distance(points[i], points[i + 1]); 155 | if (_distance > longestDistance) { 156 | longestDistance = _distance; 157 | longestLine = [points[i], points[i + 1]]; 158 | } 159 | } 160 | return longestLine; 161 | } 162 | 163 | /** 164 | * Calculate the position of a label on the polyline. 165 | * 166 | * It first finds the midpoint, and if the number of points is odd, it then finds the longest path. 167 | */ 168 | export function getLabelPosition( 169 | points: ControlPoint[], 170 | minGap = 20 171 | ): XYPosition { 172 | if (points.length % 2 === 0) { 173 | // Find the midpoint of the polyline 174 | const middleP1 = points[points.length / 2 - 1]; 175 | const middleP2 = points[points.length / 2]; 176 | if (distance(middleP1, middleP2) > minGap) { 177 | return getLineCenter(middleP1, middleP2); 178 | } 179 | } 180 | const [start, end] = getLongestLine(points); 181 | return { 182 | x: (start.x + end.x) / 2, 183 | y: (start.y + end.y) / 2, 184 | }; 185 | } 186 | 187 | /** 188 | * Determines whether two line segments are perpendicular (assuming line segments are either horizontal or vertical). 189 | */ 190 | export function areLinesPerpendicular( 191 | p1: ControlPoint, 192 | p2: ControlPoint, 193 | p3: ControlPoint, 194 | p4: ControlPoint 195 | ): boolean { 196 | return (p1.x === p2.x && p3.y === p4.y) || (p1.y === p2.y && p3.x === p4.x); 197 | } 198 | 199 | /** 200 | * Determines whether two line segments are parallel (assuming line segments are either horizontal or vertical). 201 | */ 202 | export function areLinesParallel( 203 | p1: ControlPoint, 204 | p2: ControlPoint, 205 | p3: ControlPoint, 206 | p4: ControlPoint 207 | ) { 208 | return (p1.x === p2.x && p3.x === p4.x) || (p1.y === p2.y && p3.y === p4.y); 209 | } 210 | 211 | /** 212 | * Determines whether two lines are in the same direction (assuming line segments are either horizontal or vertical). 213 | */ 214 | export function areLinesSameDirection( 215 | p1: ControlPoint, 216 | p2: ControlPoint, 217 | p3: ControlPoint, 218 | p4: ControlPoint 219 | ) { 220 | return ( 221 | (p1.x === p2.x && p3.x === p4.x && (p1.y - p2.y) * (p3.y - p4.y) > 0) || 222 | (p1.y === p2.y && p3.y === p4.y && (p1.x - p2.x) * (p3.x - p4.x) > 0) 223 | ); 224 | } 225 | 226 | /** 227 | * Determines whether two lines are in reverse direction (assuming line segments are either horizontal or vertical). 228 | */ 229 | export function areLinesReverseDirection( 230 | p1: ControlPoint, 231 | p2: ControlPoint, 232 | p3: ControlPoint, 233 | p4: ControlPoint 234 | ) { 235 | return ( 236 | (p1.x === p2.x && p3.x === p4.x && (p1.y - p2.y) * (p3.y - p4.y) < 0) || 237 | (p1.y === p2.y && p3.y === p4.y && (p1.x - p2.x) * (p3.x - p4.x) < 0) 238 | ); 239 | } 240 | 241 | export function getAngleBetweenLines( 242 | p1: ControlPoint, 243 | p2: ControlPoint, 244 | p3: ControlPoint, 245 | p4: ControlPoint 246 | ) { 247 | // Calculate the vectors of the two line segments 248 | const v1 = { x: p2.x - p1.x, y: p2.y - p1.y }; 249 | const v2 = { x: p4.x - p3.x, y: p4.y - p3.y }; 250 | 251 | // Calculate the dot product of the two vectors 252 | const dotProduct = v1.x * v2.x + v1.y * v2.y; 253 | 254 | // Calculate the magnitudes of the two vectors 255 | const magnitude1 = Math.sqrt(v1.x ** 2 + v1.y ** 2); 256 | const magnitude2 = Math.sqrt(v2.x ** 2 + v2.y ** 2); 257 | 258 | // Calculate the cosine of the angle 259 | const cosine = dotProduct / (magnitude1 * magnitude2); 260 | 261 | // Calculate the angle in radians 262 | const angleInRadians = Math.acos(cosine); 263 | 264 | // Convert the angle to degrees 265 | const angleInDegrees = (angleInRadians * 180) / Math.PI; 266 | 267 | return angleInDegrees; 268 | } 269 | -------------------------------------------------------------------------------- /src/layout/edge/index.ts: -------------------------------------------------------------------------------- 1 | import { EdgeLayout } from "../../data/types"; 2 | import { kReactflow } from "../../states/reactflow"; 3 | import { getControlPoints, GetControlPointsParams } from "./algorithms"; 4 | import { getLabelPosition, getPathWithRoundCorners } from "./edge"; 5 | 6 | interface GetBasePathParams extends GetControlPointsParams { 7 | borderRadius: number; 8 | } 9 | 10 | export function getBasePath({ 11 | id, 12 | offset, 13 | borderRadius, 14 | source, 15 | target, 16 | sourceX, 17 | sourceY, 18 | targetX, 19 | targetY, 20 | sourcePosition, 21 | targetPosition, 22 | }: any) { 23 | const sourceNode = kReactflow.instance!.getInternalNode(source)!; 24 | const targetNode = kReactflow.instance!.getInternalNode(target)!; 25 | 26 | return getPathWithPoints({ 27 | offset, 28 | borderRadius, 29 | source: { 30 | id: "source-" + id, 31 | x: sourceX, 32 | y: sourceY, 33 | position: sourcePosition, 34 | }, 35 | target: { 36 | id: "target-" + id, 37 | x: targetX, 38 | y: targetY, 39 | position: targetPosition, 40 | }, 41 | sourceRect: { 42 | ...(sourceNode.internals.positionAbsolute || sourceNode.position), 43 | width: sourceNode.width!, 44 | height: sourceNode.height!, 45 | }, 46 | targetRect: { 47 | ...(targetNode.internals.positionAbsolute || targetNode.position), 48 | width: targetNode.width!, 49 | height: targetNode.height!, 50 | }, 51 | }); 52 | } 53 | 54 | export function getPathWithPoints({ 55 | source, 56 | target, 57 | sourceRect, 58 | targetRect, 59 | offset = 20, 60 | borderRadius = 16, 61 | }: GetBasePathParams): EdgeLayout { 62 | const { points, inputPoints } = getControlPoints({ 63 | source, 64 | target, 65 | offset, 66 | sourceRect, 67 | targetRect, 68 | }); 69 | const labelPosition = getLabelPosition(points); 70 | const path = getPathWithRoundCorners(points, borderRadius); 71 | return { path, points, inputPoints, labelPosition }; 72 | } 73 | -------------------------------------------------------------------------------- /src/layout/edge/point.ts: -------------------------------------------------------------------------------- 1 | import { Position } from "@xyflow/react"; 2 | 3 | import { uuid } from "@/utils/uuid"; 4 | 5 | import { isHorizontalFromPosition } from "./edge"; 6 | 7 | export interface ControlPoint { 8 | id: string; 9 | x: number; 10 | y: number; 11 | } 12 | 13 | export interface NodeRect { 14 | x: number; // left 15 | y: number; // top 16 | width: number; 17 | height: number; 18 | } 19 | 20 | export interface RectSides { 21 | top: number; 22 | right: number; 23 | bottom: number; 24 | left: number; 25 | } 26 | 27 | export interface HandlePosition extends ControlPoint { 28 | position: Position; 29 | } 30 | 31 | export interface GetVerticesParams { 32 | source: NodeRect; 33 | target: NodeRect; 34 | sourceOffset: ControlPoint; 35 | targetOffset: ControlPoint; 36 | } 37 | 38 | /** 39 | * Find the potential midpoint and intersection 40 | */ 41 | export const getCenterPoints = ({ 42 | source, 43 | target, 44 | sourceOffset, 45 | targetOffset, 46 | }: GetVerticesParams): ControlPoint[] => { 47 | if (sourceOffset.x === targetOffset.x || sourceOffset.y === targetOffset.y) { 48 | // Cannot determine the rectangle 49 | return []; 50 | } 51 | const vertices = [...getRectVertices(source), ...getRectVertices(target)]; 52 | const outerSides = getSidesFromPoints(vertices); 53 | const { left, right, top, bottom } = getSidesFromPoints([ 54 | sourceOffset, 55 | targetOffset, 56 | ]); 57 | const centerX = (left + right) / 2; 58 | const centerY = (top + bottom) / 2; 59 | const points = [ 60 | { id: uuid(), x: centerX, y: top }, // topCenter 61 | { id: uuid(), x: right, y: centerY }, // rightCenter 62 | { id: uuid(), x: centerX, y: bottom }, // bottomCenter 63 | { id: uuid(), x: left, y: centerY }, // leftCenter 64 | { id: uuid(), x: centerX, y: outerSides.top }, // outerTop 65 | { id: uuid(), x: outerSides.right, y: centerY }, // outerRight 66 | { id: uuid(), x: centerX, y: outerSides.bottom }, // outerBottom 67 | { id: uuid(), x: outerSides.left, y: centerY }, // outerLeft 68 | ]; 69 | return points.filter((p) => { 70 | return !isPointInRect(p, source) && !isPointInRect(p, target); 71 | }); 72 | }; 73 | 74 | export const getExpandedRect = (rect: NodeRect, offset: number): NodeRect => { 75 | return { 76 | x: rect.x - offset, 77 | y: rect.y - offset, 78 | width: rect.width + 2 * offset, 79 | height: rect.height + 2 * offset, 80 | }; 81 | }; 82 | 83 | export const isRectOverLapping = (rect1: NodeRect, rect2: NodeRect) => { 84 | return ( 85 | Math.abs(rect1.x - rect2.x) < (rect1.width + rect2.width) / 2 && 86 | Math.abs(rect1.y - rect2.y) < (rect1.height + rect2.height) / 2 87 | ); 88 | }; 89 | 90 | export const isPointInRect = (p: ControlPoint, box: NodeRect) => { 91 | const sides = getRectSides(box); 92 | return ( 93 | p.x >= sides.left && 94 | p.x <= sides.right && 95 | p.y >= sides.top && 96 | p.y <= sides.bottom 97 | ); 98 | }; 99 | 100 | /** 101 | * Find the vertex of an enclosing rectangle with a vertex outside of it, given a rectangle and an external vertex. 102 | */ 103 | export const getVerticesFromRectVertex = ( 104 | box: NodeRect, 105 | vertex: ControlPoint 106 | ): ControlPoint[] => { 107 | const points = [vertex, ...getRectVertices(box)]; 108 | const { top, right, bottom, left } = getSidesFromPoints(points); 109 | return [ 110 | { id: uuid(), x: left, y: top }, // topLeft 111 | { id: uuid(), x: right, y: top }, // topRight 112 | { id: uuid(), x: right, y: bottom }, // bottomRight 113 | { id: uuid(), x: left, y: bottom }, // bottomLeft 114 | ]; 115 | }; 116 | 117 | export const getSidesFromPoints = (points: ControlPoint[]) => { 118 | const left = Math.min(...points.map((p) => p.x)); 119 | const right = Math.max(...points.map((p) => p.x)); 120 | const top = Math.min(...points.map((p) => p.y)); 121 | const bottom = Math.max(...points.map((p) => p.y)); 122 | return { top, right, bottom, left }; 123 | }; 124 | 125 | /** 126 | * Get the top, right, bottom, left of the Rect. 127 | */ 128 | export const getRectSides = (box: NodeRect): RectSides => { 129 | const { x: left, y: top, width, height } = box; 130 | const right = left + width; 131 | const bottom = top + height; 132 | return { top, right, bottom, left }; 133 | }; 134 | 135 | export const getRectVerticesFromSides = ({ 136 | top, 137 | right, 138 | bottom, 139 | left, 140 | }: RectSides): ControlPoint[] => { 141 | return [ 142 | { id: uuid(), x: left, y: top }, // topLeft 143 | { id: uuid(), x: right, y: top }, // topRight 144 | { id: uuid(), x: right, y: bottom }, // bottomRight 145 | { id: uuid(), x: left, y: bottom }, // bottomLeft 146 | ]; 147 | }; 148 | 149 | export const getRectVertices = (box: NodeRect) => { 150 | const sides = getRectSides(box); 151 | return getRectVerticesFromSides(sides); 152 | }; 153 | 154 | export const mergeRects = (...boxes: NodeRect[]): NodeRect => { 155 | const left = Math.min( 156 | ...boxes.reduce((pre, e) => [...pre, e.x, e.x + e.width], [] as number[]) 157 | ); 158 | const right = Math.max( 159 | ...boxes.reduce((pre, e) => [...pre, e.x, e.x + e.width], [] as number[]) 160 | ); 161 | const top = Math.min( 162 | ...boxes.reduce((pre, e) => [...pre, e.y, e.y + e.height], [] as number[]) 163 | ); 164 | const bottom = Math.max( 165 | ...boxes.reduce((pre, e) => [...pre, e.y, e.y + e.height], [] as number[]) 166 | ); 167 | return { 168 | x: left, 169 | y: top, 170 | width: right - left, 171 | height: bottom - top, 172 | }; 173 | }; 174 | 175 | /** 176 | * 0 ---------> X 177 | * | 178 | * | 179 | * | 180 | * v 181 | * 182 | * Y 183 | */ 184 | export const getOffsetPoint = ( 185 | box: HandlePosition, 186 | offset: number 187 | ): ControlPoint => { 188 | switch (box.position) { 189 | case Position.Top: 190 | return { 191 | id: uuid(), 192 | x: box.x, 193 | y: box.y - offset, 194 | }; 195 | case Position.Bottom: 196 | return { id: uuid(), x: box.x, y: box.y + offset }; 197 | case Position.Left: 198 | return { id: uuid(), x: box.x - offset, y: box.y }; 199 | case Position.Right: 200 | return { id: uuid(), x: box.x + offset, y: box.y }; 201 | } 202 | }; 203 | 204 | /** 205 | * Determine whether a point is in the segment 206 | */ 207 | export const isInLine = ( 208 | p: ControlPoint, 209 | p1: ControlPoint, 210 | p2: ControlPoint 211 | ) => { 212 | const xPoints = p1.x < p2.x ? [p1.x, p2.x] : [p2.x, p1.x]; 213 | const yPoints = p1.y < p2.y ? [p1.y, p2.y] : [p2.y, p1.y]; 214 | return ( 215 | (p1.x === p.x && p.x === p2.x && p.y >= yPoints[0] && p.y <= yPoints[1]) || 216 | (p1.y === p.y && p.y === p2.y && p.x >= xPoints[0] && p.x <= xPoints[1]) 217 | ); 218 | }; 219 | 220 | /** 221 | * Determine whether a point is on the straight line 222 | */ 223 | export const isOnLine = ( 224 | p: ControlPoint, 225 | p1: ControlPoint, 226 | p2: ControlPoint 227 | ) => { 228 | return (p1.x === p.x && p.x === p2.x) || (p1.y === p.y && p.y === p2.y); 229 | }; 230 | 231 | export interface OptimizePointsParams { 232 | edgePoints: ControlPoint[]; 233 | source: HandlePosition; 234 | target: HandlePosition; 235 | sourceOffset: ControlPoint; 236 | targetOffset: ControlPoint; 237 | } 238 | 239 | /** 240 | * Optimize the control points of edges. 241 | * 242 | * - Merge points with similar coordinates. 243 | * - Delete duplicate coordinate points. 244 | * - Correct source and target points. 245 | */ 246 | export const optimizeInputPoints = (p: OptimizePointsParams) => { 247 | // Merge points with similar coordinates 248 | let edgePoints = mergeClosePoints([ 249 | p.source, 250 | p.sourceOffset, 251 | ...p.edgePoints, 252 | p.targetOffset, 253 | p.target, 254 | ]); 255 | const source = edgePoints.shift()!; 256 | const target = edgePoints.pop()!; 257 | const sourceOffset = edgePoints[0]; 258 | const targetOffset = edgePoints[edgePoints.length - 1]; 259 | // Correct source and target points. 260 | if (isHorizontalFromPosition(p.source.position)) { 261 | source.x = p.source.x; 262 | } else { 263 | source.y = p.source.y; 264 | } 265 | if (isHorizontalFromPosition(p.target.position)) { 266 | target.x = p.target.x; 267 | } else { 268 | target.y = p.target.y; 269 | } 270 | // Remove duplicate coordinate points 271 | edgePoints = removeRepeatPoints(edgePoints).map((p, idx) => ({ 272 | ...p, 273 | id: `${idx + 1}`, 274 | })); 275 | return { source, target, sourceOffset, targetOffset, edgePoints }; 276 | }; 277 | 278 | /** 279 | * Reduce the control points of edges. 280 | * 281 | * - Ensure that there are only 2 endpoints on a straight line. 282 | * - Remove control points inside the straight line. 283 | */ 284 | export function reducePoints(points: ControlPoint[]): ControlPoint[] { 285 | const optimizedPoints = [points[0]]; 286 | for (let i = 1; i < points.length - 1; i++) { 287 | const inSegment = isInLine(points[i], points[i - 1], points[i + 1]); 288 | if (!inSegment) { 289 | optimizedPoints.push(points[i]); 290 | } 291 | } 292 | optimizedPoints.push(points[points.length - 1]); 293 | return optimizedPoints; 294 | } 295 | 296 | /** 297 | * Merge nearby coordinates while rounding the coordinates to integers. 298 | */ 299 | export function mergeClosePoints( 300 | points: ControlPoint[], 301 | threshold = 4 302 | ): ControlPoint[] { 303 | // Discrete coordinates 304 | const positions = { x: [] as number[], y: [] as number[] }; 305 | const findPosition = (axis: "x" | "y", v: number) => { 306 | // eslint-disable-next-line no-param-reassign 307 | v = Math.floor(v); 308 | const ps = positions[axis]; 309 | let p = ps.find((e) => Math.abs(v - e) < threshold); 310 | // eslint-disable-next-line eqeqeq 311 | if (p == null) { 312 | p = v; 313 | positions[axis].push(v); 314 | } 315 | return p; 316 | }; 317 | 318 | const finalPoints = points.map((point) => { 319 | return { 320 | ...point, 321 | x: findPosition("x", point.x), 322 | y: findPosition("y", point.y), 323 | }; 324 | }); 325 | 326 | return finalPoints; 327 | } 328 | 329 | export function isEqualPoint(p1: ControlPoint, p2: ControlPoint) { 330 | return p1.x === p2.x && p1.y === p2.y; 331 | } 332 | 333 | /** 334 | * Remove the duplicate point (retain the starting point and end point) 335 | */ 336 | export function removeRepeatPoints(points: ControlPoint[]): ControlPoint[] { 337 | const lastP = points[points.length - 1]; 338 | const uniquePoints = new Set([`${lastP.x}-${lastP.y}`]); 339 | const finalPoints: ControlPoint[] = []; 340 | points.forEach((p, idx) => { 341 | if (idx === points.length - 1) { 342 | return finalPoints.push(p); 343 | } 344 | const key = `${p.x}-${p.y}`; 345 | if (!uniquePoints.has(key)) { 346 | uniquePoints.add(key); 347 | finalPoints.push(p); 348 | } 349 | }); 350 | return finalPoints; 351 | } 352 | 353 | /** 354 | * Determine whether the line segment intersects 355 | */ 356 | const isSegmentsIntersected = ( 357 | p0: ControlPoint, 358 | p1: ControlPoint, 359 | p2: ControlPoint, 360 | p3: ControlPoint 361 | ): boolean => { 362 | const s1x = p1.x - p0.x; 363 | const s1y = p1.y - p0.y; 364 | const s2x = p3.x - p2.x; 365 | const s2y = p3.y - p2.y; 366 | 367 | if (s1x * s2y - s1y * s2x === 0) { 368 | // Lines are parallel, no intersection 369 | return false; 370 | } 371 | 372 | const denominator = -s2x * s1y + s1x * s2y; 373 | const s = (s1y * (p2.x - p0.x) - s1x * (p2.y - p0.y)) / denominator; 374 | const t = (s2x * (p0.y - p2.y) - s2y * (p0.x - p2.x)) / denominator; 375 | 376 | return s >= 0 && s <= 1 && t >= 0 && t <= 1; 377 | }; 378 | 379 | /** 380 | * Determine whether the line segment intersects the rectangle 381 | */ 382 | export const isSegmentCrossingRect = ( 383 | p1: ControlPoint, 384 | p2: ControlPoint, 385 | box: NodeRect 386 | ): boolean => { 387 | if (box.width === 0 && box.height === 0) { 388 | return false; 389 | } 390 | const [topLeft, topRight, bottomRight, bottomLeft] = getRectVertices(box); 391 | return ( 392 | isSegmentsIntersected(p1, p2, topLeft, topRight) || 393 | isSegmentsIntersected(p1, p2, topRight, bottomRight) || 394 | isSegmentsIntersected(p1, p2, bottomRight, bottomLeft) || 395 | isSegmentsIntersected(p1, p2, bottomLeft, topLeft) 396 | ); 397 | }; 398 | -------------------------------------------------------------------------------- /src/layout/edge/style.ts: -------------------------------------------------------------------------------- 1 | import { deepClone, lastOf } from "@/utils/base"; 2 | import { Position, getBezierPath } from "@xyflow/react"; 3 | 4 | import { getBasePath } from "."; 5 | import { 6 | kBaseMarkerColor, 7 | kBaseMarkerColors, 8 | kNoMarkerColor, 9 | kYesMarkerColor, 10 | } from "../../components/Edges/Marker"; 11 | import { isEqual } from "../../utils/diff"; 12 | import { EdgeLayout, ReactflowEdgeWithData } from "../../data/types"; 13 | import { kReactflow } from "../../states/reactflow"; 14 | import { getPathWithRoundCorners } from "./edge"; 15 | 16 | interface EdgeStyle { 17 | color: string; 18 | edgeType: "solid" | "dashed"; 19 | pathType: "base" | "bezier"; 20 | } 21 | 22 | /** 23 | * Get the style of the connection line 24 | * 25 | * 1. When there are more than 3 edges connecting to both ends of the Node, use multiple colors to distinguish the edges. 26 | * 2. When the connection line goes backward or connects to a hub Node, use dashed lines to distinguish the edges. 27 | * 3. When the connection line goes from a hub to a Node, use bezier path. 28 | */ 29 | export const getEdgeStyles = (props: { 30 | id: string; 31 | isBackward: boolean; 32 | }): EdgeStyle => { 33 | const { id, isBackward } = props; 34 | const idx = parseInt(lastOf(id.split("#")) ?? "0", 10); 35 | if (isBackward) { 36 | // Use dashed lines to distinguish the edges when the connection line goes backward or connects to a hub Node 37 | return { color: kNoMarkerColor, edgeType: "dashed", pathType: "base" }; 38 | } 39 | const edge = kReactflow.instance!.getEdge(id)! as ReactflowEdgeWithData; 40 | if (edge.data!.targetPort.edges > 2) { 41 | // Use dashed bezier path when the connection line connects to a hub Node 42 | return { 43 | color: kYesMarkerColor, 44 | edgeType: "dashed", 45 | pathType: "bezier", 46 | }; 47 | } 48 | if (edge.data!.sourcePort.edges > 2) { 49 | // Use multiple colors to distinguish the edges when there are more than 3 edges connecting to both ends of the Node 50 | return { 51 | color: kBaseMarkerColors[idx % kBaseMarkerColors.length], 52 | edgeType: "solid", 53 | pathType: "base", 54 | }; 55 | } 56 | return { color: kBaseMarkerColor, edgeType: "solid", pathType: "base" }; 57 | }; 58 | 59 | interface ILayoutEdge { 60 | id: string; 61 | layout?: EdgeLayout; 62 | offset: number; 63 | borderRadius: number; 64 | pathType: EdgeStyle["pathType"]; 65 | source: string; 66 | target: string; 67 | sourceX: number; 68 | sourceY: number; 69 | targetX: number; 70 | targetY: number; 71 | sourcePosition: Position; 72 | targetPosition: Position; 73 | } 74 | 75 | export function layoutEdge({ 76 | id, 77 | layout, 78 | offset, 79 | borderRadius, 80 | pathType, 81 | source, 82 | target, 83 | sourceX, 84 | sourceY, 85 | targetX, 86 | targetY, 87 | sourcePosition, 88 | targetPosition, 89 | }: ILayoutEdge): EdgeLayout { 90 | const relayoutDeps = [sourceX, sourceY, targetX, targetY]; 91 | const needRelayout = !isEqual(relayoutDeps, layout?.deps?.relayoutDeps); 92 | const reBuildPathDeps = layout?.points; 93 | const needReBuildPath = !isEqual( 94 | reBuildPathDeps, 95 | layout?.deps?.reBuildPathDeps 96 | ); 97 | let newLayout = layout; 98 | if (needRelayout) { 99 | newLayout = _layoutEdge({ 100 | id, 101 | offset, 102 | borderRadius, 103 | pathType, 104 | source, 105 | target, 106 | sourceX, 107 | sourceY, 108 | targetX, 109 | targetY, 110 | sourcePosition, 111 | targetPosition, 112 | }); 113 | } else if (needReBuildPath) { 114 | newLayout = _layoutEdge({ 115 | layout, 116 | id, 117 | offset, 118 | borderRadius, 119 | pathType, 120 | source, 121 | target, 122 | sourceX, 123 | sourceY, 124 | targetX, 125 | targetY, 126 | sourcePosition, 127 | targetPosition, 128 | }); 129 | } 130 | newLayout!.deps = deepClone({ relayoutDeps, reBuildPathDeps }); 131 | return newLayout!; 132 | } 133 | 134 | function _layoutEdge({ 135 | id, 136 | layout, 137 | offset, 138 | borderRadius, 139 | pathType, 140 | source, 141 | target, 142 | sourceX, 143 | sourceY, 144 | targetX, 145 | targetY, 146 | sourcePosition, 147 | targetPosition, 148 | }: ILayoutEdge): EdgeLayout { 149 | const _pathType: EdgeStyle["pathType"] = pathType; 150 | if (_pathType === "bezier") { 151 | const [path, labelX, labelY] = getBezierPath({ 152 | sourceX, 153 | sourceY, 154 | targetX, 155 | targetY, 156 | sourcePosition, 157 | targetPosition, 158 | }); 159 | const points = [ 160 | { 161 | id: "source-" + id, 162 | x: sourceX, 163 | y: sourceY, 164 | }, 165 | { 166 | id: "target-" + id, 167 | x: targetX, 168 | y: targetY, 169 | }, 170 | ]; 171 | return { 172 | path, 173 | points, 174 | inputPoints: points, 175 | labelPosition: { 176 | x: labelX, 177 | y: labelY, 178 | }, 179 | }; 180 | } 181 | 182 | if ((layout?.points?.length ?? 0) > 1) { 183 | layout!.path = getPathWithRoundCorners(layout!.points, borderRadius); 184 | return layout!; 185 | } 186 | 187 | return getBasePath({ 188 | id, 189 | offset, 190 | borderRadius, 191 | source, 192 | target, 193 | sourceX, 194 | sourceY, 195 | targetX, 196 | targetY, 197 | sourcePosition, 198 | targetPosition, 199 | }); 200 | } 201 | -------------------------------------------------------------------------------- /src/layout/metadata.ts: -------------------------------------------------------------------------------- 1 | import { MarkerType, Position } from "@xyflow/react"; 2 | 3 | import { 4 | Reactflow, 5 | ReactflowEdgeWithData, 6 | ReactflowNodeWithData, 7 | } from "../data/types"; 8 | import { LayoutDirection, LayoutVisibility } from "./node"; 9 | 10 | export const getRootNode = (nodes: Reactflow["nodes"]) => { 11 | return nodes.find((e) => e.type === "start") ?? nodes[0]; 12 | }; 13 | 14 | export const getNodeSize = ( 15 | node: ReactflowNodeWithData, 16 | defaultSize = { width: 150, height: 36 } 17 | ) => { 18 | const nodeWith = node.measured?.width; 19 | const nodeHeight = node.measured?.height; 20 | const hasDimension = [nodeWith, nodeHeight].every((e) => e != null); 21 | 22 | return { 23 | hasDimension, 24 | width: nodeWith, 25 | height: nodeHeight, 26 | widthWithDefault: nodeWith ?? defaultSize.width, 27 | heightWithDefault: nodeHeight ?? defaultSize.height, 28 | }; 29 | }; 30 | 31 | export type IFixPosition = (pros: { 32 | x: number; 33 | y: number; 34 | width: number; 35 | height: number; 36 | }) => { 37 | x: number; 38 | y: number; 39 | }; 40 | 41 | export const getNodeLayouted = (props: { 42 | node: ReactflowNodeWithData; 43 | position: { x: number; y: number }; 44 | direction: LayoutDirection; 45 | visibility: LayoutVisibility; 46 | fixPosition?: IFixPosition; 47 | }): ReactflowNodeWithData => { 48 | const { 49 | node, 50 | position, 51 | direction, 52 | visibility, 53 | fixPosition = (p) => ({ x: p.x, y: p.y }), 54 | } = props; 55 | 56 | const hidden = visibility !== "visible"; 57 | const isHorizontal = direction === "horizontal"; 58 | const { width, height, widthWithDefault, heightWithDefault } = 59 | getNodeSize(node); 60 | 61 | return { 62 | ...node, 63 | type: "base", 64 | width, 65 | height, 66 | position: fixPosition({ 67 | ...position, 68 | width: widthWithDefault, 69 | height: heightWithDefault, 70 | }), 71 | data: { 72 | ...node.data, 73 | label: node.id, 74 | }, 75 | style: { 76 | ...node.style, 77 | visibility: hidden ? "hidden" : "visible", 78 | }, 79 | targetPosition: isHorizontal ? Position.Left : Position.Top, 80 | sourcePosition: isHorizontal ? Position.Right : Position.Bottom, 81 | }; 82 | }; 83 | 84 | export const getEdgeLayouted = (props: { 85 | edge: ReactflowEdgeWithData; 86 | visibility: LayoutVisibility; 87 | }): ReactflowEdgeWithData => { 88 | const { edge, visibility } = props; 89 | const hidden = visibility !== "visible"; 90 | return { 91 | ...edge, 92 | type: "base", 93 | markerEnd: { 94 | type: MarkerType.ArrowClosed, 95 | }, 96 | style: { 97 | ...edge.style, 98 | visibility: hidden ? "hidden" : "visible", 99 | }, 100 | }; 101 | }; 102 | -------------------------------------------------------------------------------- /src/layout/node/algorithms/d3-dag.ts: -------------------------------------------------------------------------------- 1 | import { graphStratify, sugiyama } from "d3-dag"; 2 | import { getIncomers, type Node } from "@xyflow/react"; 3 | 4 | import { ReactflowNodeWithData } from "@/data/types"; 5 | import { LayoutAlgorithm, LayoutAlgorithmProps } from ".."; 6 | import { getEdgeLayouted, getNodeLayouted, getNodeSize } from "../../metadata"; 7 | 8 | type NodeWithPosition = ReactflowNodeWithData & { x: number; y: number }; 9 | 10 | // Since d3-dag layout algorithm does not support multiple root nodes, 11 | // we attach the sub-workflows to the global rootNode. 12 | const rootNode: NodeWithPosition = { 13 | id: "#root", 14 | x: 0, 15 | y: 0, 16 | position: { x: 0, y: 0 }, 17 | data: {} as any, 18 | }; 19 | 20 | const algorithms = { 21 | "d3-dag": "d3-dag", 22 | "ds-dag(s)": "ds-dag(s)", 23 | }; 24 | 25 | export type D3DAGLayoutAlgorithms = "d3-dag" | "ds-dag(s)"; 26 | 27 | export const layoutD3DAG = async ( 28 | props: LayoutAlgorithmProps & { algorithm?: D3DAGLayoutAlgorithms } 29 | ) => { 30 | const { 31 | nodes, 32 | edges, 33 | direction, 34 | visibility, 35 | spacing, 36 | algorithm = "d3-dag", 37 | } = props; 38 | const isHorizontal = direction === "horizontal"; 39 | 40 | const initialNodes = [] as NodeWithPosition[]; 41 | let maxNodeWidth = 0; 42 | let maxNodeHeight = 0; 43 | for (const node of nodes) { 44 | const { widthWithDefault, heightWithDefault } = getNodeSize(node); 45 | initialNodes.push({ 46 | ...node, 47 | ...node.position, 48 | width: widthWithDefault, 49 | height: heightWithDefault, 50 | }); 51 | maxNodeWidth = Math.max(maxNodeWidth, widthWithDefault); 52 | maxNodeHeight = Math.max(maxNodeHeight, heightWithDefault); 53 | } 54 | 55 | // Since d3-dag does not support horizontal layout, 56 | // we swap the width and height of nodes and interchange x and y mappings based on the layout direction. 57 | const nodeSize: any = isHorizontal 58 | ? [maxNodeHeight + spacing.y, maxNodeWidth + spacing.x] 59 | : [maxNodeWidth + spacing.x, maxNodeHeight + spacing.y]; 60 | 61 | const getParentIds = (node: Node) => { 62 | if (node.id === rootNode.id) { 63 | return undefined; 64 | } 65 | // Node without input is the root node of sub-workflow, and we should connect it to the rootNode 66 | const incomers = getIncomers(node, nodes, edges); 67 | if (incomers.length < 1) { 68 | return [rootNode.id]; 69 | } 70 | return algorithm === "d3-dag" 71 | ? [incomers[0]?.id] 72 | : incomers.map((e) => e.id); 73 | }; 74 | 75 | const stratify = graphStratify(); 76 | const dag = stratify( 77 | [rootNode, ...initialNodes].map((node) => { 78 | return { 79 | id: node.id, 80 | parentIds: getParentIds(node), 81 | }; 82 | }) 83 | ); 84 | 85 | const layout = sugiyama().nodeSize(nodeSize); 86 | layout(dag); 87 | 88 | const layoutNodes = new Map(); 89 | for (const node of dag.nodes()) { 90 | layoutNodes.set(node.data.id, node); 91 | } 92 | 93 | return { 94 | nodes: nodes.map((node) => { 95 | const { x, y } = layoutNodes.get(node.id); 96 | // Interchange x and y mappings based on the layout direction. 97 | const position = isHorizontal ? { x: y, y: x } : { x, y }; 98 | return getNodeLayouted({ 99 | node, 100 | position, 101 | direction, 102 | visibility, 103 | fixPosition: ({ x, y, width, height }) => { 104 | // This algorithm uses the center coordinate of the node as the reference point, 105 | // which needs adjustment for ReactFlow's topLeft coordinate system. 106 | return { 107 | x: x - width / 2, 108 | y: y - height / 2, 109 | }; 110 | }, 111 | }); 112 | }), 113 | edges: edges.map((edge) => getEdgeLayouted({ edge, visibility })), 114 | }; 115 | }; 116 | 117 | export const kD3DAGAlgorithms: Record = Object.keys( 118 | algorithms 119 | ).reduce((pre, algorithm) => { 120 | pre[algorithm] = (props: any) => { 121 | return layoutD3DAG({ ...props, algorithm }); 122 | }; 123 | return pre; 124 | }, {} as any); 125 | -------------------------------------------------------------------------------- /src/layout/node/algorithms/d3-hierarchy.ts: -------------------------------------------------------------------------------- 1 | // Based on: https://github.com/flanksource/flanksource-ui/blob/75b35591d3bbc7d446fa326d0ca7536790f38d88/src/ui/Graphs/Layouts/algorithms/d3-hierarchy.ts 2 | 3 | import { stratify, tree, type HierarchyPointNode } from "d3-hierarchy"; 4 | import { getIncomers, type Node } from "@xyflow/react"; 5 | 6 | import { ReactflowNodeWithData } from "@/data/types"; 7 | import { LayoutAlgorithm } from ".."; 8 | import { getEdgeLayouted, getNodeLayouted, getNodeSize } from "../../metadata"; 9 | 10 | type NodeWithPosition = ReactflowNodeWithData & { x: number; y: number }; 11 | 12 | const layout = tree().separation(() => 1); 13 | 14 | // Since d3-hierarchy layout algorithm does not support multiple root nodes, 15 | // we attach the sub-workflows to the global rootNode. 16 | const rootNode: NodeWithPosition = { 17 | id: "#root", 18 | x: 0, 19 | y: 0, 20 | position: { x: 0, y: 0 }, 21 | data: {} as any, 22 | }; 23 | 24 | export const layoutD3Hierarchy: LayoutAlgorithm = async (props) => { 25 | const { nodes, edges, direction, visibility, spacing } = props; 26 | const isHorizontal = direction === "horizontal"; 27 | 28 | const initialNodes = [] as NodeWithPosition[]; 29 | let maxNodeWidth = 0; 30 | let maxNodeHeight = 0; 31 | for (const node of nodes) { 32 | const { widthWithDefault, heightWithDefault } = getNodeSize(node); 33 | initialNodes.push({ 34 | ...node, 35 | ...node.position, 36 | width: widthWithDefault, 37 | height: heightWithDefault, 38 | }); 39 | maxNodeWidth = Math.max(maxNodeWidth, widthWithDefault); 40 | maxNodeHeight = Math.max(maxNodeHeight, heightWithDefault); 41 | } 42 | 43 | // Since d3-hierarchy does not support horizontal layout, 44 | // we swap the width and height of nodes and interchange x and y mappings based on the layout direction. 45 | const nodeSize: [number, number] = isHorizontal 46 | ? [maxNodeHeight + spacing.y, maxNodeWidth + spacing.x] 47 | : [maxNodeWidth + spacing.x, maxNodeHeight + spacing.y]; 48 | 49 | layout.nodeSize(nodeSize); 50 | 51 | const getParentId = (node: Node) => { 52 | if (node.id === rootNode.id) { 53 | return undefined; 54 | } 55 | // Node without input is the root node of sub-workflow, and we should connect it to the rootNode 56 | const incomers = getIncomers(node, nodes, edges); 57 | return incomers[0]?.id || rootNode.id; 58 | }; 59 | 60 | const hierarchy = stratify() 61 | .id((d) => d.id) 62 | .parentId(getParentId)([rootNode, ...initialNodes]); 63 | 64 | const root = layout(hierarchy); 65 | const layoutNodes = new Map>(); 66 | for (const node of root) { 67 | layoutNodes.set(node.id!, node); 68 | } 69 | 70 | return { 71 | nodes: nodes.map((node) => { 72 | const { x, y } = layoutNodes.get(node.id)!; 73 | // Interchange x and y mappings based on the layout direction. 74 | const position = isHorizontal ? { x: y, y: x } : { x, y }; 75 | return getNodeLayouted({ 76 | node, 77 | position, 78 | direction, 79 | visibility, 80 | fixPosition: ({ x, y, width, height }) => { 81 | // This algorithm uses the center coordinate of the node as the reference point, 82 | // which needs adjustment for ReactFlow's topLeft coordinate system. 83 | return { 84 | x: x - width / 2, 85 | y: y - height / 2, 86 | }; 87 | }, 88 | }); 89 | }), 90 | edges: edges.map((edge) => getEdgeLayouted({ edge, visibility })), 91 | }; 92 | }; 93 | -------------------------------------------------------------------------------- /src/layout/node/algorithms/dagre-tree.ts: -------------------------------------------------------------------------------- 1 | import dagre from "@dagrejs/dagre"; 2 | import { getIncomers } from "@xyflow/react"; 3 | 4 | import { ReactflowNodeWithData } from "@/data/types"; 5 | import { LayoutAlgorithm } from ".."; 6 | import { getEdgeLayouted, getNodeLayouted, getNodeSize } from "../../metadata"; 7 | 8 | const dagreGraph = new dagre.graphlib.Graph(); 9 | dagreGraph.setDefaultEdgeLabel(() => ({})); 10 | 11 | export const layoutDagreTree: LayoutAlgorithm = async (props) => { 12 | const { nodes, edges, direction, visibility, spacing } = props; 13 | const isHorizontal = direction === "horizontal"; 14 | 15 | dagreGraph.setGraph({ 16 | nodesep: isHorizontal ? spacing.y : spacing.x, 17 | ranksep: isHorizontal ? spacing.x : spacing.y, 18 | ranker: "tight-tree", 19 | rankdir: isHorizontal ? "LR" : "TB", 20 | }); 21 | 22 | const subWorkflowRootNodes: ReactflowNodeWithData[] = []; 23 | nodes.forEach((node) => { 24 | const incomers = getIncomers(node, nodes, edges); 25 | if (incomers.length < 1) { 26 | // Node without input is the root node of sub-workflow 27 | subWorkflowRootNodes.push(node); 28 | } 29 | const { widthWithDefault, heightWithDefault } = getNodeSize(node); 30 | dagreGraph.setNode(node.id, { 31 | width: widthWithDefault, 32 | height: heightWithDefault, 33 | }); 34 | }); 35 | 36 | edges.forEach((edge) => dagreGraph.setEdge(edge.source, edge.target)); 37 | 38 | // Connect sub-workflows' root nodes to the rootNode 39 | dagreGraph.setNode("#root", { width: 1, height: 1 }); 40 | for (const subWorkflowRootNode of subWorkflowRootNodes) { 41 | dagreGraph.setEdge("#root", subWorkflowRootNode.id); 42 | } 43 | 44 | dagre.layout(dagreGraph); 45 | 46 | return { 47 | nodes: nodes.map((node) => { 48 | const position = dagreGraph.node(node.id); 49 | return getNodeLayouted({ 50 | node, 51 | position, 52 | direction, 53 | visibility, 54 | fixPosition: ({ x, y, width, height }) => ({ 55 | // This algorithm uses the center coordinate of the node as the reference point, 56 | // which needs adjustment for ReactFlow's topLeft coordinate system. 57 | x: x - width / 2, 58 | y: y - height / 2, 59 | }), 60 | }); 61 | }), 62 | edges: edges.map((edge) => getEdgeLayouted({ edge, visibility })), 63 | }; 64 | }; 65 | -------------------------------------------------------------------------------- /src/layout/node/algorithms/elk.ts: -------------------------------------------------------------------------------- 1 | import ELK from "elkjs/lib/elk.bundled.js"; 2 | import { getIncomers } from "@xyflow/react"; 3 | 4 | import { ReactflowNodeWithData } from "@/data/types"; 5 | import { LayoutAlgorithm, LayoutAlgorithmProps } from ".."; 6 | import { getEdgeLayouted, getNodeLayouted, getNodeSize } from "../../metadata"; 7 | 8 | const algorithms = { 9 | "elk-layered": "layered", 10 | "elk-mr-tree": "mrtree", 11 | }; 12 | 13 | const elk = new ELK({ algorithms: Object.values(algorithms) }); 14 | 15 | export type ELKLayoutAlgorithms = "elk-layered" | "elk-mr-tree"; 16 | 17 | export const layoutELK = async ( 18 | props: LayoutAlgorithmProps & { algorithm?: ELKLayoutAlgorithms } 19 | ) => { 20 | const { 21 | nodes, 22 | edges, 23 | direction, 24 | visibility, 25 | spacing, 26 | algorithm = "elk-mr-tree", 27 | } = props; 28 | const isHorizontal = direction === "horizontal"; 29 | 30 | const subWorkflowRootNodes: ReactflowNodeWithData[] = []; 31 | const layoutNodes = nodes.map((node) => { 32 | const incomers = getIncomers(node, nodes, edges); 33 | if (incomers.length < 1) { 34 | // Node without input is the root node of sub-workflow 35 | subWorkflowRootNodes.push(node); 36 | } 37 | const { widthWithDefault, heightWithDefault } = getNodeSize(node); 38 | const sourcePorts = node.data.sourceHandles.map((id) => ({ 39 | id, 40 | properties: { 41 | side: isHorizontal ? "EAST" : "SOUTH", 42 | }, 43 | })); 44 | const targetPorts = node.data.targetHandles.map((id) => ({ 45 | id, 46 | properties: { 47 | side: isHorizontal ? "WEST" : "NORTH", 48 | }, 49 | })); 50 | return { 51 | id: node.id, 52 | width: widthWithDefault, 53 | height: heightWithDefault, 54 | ports: [...targetPorts, ...sourcePorts], 55 | properties: { 56 | "org.eclipse.elk.portConstraints": "FIXED_ORDER", 57 | }, 58 | }; 59 | }); 60 | 61 | const layoutEdges = edges.map((edge) => { 62 | return { 63 | id: edge.id, 64 | sources: [edge.sourceHandle || edge.source], 65 | targets: [edge.targetHandle || edge.target], 66 | }; 67 | }); 68 | 69 | // Connect sub-workflows' root nodes to the rootNode 70 | const rootNode: any = { id: "#root", width: 1, height: 1 }; 71 | layoutNodes.push(rootNode); 72 | for (const subWorkflowRootNode of subWorkflowRootNodes) { 73 | layoutEdges.push({ 74 | id: `${rootNode.id}-${subWorkflowRootNode.id}`, 75 | sources: [rootNode.id], 76 | targets: [subWorkflowRootNode.id], 77 | }); 78 | } 79 | 80 | const layouted = await elk 81 | .layout({ 82 | id: "@root", 83 | children: layoutNodes, 84 | edges: layoutEdges, 85 | layoutOptions: { 86 | // - https://www.eclipse.org/elk/reference/algorithms.html 87 | "elk.algorithm": algorithms[algorithm], 88 | "elk.direction": isHorizontal ? "RIGHT" : "DOWN", 89 | // - https://www.eclipse.org/elk/reference/options.html 90 | "elk.spacing.nodeNode": isHorizontal 91 | ? spacing.y.toString() 92 | : spacing.x.toString(), 93 | "elk.layered.spacing.nodeNodeBetweenLayers": isHorizontal 94 | ? spacing.x.toString() 95 | : spacing.y.toString(), 96 | }, 97 | }) 98 | .catch((e) => { 99 | console.log("❌ ELK layout failed", e); 100 | }); 101 | 102 | if (!layouted?.children) { 103 | return; 104 | } 105 | 106 | const layoutedNodePositions = layouted.children.reduce((pre, v) => { 107 | pre[v.id] = { 108 | x: v.x ?? 0, 109 | y: v.y ?? 0, 110 | }; 111 | return pre; 112 | }, {} as Record); 113 | 114 | return { 115 | nodes: nodes.map((node) => { 116 | const position = layoutedNodePositions[node.id]; 117 | return getNodeLayouted({ node, position, direction, visibility }); 118 | }), 119 | edges: edges.map((edge) => getEdgeLayouted({ edge, visibility })), 120 | }; 121 | }; 122 | 123 | export const kElkAlgorithms: Record = Object.keys( 124 | algorithms 125 | ).reduce((pre, algorithm) => { 126 | pre[algorithm] = (props: any) => { 127 | return layoutELK({ ...props, algorithm }); 128 | }; 129 | return pre; 130 | }, {} as any); 131 | -------------------------------------------------------------------------------- /src/layout/node/algorithms/origin.ts: -------------------------------------------------------------------------------- 1 | import { LayoutAlgorithm } from ".."; 2 | import { getEdgeLayouted, getNodeLayouted } from "../../metadata"; 3 | 4 | /** 5 | * Positions all nodes at the origin (0,0) in the layout. 6 | */ 7 | export const layoutOrigin: LayoutAlgorithm = async (props) => { 8 | const { nodes, edges, direction, visibility } = props; 9 | return { 10 | nodes: nodes.map((node) => { 11 | return getNodeLayouted({ 12 | node, 13 | direction, 14 | visibility, 15 | position: { x: 0, y: 0 }, 16 | }); 17 | }), 18 | edges: edges.map((edge) => getEdgeLayouted({ edge, visibility })), 19 | }; 20 | }; 21 | -------------------------------------------------------------------------------- /src/layout/node/index.ts: -------------------------------------------------------------------------------- 1 | import { removeEmpty } from "@/utils/base"; 2 | 3 | import { Reactflow } from "../../data/types"; 4 | import { D3DAGLayoutAlgorithms, kD3DAGAlgorithms } from "./algorithms/d3-dag"; 5 | import { layoutD3Hierarchy } from "./algorithms/d3-hierarchy"; 6 | import { layoutDagreTree } from "./algorithms/dagre-tree"; 7 | import { ELKLayoutAlgorithms, kElkAlgorithms } from "./algorithms/elk"; 8 | import { layoutOrigin } from "./algorithms/origin"; 9 | 10 | export type LayoutDirection = "vertical" | "horizontal"; 11 | export type LayoutVisibility = "visible" | "hidden"; 12 | export interface LayoutSpacing { 13 | x: number; 14 | y: number; 15 | } 16 | 17 | export type ReactflowLayoutConfig = { 18 | algorithm: LayoutAlgorithms; 19 | direction: LayoutDirection; 20 | spacing: LayoutSpacing; 21 | /** 22 | * Whether to hide the layout 23 | * 24 | * We may need to hide layout if node sizes are not available during the first layout. 25 | */ 26 | visibility: LayoutVisibility; 27 | /** 28 | * Whether to reverse the order of source handles. 29 | */ 30 | reverseSourceHandles: boolean; 31 | }; 32 | 33 | export type LayoutAlgorithmProps = Reactflow & 34 | Omit; 35 | 36 | export type LayoutAlgorithm = ( 37 | props: LayoutAlgorithmProps 38 | ) => Promise; 39 | 40 | export const kLayoutAlgorithms: Record = { 41 | origin: layoutOrigin, 42 | "dagre-tree": layoutDagreTree, 43 | "d3-hierarchy": layoutD3Hierarchy, 44 | ...kElkAlgorithms, 45 | ...kD3DAGAlgorithms, 46 | }; 47 | 48 | export const kDefaultLayoutConfig: ReactflowLayoutConfig = { 49 | algorithm: "elk-mr-tree", 50 | direction: "vertical", 51 | visibility: "visible", 52 | spacing: { x: 120, y: 120 }, 53 | reverseSourceHandles: false, 54 | }; 55 | 56 | export type LayoutAlgorithms = 57 | | "origin" 58 | | "dagre-tree" 59 | | "d3-hierarchy" 60 | | ELKLayoutAlgorithms 61 | | D3DAGLayoutAlgorithms; 62 | 63 | export type ILayoutReactflow = Reactflow & Partial; 64 | 65 | export const layoutReactflow = async ( 66 | options: ILayoutReactflow 67 | ): Promise => { 68 | const config = { ...kDefaultLayoutConfig, ...removeEmpty(options) }; 69 | const { nodes = [], edges = [] } = config; 70 | const layout = kLayoutAlgorithms[config.algorithm]; 71 | let result = await layout({ ...config, nodes, edges }); 72 | if (!result) { 73 | // If the layout fails, fallback to the origin layout 74 | result = await layoutReactflow({ 75 | ...config, 76 | nodes, 77 | edges, 78 | algorithm: "origin", 79 | }); 80 | } 81 | return result!; 82 | }; 83 | -------------------------------------------------------------------------------- /src/layout/useAutoLayout.ts: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | import { nextTick } from "@/utils/base"; 4 | 5 | import { getReactflowData, kReactflow } from "../states/reactflow"; 6 | import { getRootNode } from "./metadata"; 7 | import { ILayoutReactflow, layoutReactflow } from "./node"; 8 | 9 | const layoutWithFlush = async (options: ILayoutReactflow) => { 10 | const layout = await layoutReactflow(options); 11 | kReactflow.instance?.setNodes(layout.nodes); 12 | kReactflow.instance?.setEdges(layout.edges); 13 | 14 | // Check if the node has been correctly measured. Adjust the condition as needed based on your use case. 15 | const isMeasured = () => !!kReactflow.instance?.getNodes()[0]?.measured; 16 | while (!isMeasured()) { 17 | await nextTick(10); 18 | } 19 | 20 | const { nodes, edges } = getReactflowData(); 21 | return { layout, nodes, edges }; 22 | }; 23 | 24 | export const useAutoLayout = () => { 25 | const [isDirty, setIsDirty] = useState(false); 26 | 27 | const layout = async (options: ILayoutReactflow) => { 28 | if (!kReactflow.instance || isDirty || options.nodes.length < 1) { 29 | return; 30 | } 31 | 32 | setIsDirty(true); 33 | // Perform the first layout to measure node sizes 34 | const firstLayout = await layoutWithFlush({ 35 | ...options, 36 | visibility: "hidden", // Hide layout during the first layout pass 37 | }); 38 | // Perform the second layout using actual node sizes 39 | const secondLayout = await layoutWithFlush({ 40 | visibility: "visible", 41 | ...options, 42 | nodes: firstLayout.nodes ?? options.nodes, 43 | edges: firstLayout.edges ?? options.edges, 44 | }); 45 | setIsDirty(false); 46 | 47 | // Center the viewpoint to the position of the root node 48 | const root = getRootNode(secondLayout.layout.nodes); 49 | // Give it a little offset so it's visually centered 50 | const offset = 51 | options.direction === "horizontal" 52 | ? { 53 | x: 0.2 * document.body.clientWidth, 54 | y: 0 * document.body.clientHeight, 55 | } 56 | : { 57 | x: 0 * document.body.clientHeight, 58 | y: 0.3 * document.body.clientHeight, 59 | }; 60 | if (root) { 61 | kReactflow.instance.setCenter( 62 | root.position.x + offset.x, 63 | root.position.y + offset.y, 64 | { 65 | zoom: 1, 66 | } 67 | ); 68 | } 69 | return secondLayout.layout; 70 | }; 71 | 72 | return { layout, isDirty }; 73 | }; 74 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import "./index.css"; 4 | import { WorkFlow } from "./App.tsx"; 5 | 6 | ReactDOM.createRoot(document.getElementById("root")!).render( 7 | 8 | 9 | 10 | ); 11 | -------------------------------------------------------------------------------- /src/states/reactflow.ts: -------------------------------------------------------------------------------- 1 | import type { useStoreApi } from "@xyflow/react"; 2 | import { ReactFlowInstance } from "@xyflow/react"; 3 | 4 | import { ReactflowEdgeWithData, ReactflowNodeWithData } from "../data/types"; 5 | 6 | export const kReactflow: { 7 | instance?: ReactFlowInstance; 8 | store?: ReturnType; 9 | } = {}; 10 | 11 | export const getReactflowData = () => { 12 | const nodes = (kReactflow.instance?.getNodes() ?? 13 | []) as ReactflowNodeWithData[]; 14 | const edges = (kReactflow.instance?.getEdges() ?? 15 | []) as ReactflowEdgeWithData[]; 16 | return { 17 | nodes, 18 | edges, 19 | nodesMap: nodes.reduce((pre, v) => { 20 | pre[v.id] = v; 21 | return pre; 22 | }, {} as Record), 23 | edgesMap: edges.reduce((pre, v) => { 24 | pre[v.id] = v; 25 | return pre; 26 | }, {} as Record), 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /src/utils/base.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | 3 | export const nextTick = async (frames = 1) => { 4 | const _nextTick = async (idx: number) => { 5 | return new Promise((resolve) => { 6 | requestAnimationFrame(() => resolve(idx)); 7 | }); 8 | }; 9 | for (let i = 0; i < frames; i++) { 10 | await _nextTick(i); 11 | } 12 | }; 13 | 14 | export const firstOf = (datas?: T[]) => 15 | datas ? (datas.length < 1 ? undefined : datas[0]) : undefined; 16 | 17 | export const lastOf = (datas?: T[]) => 18 | datas ? (datas.length < 1 ? undefined : datas[datas.length - 1]) : undefined; 19 | 20 | export const randomInt = (min: number, max?: number) => { 21 | if (!max) { 22 | max = min; 23 | min = 0; 24 | } 25 | return Math.floor(Math.random() * (max - min + 1) + min); 26 | }; 27 | 28 | export const pickOne = (datas: T[]) => 29 | datas.length < 1 ? undefined : datas[randomInt(datas.length - 1)]; 30 | 31 | export const range = (start: number, end?: number) => { 32 | if (!end) { 33 | end = start; 34 | start = 0; 35 | } 36 | return Array.from({ length: end - start }, (_, index) => start + index); 37 | }; 38 | 39 | /** 40 | * clamp(-1,0,1)=0 41 | */ 42 | export function clamp(num: number, min: number, max: number): number { 43 | return num < max ? (num > min ? num : min) : max; 44 | } 45 | 46 | export const toSet = (datas: T[], byKey?: (e: T) => any) => { 47 | if (byKey) { 48 | const keys: Record = {}; 49 | const newDatas: T[] = []; 50 | datas.forEach((e) => { 51 | const key = jsonEncode({ key: byKey(e) }) as any; 52 | if (!keys[key]) { 53 | newDatas.push(e); 54 | keys[key] = true; 55 | } 56 | }); 57 | return newDatas; 58 | } 59 | return Array.from(new Set(datas)); 60 | }; 61 | 62 | export function jsonEncode(obj: any, prettier = false) { 63 | try { 64 | return prettier ? JSON.stringify(obj, undefined, 4) : JSON.stringify(obj); 65 | } catch (error) { 66 | return undefined; 67 | } 68 | } 69 | 70 | export function jsonDecode(json: string | undefined) { 71 | if (json == undefined) return undefined; 72 | try { 73 | return JSON.parse(json!); 74 | } catch (error) { 75 | return undefined; 76 | } 77 | } 78 | 79 | export function removeEmpty(data: T): T { 80 | if (Array.isArray(data)) { 81 | return data.filter((e) => e != undefined) as any; 82 | } 83 | const res = {} as any; 84 | for (const key in data) { 85 | if (data[key] != undefined) { 86 | res[key] = data[key]; 87 | } 88 | } 89 | return res; 90 | } 91 | 92 | export const deepClone = (obj: T): T => { 93 | if (obj === null || typeof obj !== "object") { 94 | return obj; 95 | } 96 | 97 | if (Array.isArray(obj)) { 98 | const copy: any[] = []; 99 | obj.forEach((item, index) => { 100 | copy[index] = deepClone(item); 101 | }); 102 | 103 | return copy as unknown as T; 104 | } 105 | 106 | const copy = {} as T; 107 | 108 | for (const key in obj) { 109 | if (Object.prototype.hasOwnProperty.call(obj, key)) { 110 | (copy as any)[key] = deepClone((obj as any)[key]); 111 | } 112 | } 113 | 114 | return copy; 115 | }; 116 | -------------------------------------------------------------------------------- /src/utils/diff.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | 3 | // Source: https://github.com/AsyncBanana/microdiff 4 | 5 | interface Difference { 6 | type: "CREATE" | "REMOVE" | "CHANGE"; 7 | path: (string | number)[]; 8 | value?: any; 9 | } 10 | interface Options { 11 | cyclesFix: boolean; 12 | } 13 | 14 | const t = true; 15 | const richTypes = { Date: t, RegExp: t, String: t, Number: t }; 16 | 17 | export function isEqual(oldObj: any, newObj: any): boolean { 18 | return ( 19 | diff( 20 | { 21 | obj: oldObj, 22 | }, 23 | { obj: newObj } 24 | ).length < 1 25 | ); 26 | } 27 | 28 | export const isNotEqual = (oldObj: any, newObj: any) => 29 | !isEqual(oldObj, newObj); 30 | 31 | function diff( 32 | obj: Record | any[], 33 | newObj: Record | any[], 34 | options: Partial = { cyclesFix: true }, 35 | _stack: Record[] = [] 36 | ): Difference[] { 37 | const diffs: Difference[] = []; 38 | const isObjArray = Array.isArray(obj); 39 | 40 | for (const key in obj) { 41 | const objKey = obj[key]; 42 | const path = isObjArray ? Number(key) : key; 43 | if (!(key in newObj)) { 44 | diffs.push({ 45 | type: "REMOVE", 46 | path: [path], 47 | }); 48 | continue; 49 | } 50 | const newObjKey = newObj[key]; 51 | const areObjects = 52 | typeof objKey === "object" && typeof newObjKey === "object"; 53 | if ( 54 | objKey && 55 | newObjKey && 56 | areObjects && 57 | !richTypes[Object.getPrototypeOf(objKey).constructor.name] && 58 | (options.cyclesFix ? !_stack.includes(objKey) : true) 59 | ) { 60 | const nestedDiffs = diff( 61 | objKey, 62 | newObjKey, 63 | options, 64 | options.cyclesFix ? _stack.concat([objKey]) : [] 65 | ); 66 | // eslint-disable-next-line prefer-spread 67 | diffs.push.apply( 68 | diffs, 69 | nestedDiffs.map((difference) => { 70 | difference.path.unshift(path); 71 | 72 | return difference; 73 | }) 74 | ); 75 | } else if ( 76 | objKey !== newObjKey && 77 | !( 78 | areObjects && 79 | (Number.isNaN(objKey) 80 | ? String(objKey) === String(newObjKey) 81 | : Number(objKey) === Number(newObjKey)) 82 | ) 83 | ) { 84 | diffs.push({ 85 | path: [path], 86 | type: "CHANGE", 87 | value: newObjKey, 88 | }); 89 | } 90 | } 91 | 92 | const isNewObjArray = Array.isArray(newObj); 93 | 94 | for (const key in newObj) { 95 | if (!(key in obj)) { 96 | diffs.push({ 97 | type: "CREATE", 98 | path: [isNewObjArray ? Number(key) : key], 99 | value: newObj[key], 100 | }); 101 | } 102 | } 103 | 104 | return diffs; 105 | } 106 | -------------------------------------------------------------------------------- /src/utils/uuid.ts: -------------------------------------------------------------------------------- 1 | export function uuid(): string { 2 | const uuid = new Array(36); 3 | for (let i = 0; i < 36; i++) { 4 | uuid[i] = Math.floor(Math.random() * 16); 5 | } 6 | uuid[14] = 4; 7 | uuid[19] = uuid[19] &= ~(1 << 2); 8 | uuid[19] = uuid[19] |= 1 << 3; 9 | uuid[8] = uuid[13] = uuid[18] = uuid[23] = "-"; 10 | return uuid.map((x) => x.toString(16)).join(""); 11 | } 12 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | 23 | "paths": { 24 | "@/*": ["./src/*"], 25 | } 26 | }, 27 | "include": ["src"], 28 | "references": [{ "path": "./tsconfig.node.json" }] 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "strict": true 9 | }, 10 | "include": ["vite.config.ts"] 11 | } 12 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import * as path from "path"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | resolve: { 9 | alias: [{ find: "@", replacement: path.resolve("src/") }], 10 | }, 11 | }); 12 | --------------------------------------------------------------------------------