├── .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 |
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 |
--------------------------------------------------------------------------------