├── .babelrc.json ├── .cspell.json ├── .eslintrc.json ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ └── feature_request.yml ├── images │ └── example.gif └── workflows │ ├── codeql.yml │ ├── main.yml │ └── stale.yml ├── .gitignore ├── .np-config.json ├── .prettierrc ├── .storybook ├── main.js └── preview.js ├── .vscode └── settings.json ├── .yarn └── releases │ └── yarn-1.22.19.cjs ├── .yarnrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── src ├── SmartBezierEdge │ └── index.tsx ├── SmartEdge │ └── index.tsx ├── SmartStepEdge │ └── index.tsx ├── SmartStraightEdge │ └── index.tsx ├── functions │ ├── createGrid.ts │ ├── drawSvgPath.ts │ ├── generatePath.ts │ ├── getBoundingBoxes.ts │ ├── guaranteeWalkablePath.ts │ ├── index.ts │ ├── pointConversion.ts │ └── utils.ts ├── getSmartEdge │ └── index.ts ├── index.tsx └── stories │ ├── CustomLabel.tsx │ ├── DummyData.ts │ ├── GraphWrapper.tsx │ ├── SimulateDragAndDrop.ts │ ├── SmartEdge.stories.tsx │ └── SmartEdgeInteractions.stories.tsx ├── tsconfig.json └── yarn.lock /.babelrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "sourceType": "unambiguous", 3 | "presets": [ 4 | [ 5 | "@babel/preset-env", 6 | { 7 | "targets": { 8 | "chrome": 100, 9 | "safari": 15, 10 | "firefox": 91 11 | } 12 | } 13 | ], 14 | "@babel/preset-typescript", 15 | "@babel/preset-react" 16 | ], 17 | "plugins": [] 18 | } -------------------------------------------------------------------------------- /.cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2", 3 | "language": "en,lorem", 4 | "dictionaries": ["en_US", "en-gb", "fonts", "npm", "html", "css"], 5 | "ignorePaths": [".cspell.json", ".eslintrc.js", "package.json", "yarn.lock"], 6 | "allowCompoundWords": true, 7 | "words": [], 8 | "flagWords": [] 9 | } 10 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "project": ["./tsconfig.json"] 4 | }, 5 | "plugins": ["prettier", "storybook"], 6 | "extends": [ 7 | "@tisoap/eslint-config-ts-react", 8 | "plugin:prettier/recommended", 9 | "plugin:storybook/recommended" 10 | ], 11 | "rules": { 12 | "react/no-multi-comp": "off", 13 | "prettier/prettier": ["error", {}, { "usePrettierrc": true }] 14 | }, 15 | "overrides": [ 16 | { 17 | "files": ["*.stories.tsx"], 18 | "rules": { 19 | "@typescript-eslint/await-thenable": "off" 20 | } 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: tisoap 2 | github: tisoap 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report a bug or issue on the smart edge 3 | labels: [bug] 4 | body: 5 | - type: textarea 6 | id: description 7 | attributes: 8 | label: Describe the Bug 9 | description: Provide a clear and concise description of the challenge you are running into. 10 | validations: 11 | required: true 12 | - type: input 13 | id: link 14 | attributes: 15 | label: Minimal Example 16 | description: | 17 | Share an URL with a minimal example on CodeSandbox (https://codesandbox.io/) or a minimal GitHub repository where the error happens. Please read these tips for providing a minimal example: https://stackoverflow.com/help/mcve. 18 | placeholder: | 19 | e.g. https://codesandbox.io/s/...... OR GitHub Repo 20 | validations: 21 | required: true 22 | - type: textarea 23 | id: steps 24 | attributes: 25 | label: Steps to Reproduce the Bug or Issue 26 | description: Describe the steps we have to take to reproduce the behavior. 27 | placeholder: | 28 | 1. Go to '...' 29 | 2. Click on '....' 30 | 3. Scroll down to '....' 31 | 4. See error 32 | validations: 33 | required: true 34 | - type: textarea 35 | id: expected 36 | attributes: 37 | label: Expected behavior 38 | description: Provide a clear and concise description of what you expected to happen. 39 | placeholder: | 40 | As a user, I expected ___ behavior but i am seeing ___ 41 | validations: 42 | required: true 43 | - type: textarea 44 | id: screenshots_or_videos 45 | attributes: 46 | label: Screenshots or Videos 47 | description: | 48 | If applicable, add screenshots or a video to help explain your problem. 49 | placeholder: | 50 | You can drag your video or image files inside of this editor ↓ 51 | - type: textarea 52 | id: platform 53 | attributes: 54 | label: Platform 55 | value: | 56 | - OS: [e.g. macOS, Windows, Linux] 57 | - Browser: [e.g. Chrome, Safari, Firefox] 58 | - Version: [e.g. 91.1] 59 | validations: 60 | required: true 61 | - type: textarea 62 | id: additional 63 | attributes: 64 | label: Additional context 65 | description: Add any other context about the problem here. 66 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest a new feature for the smart edge 3 | labels: [enhancement] 4 | body: 5 | - type: textarea 6 | id: problem 7 | attributes: 8 | label: Is your feature request related to a problem? Please describe. 9 | description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 10 | validations: 11 | required: true 12 | - type: textarea 13 | id: solution 14 | attributes: 15 | label: Describe the solution you'd like 16 | description: A clear and concise description of what you want to happen. 17 | validations: 18 | required: true 19 | - type: textarea 20 | id: alternatives 21 | attributes: 22 | label: Describe alternatives you've considered 23 | description: A clear and concise description of any alternative solutions or features you've considered. 24 | validations: 25 | required: true 26 | - type: textarea 27 | id: context 28 | attributes: 29 | label: Additional context 30 | description: Add any other context or screenshots about the feature request here. 31 | placeholder: | 32 | You can drag your video or image files inside of this editor ↓ 33 | validations: 34 | required: false 35 | -------------------------------------------------------------------------------- /.github/images/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tisoap/react-flow-smart-edge/01d4baeb549d95c4b010920be8a954344144319f/.github/images/example.gif -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '15 1 * * 4' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | # Runner size impacts CodeQL analysis time. To learn more, please see: 27 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 28 | # - https://gh.io/supported-runners-and-hardware-resources 29 | # - https://gh.io/using-larger-runners 30 | # Consider using larger runners for possible analysis time improvements. 31 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 32 | timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} 33 | permissions: 34 | actions: read 35 | contents: read 36 | security-events: write 37 | 38 | strategy: 39 | fail-fast: false 40 | matrix: 41 | language: [ 'javascript' ] 42 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby', 'swift' ] 43 | # Use only 'java' to analyze code written in Java, Kotlin or both 44 | # Use only 'javascript' to analyze code written in JavaScript, TypeScript or both 45 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 46 | 47 | steps: 48 | - name: Checkout repository 49 | uses: actions/checkout@v4 50 | 51 | # Initializes the CodeQL tools for scanning. 52 | - name: Initialize CodeQL 53 | uses: github/codeql-action/init@v2 54 | with: 55 | languages: ${{ matrix.language }} 56 | # If you wish to specify custom queries, you can do so here or in a config file. 57 | # By default, queries listed here will override any specified in a config file. 58 | # Prefix the list here with "+" to use these queries and those in the config file. 59 | 60 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 61 | # queries: security-extended,security-and-quality 62 | 63 | 64 | # Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift). 65 | # If this step fails, then you should remove it and run the build manually (see below) 66 | - name: Autobuild 67 | uses: github/codeql-action/autobuild@v2 68 | 69 | # ℹ️ Command-line programs to run using the OS shell. 70 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 71 | 72 | # If the Autobuild fails above, remove it and uncomment the following three lines. 73 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 74 | 75 | # - run: | 76 | # echo "Run, Build Application using script" 77 | # ./location_of_script_within_repo/buildscript.sh 78 | 79 | - name: Perform CodeQL Analysis 80 | uses: github/codeql-action/analyze@v2 81 | with: 82 | category: "/language:${{matrix.language}}" 83 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | jobs: 8 | build: 9 | name: Build and Test 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | cache: "yarn" 18 | 19 | - name: Install Node 20 | uses: actions/setup-node@v3 21 | with: 22 | node-version: "18" 23 | 24 | - name: Install Dependencies 25 | run: yarn --frozen-lockfile --silent 26 | 27 | - name: Check Types 28 | run: yarn check-types 29 | 30 | - name: Lint 31 | run: yarn lint 32 | 33 | - name: Build 34 | run: yarn build 35 | 36 | - name: Publish to Chromatic 37 | uses: chromaui/action@v1 38 | with: 39 | projectToken: f5598c842f1a 40 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: Close inactive issues 2 | on: 3 | schedule: 4 | - cron: '30 1 * * *' 5 | workflow_dispatch: 6 | jobs: 7 | close-issues: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | issues: write 11 | pull-requests: write 12 | steps: 13 | - uses: actions/stale@v8 14 | with: 15 | operations-per-run: 60 16 | days-before-issue-stale: 7 17 | days-before-issue-close: 7 18 | stale-issue-label: 'stale' 19 | stale-issue-message: 'This issue is stale because it has been open for 7 days with no activity.' 20 | close-issue-message: 'This issue was closed because it has been inactive for 7 days since being marked as stale.' 21 | days-before-pr-stale: 7 22 | days-before-pr-close: 7 23 | exempt-issue-labels: pinned,security 24 | repo-token: ${{ secrets.GITHUB_TOKEN }} 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .cache 5 | dist 6 | storybook-static 7 | .parcel-cache 8 | tmp 9 | -------------------------------------------------------------------------------- /.np-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "anyBranch ": true, 3 | "branch": "main", 4 | "releaseDraft": true, 5 | "tests": true, 6 | "yarn": true 7 | } 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "useTabs": true, 4 | "printWidth": 80, 5 | "singleQuote": true, 6 | "trailingComma": "none", 7 | "jsxSingleQuote": true 8 | } 9 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ['../src/stories/**/*.stories.@(ts|tsx|js|jsx)'], 3 | addons: ['@storybook/addon-essentials', '@storybook/addon-interactions'], 4 | typescript: { 5 | check: true 6 | }, 7 | docs: { 8 | autodocs: true 9 | }, 10 | framework: { 11 | name: "@storybook/react-webpack5", 12 | options: {} 13 | }, 14 | core: { 15 | disableWhatsNewNotifications: true 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import 'reactflow/dist/style.css' 2 | 3 | // https://storybook.js.org/docs/react/writing-stories/parameters#global-parameters 4 | export const parameters = { 5 | // https://storybook.js.org/docs/react/essentials/actions#automatically-matching-args 6 | actions: { argTypesRegex: '^on.*' } 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "editor.formatOnSave": true, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": true 6 | }, 7 | "[html]": { 8 | "editor.defaultFormatter": "esbenp.prettier-vscode" 9 | }, 10 | "[json]": { 11 | "editor.defaultFormatter": "esbenp.prettier-vscode" 12 | }, 13 | "cSpell.words": [ 14 | "bahmutov", 15 | "bezier", 16 | "chromaui", 17 | "drawedge", 18 | "flowtype", 19 | "generatepath", 20 | "github", 21 | "predeploy", 22 | "reactflow", 23 | "shopify", 24 | "sonarjs", 25 | "tisoap", 26 | "tsdx", 27 | "wontfix" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | yarn-path ".yarn/releases/yarn-1.22.19.cjs" 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | Please check the [releases](https://github.com/tisoap/react-flow-smart-edge/releases) page for information about each release. 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Tiso Alvarez Puccinelli 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 | # React Flow Smart Edge 2 | 3 | Custom Edges for React Flow that never intersect with other nodes, using pathfinding. 4 | 5 | ![CI](https://github.com/tisoap/react-flow-smart-edge/actions/workflows/main.yml/badge.svg?branch=main) 6 | ![Code Quality](https://github.com/tisoap/react-flow-smart-edge/actions/workflows/codeql-analysis.yml/badge.svg?branch=main) 7 | ![TypeScript](https://shields.io/badge/TypeScript-3178C6?logo=TypeScript&logoColor=white) 8 | ![Storybook](https://img.shields.io/badge/Storybook-FF4785?logo=storybook&logoColor=white) 9 | ![Testing Library](https://img.shields.io/badge/Testing_Library-DC3130?logo=testinglibrary&logoColor=white) 10 | ![ESLint](https://img.shields.io/badge/ESLint-3A33D1?logo=eslint&logoColor=white) 11 | 12 | ![Smart Edge](https://raw.githubusercontent.com/tisoap/react-flow-smart-edge/main/.github/images/example.gif) 13 | 14 | # Important Note 15 | 16 | This project is archived and won't receive any new updates in the future. 17 | 18 | ## Install 19 | 20 | With `npm`: 21 | 22 | ```bash 23 | npm install @tisoap/react-flow-smart-edge 24 | ``` 25 | 26 | With `yarn`: 27 | 28 | ```bash 29 | yarn add @tisoap/react-flow-smart-edge 30 | ``` 31 | 32 | This package is only compatible with [**version 11 or newer** of React Flow Edge](https://reactflow.dev/docs/guides/migrate-to-v11/). 33 | 34 | ## Support 35 | 36 | Like this project and want to show your support? Buy me a coffee: 37 | 38 | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/J3J472RAJ) 39 | 40 | _Really_ like this project? Sponsor me on GitHub: 41 | 42 | [![GitHub Sponsors](https://img.shields.io/static/v1?label=Sponsor%20Me&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86)](https://github.com/sponsors/tisoap) 43 | 44 | ## Usage 45 | 46 | This package ships with the following Smart Edges components: 47 | 48 | - `SmartBezierEdge`: A smart equivalent to React Flow's [BezierEdge](https://reactflow.dev/docs/api/edges/edge-types/) 49 | - `SmartStraightEdge`: A smart equivalent to React Flow's [StraightEdge](https://reactflow.dev/docs/api/edges/edge-types/) 50 | - `SmartStepEdge`: A smart equivalent to React Flow's [StepEdge](https://reactflow.dev/docs/api/edges/edge-types/) 51 | 52 | Each one can be imported individually as a named export. 53 | 54 | ### Example 55 | 56 | ```jsx 57 | import React from 'react' 58 | import { ReactFlow } from 'reactflow' 59 | import { SmartBezierEdge } from '@tisoap/react-flow-smart-edge' 60 | import 'reactflow/dist/style.css' 61 | 62 | const nodes = [ 63 | { 64 | id: '1', 65 | data: { label: 'Node 1' }, 66 | position: { x: 300, y: 100 } 67 | }, 68 | { 69 | id: '2', 70 | data: { label: 'Node 2' }, 71 | position: { x: 300, y: 200 } 72 | } 73 | ] 74 | 75 | const edges = [ 76 | { 77 | id: 'e21', 78 | source: '2', 79 | target: '1', 80 | type: 'smart' 81 | } 82 | ] 83 | 84 | // You can give any name to your edge types 85 | // https://reactflow.dev/docs/api/edges/custom-edges/ 86 | const edgeTypes = { 87 | smart: SmartBezierEdge 88 | } 89 | 90 | export const Graph = (props) => { 91 | const { children, ...rest } = props 92 | 93 | return ( 94 | 100 | {children} 101 | 102 | ) 103 | } 104 | ``` 105 | 106 | ## Edge Options 107 | 108 | All smart edges will take the exact same options as a [React Flow Edge](https://reactflow.dev/docs/api/edges/edge-options/). 109 | 110 | ## Custom Smart Edges 111 | 112 | You can have more control over how the edge is rerendered by creating a [custom edge](https://reactflow.dev/docs/api/edges/custom-edges/) and using the provided `getSmartEdge` function. It takes an object with the following keys: 113 | 114 | - `sourcePosition`, `targetPosition`, `sourceX`, `sourceY`, `targetX` and `targetY`: The same values your [custom edge](https://reactflow.dev/docs/examples/edges/custom-edge/) will take as props 115 | - `nodes`: An array containing all graph nodes, you can get it from the [`useNodes` hook](https://reactflow.dev/docs/api/hooks/use-nodes/) 116 | 117 | ### Example 118 | 119 | Just like you can use `getBezierPath` from `reactflow` to create a [custom edge with a button](https://reactflow.dev/docs/examples/edges/edge-with-button/), you can do the same with `getSmartEdge`: 120 | 121 | ```jsx 122 | import React from 'react' 123 | import { useNodes, BezierEdge } from 'reactflow' 124 | import { getSmartEdge } from '@tisoap/react-flow-smart-edge' 125 | 126 | const foreignObjectSize = 200 127 | 128 | export function SmartEdgeWithButtonLabel(props) { 129 | const { 130 | id, 131 | sourcePosition, 132 | targetPosition, 133 | sourceX, 134 | sourceY, 135 | targetX, 136 | targetY, 137 | style, 138 | markerStart, 139 | markerEnd 140 | } = props 141 | 142 | const nodes = useNodes() 143 | 144 | const getSmartEdgeResponse = getSmartEdge({ 145 | sourcePosition, 146 | targetPosition, 147 | sourceX, 148 | sourceY, 149 | targetX, 150 | targetY, 151 | nodes 152 | }) 153 | 154 | // If the value returned is null, it means "getSmartEdge" was unable to find 155 | // a valid path, and you should do something else instead 156 | if (getSmartEdgeResponse === null) { 157 | return 158 | } 159 | 160 | const { edgeCenterX, edgeCenterY, svgPathString } = getSmartEdgeResponse 161 | 162 | return ( 163 | <> 164 | 171 | 178 | 186 | 187 | 188 | ) 189 | } 190 | ``` 191 | 192 | ## Advanced Custom Smart Edges 193 | 194 | The `getSmartEdge` function also accepts an optional object `options`, which allows you to configure aspects of the path-finding algorithm. You may use it like so: 195 | 196 | ```js 197 | const myOptions = { 198 | // your configuration goes here 199 | nodePadding: 20, 200 | gridRatio: 15 201 | } 202 | 203 | // ... 204 | 205 | const getSmartEdgeResponse = getSmartEdge({ 206 | sourcePosition, 207 | targetPosition, 208 | sourceX, 209 | sourceY, 210 | targetX, 211 | targetY, 212 | nodes, 213 | // Pass down options in the getSmartEdge object 214 | options: myOptions 215 | }) 216 | ``` 217 | 218 | The `options` object accepts the following keys (they're all optional): 219 | 220 | - `nodePadding`: How many pixels of padding are added around nodes, or by how much should the edge avoid the walls of a node. Default `10`, minimum `2`. 221 | - `gridRatio`: The size in pixels of each square grid cell used for path-finding. Smaller values for a more accurate path, bigger for faster path-finding. Default `10`, minimum `2`. 222 | - `drawEdge`: Allows you to change the function responsible to draw the SVG line, by default it's the same used by `SmartBezierEdge` ([more below](#drawedge)) 223 | - `generatePath`: Allows you to change the function for the path-finding, by default it's the same used by `SmartBezierEdge` ([more below](#generatepath)) 224 | 225 | ### `drawEdge` 226 | 227 | With the `drawEdge` option, you can change the function used to generate the final [SVG path string](https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorial/Paths), used to draw the line. By default it's the `svgDrawSmoothLinePath` function (same as used by the `SmartBezierEdge`), but the package also includes `svgDrawStraightLinePath` (same as used by the `SmartStraightEdge` and `SmartStepEdge`), or you can provide your own. 228 | 229 | ```jsx 230 | import { 231 | getSmartEdge, 232 | // Available built-in SVG draw functions 233 | svgDrawSmoothLinePath, 234 | svgDrawStraightLinePath 235 | } from '@tisoap/react-flow-smart-edge' 236 | 237 | // Using provided SVG draw functions: 238 | const result = getSmartEdge({ 239 | // ... 240 | options: { 241 | drawEdge: svgDrawSmoothLinePath 242 | } 243 | }) 244 | 245 | // ...or using your own custom function 246 | const result = getSmartEdge({ 247 | // ... 248 | options: { 249 | drawEdge: (source, target, path) => { 250 | // your code goes here 251 | // ... 252 | return svgPath 253 | } 254 | } 255 | }) 256 | ``` 257 | 258 | The function you provided must comply with this signature: 259 | 260 | ```ts 261 | type SVGDrawFunction = ( 262 | source: XYPosition, // The starting {x, y} point 263 | target: XYPosition, // The ending {x, y} point 264 | path: number[][] // The sequence of points [x, y] the line must follow 265 | ) => string // A string to be used in the "d" property of the SVG line 266 | ``` 267 | 268 | For inspiration on how to implement your own, you can check the [`drawSvgPath.ts` source code](https://github.com/tisoap/react-flow-smart-edge/blob/main/src/functions/drawSvgPath.ts). 269 | 270 | ### `generatePath` 271 | 272 | With the `generatePath` option, you can change the function used to do [Pathfinding](https://en.wikipedia.org/wiki/Pathfinding). By default, it's the `pathfindingAStarDiagonal` function (same as used by the `SmartBezierEdge`), but the package also includes `pathfindingAStarNoDiagonal` (used by `SmartStraightEdge`) and `pathfindingJumpPointNoDiagonal` (used by `SmartStepEdge`), or your can provide your own. The built-in functions use the [`pathfinding` dependency](https://www.npmjs.com/package/pathfinding#advanced-usage) behind the scenes. 273 | 274 | ```jsx 275 | import { 276 | getSmartEdge, 277 | // Available built-in pathfinding functions 278 | pathfindingAStarDiagonal, 279 | pathfindingAStarNoDiagonal, 280 | pathfindingJumpPointNoDiagonal 281 | } from '@tisoap/react-flow-smart-edge' 282 | 283 | // Using provided pathfinding functions: 284 | const result = getSmartEdge({ 285 | // ... 286 | options: { 287 | generatePath: pathfindingJumpPointNoDiagonal 288 | } 289 | }) 290 | 291 | // ...or using your own custom function 292 | const result = getSmartEdge({ 293 | // ... 294 | options: { 295 | generatePath: (grid, start, end) => { 296 | // your code goes here 297 | // ... 298 | return { fullPath, smoothedPath } 299 | } 300 | } 301 | }) 302 | ``` 303 | 304 | The function you provide must comply with this signature: 305 | 306 | ```ts 307 | type PathFindingFunction = ( 308 | grid: Grid, // Grid representation of the graph 309 | start: XYPosition, // The starting {x, y} point 310 | end: XYPosition // The ending {x, y} point 311 | ) => { 312 | fullPath: number[][] // Array of points [x, y] representing the full path with all points 313 | smoothedPath: number[][] // Array of points [x, y] representing a smaller, compressed path 314 | } | null // The function should return null if it was unable to do pathfinding 315 | ``` 316 | 317 | For inspiration on how to implement your own, you can check the [`generatePath.ts` source code](https://github.com/tisoap/react-flow-smart-edge/blob/main/src/functions/generatePath.ts) and the [`pathfinding` dependency](https://www.npmjs.com/package/pathfinding#advanced-usage) documentation. 318 | 319 | ### Advanced Examples 320 | 321 | ```jsx 322 | import { 323 | getSmartEdge, 324 | svgDrawSmoothLinePath, 325 | svgDrawStraightLinePath 326 | pathfindingAStarDiagonal, 327 | pathfindingAStarNoDiagonal, 328 | pathfindingJumpPointNoDiagonal 329 | } from '@tisoap/react-flow-smart-edge' 330 | 331 | // ... 332 | 333 | // Same as importing "SmartBezierEdge" directly 334 | const bezierResult = getSmartEdge({ 335 | // ... 336 | options: { 337 | drawEdge: svgDrawSmoothLinePath, 338 | generatePath: pathfindingAStarDiagonal, 339 | } 340 | }) 341 | 342 | // Same as importing "SmartStepEdge" directly 343 | const stepResult = getSmartEdge({ 344 | // ... 345 | options: { 346 | drawEdge: svgDrawStraightLinePath, 347 | generatePath: pathfindingJumpPointNoDiagonal, 348 | } 349 | }) 350 | 351 | // Same as importing "SmartStraightEdge" directly 352 | const straightResult = getSmartEdge({ 353 | // ... 354 | options: { 355 | drawEdge: svgDrawStraightLinePath, 356 | generatePath: pathfindingAStarNoDiagonal, 357 | } 358 | }) 359 | ``` 360 | 361 | ## Storybook 362 | 363 | You can see live Storybook examples by visiting [this page](https://tisoap.github.io/react-flow-smart-edge/), and see their source code [here](https://github.com/tisoap/react-flow-smart-edge/blob/main/src/stories/SmartEdge.stories.tsx). 364 | 365 | ## License 366 | 367 | This project is [MIT](https://github.com/tisoap/react-flow-smart-edge/blob/main/LICENSE) licensed. 368 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('@jest/types').Config.InitialOptions} */ 2 | const config = { 3 | testEnvironment: 'jsdom' 4 | } 5 | 6 | module.exports = config 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@tisoap/react-flow-smart-edge", 3 | "version": "3.0.0", 4 | "keywords": [ 5 | "react", 6 | "typescript", 7 | "graph", 8 | "flow", 9 | "flowchart", 10 | "smart", 11 | "edge", 12 | "pathfinding", 13 | "react-flow-smart-edge" 14 | ], 15 | "homepage": "https://tisoap.github.io/react-flow-smart-edge/", 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/tisoap/react-flow-smart-edge.git" 19 | }, 20 | "license": "MIT", 21 | "author": "Tiso Alvarez Puccinelli", 22 | "type": "module", 23 | "main": "dist/index.js", 24 | "module": "dist/react-flow-smart-edge.esm.js", 25 | "typings": "dist/index.d.ts", 26 | "files": [ 27 | "dist", 28 | "src" 29 | ], 30 | "scripts": { 31 | "build": "yarn build-storybook && yarn build-component", 32 | "build-component": "dts build", 33 | "build-storybook": "storybook build", 34 | "check-types": "tsc --noEmit", 35 | "chromatic": "chromatic --exit-zero-on-changes --project-token f5598c842f1a", 36 | "deploy-component": "np --any-branch", 37 | "deploy-storybook": "gh-pages -d storybook-static", 38 | "install-playwright": "playwright install --with-deps", 39 | "lint": "dts lint src", 40 | "lint-fix": "dts lint src --fix", 41 | "predeploy": "yarn build", 42 | "prepare": "dts build", 43 | "start": "dts watch", 44 | "storybook": "storybook dev -p 6006 --ci", 45 | "test": "yarn check-types && yarn lint && yarn test-storybook-ci", 46 | "test-storybook": "test-storybook", 47 | "test-storybook-ci": "concurrently -k -s first -n 'SB,TEST' -c 'magenta,blue' 'http-server storybook-static --port 6006 --silent' 'wait-on tcp:6006 && yarn test-storybook --maxWorkers=2'", 48 | "ui": "yarn upgrade-interactive --latest" 49 | }, 50 | "husky": { 51 | "hooks": { 52 | "pre-commit": "dts lint" 53 | } 54 | }, 55 | "resolutions": { 56 | "string-width": "^4.2.3", 57 | "strip-ansi": "^6.0.1" 58 | }, 59 | "dependencies": { 60 | "pathfinding": "^0.4.18" 61 | }, 62 | "devDependencies": { 63 | "@babel/core": "^7.20.2", 64 | "@babel/plugin-syntax-flow": "^7.18.6", 65 | "@babel/plugin-transform-react-jsx": "^7.19.0", 66 | "@babel/preset-env": "^7.22.15", 67 | "@babel/preset-react": "^7.22.15", 68 | "@babel/preset-typescript": "^7.22.15", 69 | "@shopify/eslint-plugin": "^43.0.0", 70 | "@storybook/addon-essentials": "^7.4.0", 71 | "@storybook/addon-interactions": "^7.4.0", 72 | "@storybook/addons": "^7.4.0", 73 | "@storybook/components": "^7.4.0", 74 | "@storybook/core-events": "^7.4.0", 75 | "@storybook/jest": "^0.2.2", 76 | "@storybook/react": "^7.4.0", 77 | "@storybook/react-webpack5": "^7.4.0", 78 | "@storybook/test-runner": "^0.13.0", 79 | "@storybook/testing-library": "^0.2.0", 80 | "@storybook/theming": "^7.4.0", 81 | "@tisoap/eslint-config-ts-react": "^7.0.0", 82 | "@types/minimist": "^1.2.2", 83 | "@types/node": "^20.6.0", 84 | "@types/pathfinding": "^0.0.6", 85 | "@types/react": "^18.0.25", 86 | "@types/react-dom": "^18.0.9", 87 | "@typescript-eslint/eslint-plugin": "^6.6.0", 88 | "@typescript-eslint/parser": "^6.6.0", 89 | "chromatic": "^7.1.0", 90 | "concurrently": "^8.2.1", 91 | "dts-cli": "^2.0.3", 92 | "eslint": "^8.28.0", 93 | "eslint-config-prettier": "^9.0.0", 94 | "eslint-plugin-flowtype": "^8.0.3", 95 | "eslint-plugin-import": "^2.26.0", 96 | "eslint-plugin-jest-dom": "^5.1.0", 97 | "eslint-plugin-jsx-a11y": "^6.6.1", 98 | "eslint-plugin-prettier": "^5.0.0", 99 | "eslint-plugin-react": "^7.31.11", 100 | "eslint-plugin-react-hooks": "^4.6.0", 101 | "eslint-plugin-react-prefer-function-component": "^3.1.0", 102 | "eslint-plugin-sonarjs": "^0.21.0", 103 | "eslint-plugin-storybook": "^0.6.13", 104 | "eslint-plugin-testing-library": "^6.0.1", 105 | "eslint-plugin-unicorn": "^48.0.1", 106 | "gh-pages": "^6.0.0", 107 | "http-server": "^14.1.1", 108 | "husky": "^8.0.2", 109 | "jest": "^29.3.1", 110 | "jest-circus": "^29.3.1", 111 | "jest-environment-node": "^29.3.1", 112 | "np": "^8.0.4", 113 | "playwright": "^1.28.0", 114 | "prettier": "^3.0.3", 115 | "react": "^18.2.0", 116 | "react-dom": "^18.2.0", 117 | "reactflow": "^11.2.0", 118 | "require-from-string": "^2.0.2", 119 | "storybook": "^7.4.0", 120 | "string-width": "^4.2.3", 121 | "strip-ansi": "^6.0.1", 122 | "typescript": "^5.2.2", 123 | "wait-on": "^7.0.1", 124 | "webpack": "^5.75.0" 125 | }, 126 | "peerDependencies": { 127 | "react": ">=17", 128 | "react-dom": ">=17", 129 | "reactflow": ">=11", 130 | "typescript": ">=4.6" 131 | }, 132 | "engines": { 133 | "node": ">=18", 134 | "npm": ">=8" 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /src/SmartBezierEdge/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useNodes, BezierEdge } from 'reactflow' 3 | import { SmartEdge } from '../SmartEdge' 4 | import { svgDrawSmoothLinePath, pathfindingAStarDiagonal } from '../functions' 5 | import type { SmartEdgeOptions } from '../SmartEdge' 6 | import type { EdgeProps } from 'reactflow' 7 | 8 | const BezierConfiguration: SmartEdgeOptions = { 9 | drawEdge: svgDrawSmoothLinePath, 10 | generatePath: pathfindingAStarDiagonal, 11 | fallback: BezierEdge 12 | } 13 | 14 | export function SmartBezierEdge( 15 | props: EdgeProps 16 | ) { 17 | const nodes = useNodes() 18 | 19 | return ( 20 | 21 | {...props} 22 | options={BezierConfiguration} 23 | nodes={nodes} 24 | /> 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/SmartEdge/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { BezierEdge, BaseEdge } from 'reactflow' 3 | import { getSmartEdge } from '../getSmartEdge' 4 | import type { GetSmartEdgeOptions } from '../getSmartEdge' 5 | import type { EdgeProps, Node } from 'reactflow' 6 | 7 | export type EdgeElement = typeof BezierEdge 8 | 9 | export type SmartEdgeOptions = GetSmartEdgeOptions & { 10 | fallback?: EdgeElement 11 | } 12 | 13 | export interface SmartEdgeProps 14 | extends EdgeProps { 15 | nodes: Node[] 16 | options: SmartEdgeOptions 17 | } 18 | 19 | export function SmartEdge({ 20 | nodes, 21 | options, 22 | ...edgeProps 23 | }: SmartEdgeProps) { 24 | const { 25 | sourceX, 26 | sourceY, 27 | sourcePosition, 28 | targetX, 29 | targetY, 30 | targetPosition, 31 | style, 32 | label, 33 | labelStyle, 34 | labelShowBg, 35 | labelBgStyle, 36 | labelBgPadding, 37 | labelBgBorderRadius, 38 | markerEnd, 39 | markerStart, 40 | interactionWidth 41 | } = edgeProps 42 | 43 | const smartResponse = getSmartEdge({ 44 | sourcePosition, 45 | targetPosition, 46 | sourceX, 47 | sourceY, 48 | targetX, 49 | targetY, 50 | options, 51 | nodes 52 | }) 53 | 54 | const FallbackEdge = options.fallback || BezierEdge 55 | 56 | if (smartResponse === null) { 57 | return 58 | } 59 | 60 | const { edgeCenterX, edgeCenterY, svgPathString } = smartResponse 61 | 62 | return ( 63 | 78 | ) 79 | } 80 | 81 | export type SmartEdgeFunction = typeof SmartEdge 82 | -------------------------------------------------------------------------------- /src/SmartStepEdge/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useNodes, StepEdge } from 'reactflow' 3 | import { SmartEdge } from '../SmartEdge' 4 | import { 5 | svgDrawStraightLinePath, 6 | pathfindingJumpPointNoDiagonal 7 | } from '../functions' 8 | import type { SmartEdgeOptions } from '../SmartEdge' 9 | import type { EdgeProps } from 'reactflow' 10 | 11 | const StepConfiguration: SmartEdgeOptions = { 12 | drawEdge: svgDrawStraightLinePath, 13 | generatePath: pathfindingJumpPointNoDiagonal, 14 | fallback: StepEdge 15 | } 16 | 17 | export function SmartStepEdge( 18 | props: EdgeProps 19 | ) { 20 | const nodes = useNodes() 21 | 22 | return ( 23 | 24 | {...props} 25 | options={StepConfiguration} 26 | nodes={nodes} 27 | /> 28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/SmartStraightEdge/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useNodes, StraightEdge } from 'reactflow' 3 | import { SmartEdge } from '../SmartEdge' 4 | import { 5 | svgDrawStraightLinePath, 6 | pathfindingAStarNoDiagonal 7 | } from '../functions' 8 | import type { SmartEdgeOptions } from '../SmartEdge' 9 | import type { EdgeProps } from 'reactflow' 10 | 11 | const StraightConfiguration: SmartEdgeOptions = { 12 | drawEdge: svgDrawStraightLinePath, 13 | generatePath: pathfindingAStarNoDiagonal, 14 | fallback: StraightEdge 15 | } 16 | 17 | export function SmartStraightEdge< 18 | EdgeDataType = unknown, 19 | NodeDataType = unknown 20 | >(props: EdgeProps) { 21 | const nodes = useNodes() 22 | 23 | return ( 24 | 25 | {...props} 26 | options={StraightConfiguration} 27 | nodes={nodes} 28 | /> 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /src/functions/createGrid.ts: -------------------------------------------------------------------------------- 1 | import { Grid } from 'pathfinding' 2 | import { 3 | guaranteeWalkablePath, 4 | getNextPointFromPosition 5 | } from './guaranteeWalkablePath' 6 | import { graphToGridPoint } from './pointConversion' 7 | import { round, roundUp } from './utils' 8 | import type { NodeBoundingBox, GraphBoundingBox } from './getBoundingBoxes' 9 | import type { Position } from 'reactflow' 10 | 11 | export type PointInfo = { 12 | x: number 13 | y: number 14 | position: Position 15 | } 16 | 17 | export const createGrid = ( 18 | graph: GraphBoundingBox, 19 | nodes: NodeBoundingBox[], 20 | source: PointInfo, 21 | target: PointInfo, 22 | gridRatio = 2 23 | ) => { 24 | const { xMin, yMin, width, height } = graph 25 | 26 | // Create a grid representation of the graph box, where each cell is 27 | // equivalent to 10x10 pixels (or the grid ratio) on the graph. We'll use 28 | // this simplified grid to do pathfinding. 29 | const mapColumns = roundUp(width, gridRatio) / gridRatio + 1 30 | const mapRows = roundUp(height, gridRatio) / gridRatio + 1 31 | const grid = new Grid(mapColumns, mapRows) 32 | 33 | // Update the grid representation with the space the nodes take up 34 | nodes.forEach((node) => { 35 | const nodeStart = graphToGridPoint(node.topLeft, xMin, yMin, gridRatio) 36 | const nodeEnd = graphToGridPoint(node.bottomRight, xMin, yMin, gridRatio) 37 | 38 | for (let x = nodeStart.x; x < nodeEnd.x; x++) { 39 | for (let y = nodeStart.y; y < nodeEnd.y; y++) { 40 | grid.setWalkableAt(x, y, false) 41 | } 42 | } 43 | }) 44 | 45 | // Convert the starting and ending graph points to grid points 46 | const startGrid = graphToGridPoint( 47 | { 48 | x: round(source.x, gridRatio), 49 | y: round(source.y, gridRatio) 50 | }, 51 | xMin, 52 | yMin, 53 | gridRatio 54 | ) 55 | 56 | const endGrid = graphToGridPoint( 57 | { 58 | x: round(target.x, gridRatio), 59 | y: round(target.y, gridRatio) 60 | }, 61 | xMin, 62 | yMin, 63 | gridRatio 64 | ) 65 | 66 | // Guarantee a walkable path between the start and end points, even if the 67 | // source or target where covered by another node or by padding 68 | const startingNode = grid.getNodeAt(startGrid.x, startGrid.y) 69 | guaranteeWalkablePath(grid, startingNode, source.position) 70 | const endingNode = grid.getNodeAt(endGrid.x, endGrid.y) 71 | guaranteeWalkablePath(grid, endingNode, target.position) 72 | 73 | // Use the next closest points as the start and end points, so 74 | // pathfinding does not start too close to the nodes 75 | const start = getNextPointFromPosition(startingNode, source.position) 76 | const end = getNextPointFromPosition(endingNode, target.position) 77 | 78 | return { grid, start, end } 79 | } 80 | -------------------------------------------------------------------------------- /src/functions/drawSvgPath.ts: -------------------------------------------------------------------------------- 1 | import type { XYPosition } from 'reactflow' 2 | 3 | /** 4 | * Takes source and target {x, y} points, together with an array of number 5 | * tuples [x, y] representing the points along the path, and returns a string 6 | * to be used as the SVG path. 7 | */ 8 | export type SVGDrawFunction = ( 9 | source: XYPosition, 10 | target: XYPosition, 11 | path: number[][] 12 | ) => string 13 | 14 | /** 15 | * Draws a SVG path from a list of points, using straight lines. 16 | */ 17 | export const svgDrawStraightLinePath: SVGDrawFunction = ( 18 | source, 19 | target, 20 | path 21 | ) => { 22 | let svgPathString = `M ${source.x}, ${source.y} ` 23 | 24 | path.forEach((point) => { 25 | const [x, y] = point 26 | svgPathString += `L ${x}, ${y} ` 27 | }) 28 | 29 | svgPathString += `L ${target.x}, ${target.y} ` 30 | 31 | return svgPathString 32 | } 33 | 34 | /** 35 | * Draws a SVG path from a list of points, using rounded lines. 36 | */ 37 | export const svgDrawSmoothLinePath: SVGDrawFunction = ( 38 | source, 39 | target, 40 | path 41 | ) => { 42 | const points = [[source.x, source.y], ...path, [target.x, target.y]] 43 | return quadraticBezierCurve(points) 44 | } 45 | 46 | const quadraticBezierCurve = (points: number[][]) => { 47 | const X = 0 48 | const Y = 1 49 | let point = points[0] 50 | 51 | const first = points[0] 52 | let svgPath = `M${first[X]},${first[Y]}M` 53 | 54 | for (let i = 0; i < points.length; i++) { 55 | const next = points[i] 56 | const midPoint = getMidPoint(point[X], point[Y], next[X], next[Y]) 57 | 58 | svgPath += ` ${midPoint[X]},${midPoint[Y]}` 59 | svgPath += `Q${next[X]},${next[Y]}` 60 | point = next 61 | } 62 | 63 | const last = points[points.length - 1] 64 | svgPath += ` ${last[0]},${last[1]}` 65 | 66 | return svgPath 67 | } 68 | 69 | const getMidPoint = (Ax: number, Ay: number, Bx: number, By: number) => { 70 | const Zx = (Ax - Bx) / 2 + Bx 71 | const Zy = (Ay - By) / 2 + By 72 | return [Zx, Zy] 73 | } 74 | -------------------------------------------------------------------------------- /src/functions/generatePath.ts: -------------------------------------------------------------------------------- 1 | // FIXME: The "pathfinding" module doe not have proper typings. 2 | /* eslint-disable 3 | @typescript-eslint/no-unsafe-call, 4 | @typescript-eslint/no-unsafe-member-access, 5 | @typescript-eslint/no-unsafe-assignment, 6 | @typescript-eslint/ban-ts-comment, 7 | */ 8 | import { 9 | AStarFinder, 10 | JumpPointFinder, 11 | Util, 12 | DiagonalMovement 13 | } from 'pathfinding' 14 | import type { Grid } from 'pathfinding' 15 | import type { XYPosition } from 'reactflow' 16 | 17 | /** 18 | * Takes source and target {x, y} points, together with an grid representation 19 | * of the graph, and returns two arrays of number tuples [x, y]. The first 20 | * array represents the full path from source to target, and the second array 21 | * represents a condensed path from source to target. 22 | */ 23 | export type PathFindingFunction = ( 24 | grid: Grid, 25 | start: XYPosition, 26 | end: XYPosition 27 | ) => { 28 | fullPath: number[][] 29 | smoothedPath: number[][] 30 | } | null 31 | 32 | export const pathfindingAStarDiagonal: PathFindingFunction = ( 33 | grid, 34 | start, 35 | end 36 | ) => { 37 | try { 38 | const finder = new AStarFinder({ 39 | diagonalMovement: DiagonalMovement.Always 40 | }) 41 | const fullPath = finder.findPath(start.x, start.y, end.x, end.y, grid) 42 | const smoothedPath = Util.smoothenPath(grid, fullPath) 43 | if (fullPath.length === 0 || smoothedPath.length === 0) return null 44 | return { fullPath, smoothedPath } 45 | } catch { 46 | return null 47 | } 48 | } 49 | 50 | export const pathfindingAStarNoDiagonal: PathFindingFunction = ( 51 | grid, 52 | start, 53 | end 54 | ) => { 55 | try { 56 | const finder = new AStarFinder({ 57 | diagonalMovement: DiagonalMovement.Never 58 | }) 59 | const fullPath = finder.findPath(start.x, start.y, end.x, end.y, grid) 60 | const smoothedPath = Util.smoothenPath(grid, fullPath) 61 | if (fullPath.length === 0 || smoothedPath.length === 0) return null 62 | return { fullPath, smoothedPath } 63 | } catch { 64 | return null 65 | } 66 | } 67 | 68 | export const pathfindingJumpPointNoDiagonal: PathFindingFunction = ( 69 | grid, 70 | start, 71 | end 72 | ) => { 73 | try { 74 | // FIXME: The "pathfinding" module doe not have proper typings. 75 | // @ts-ignore 76 | const finder = new JumpPointFinder({ 77 | diagonalMovement: DiagonalMovement.Never 78 | }) 79 | const fullPath = finder.findPath(start.x, start.y, end.x, end.y, grid) 80 | const smoothedPath = fullPath 81 | if (fullPath.length === 0 || smoothedPath.length === 0) return null 82 | return { fullPath, smoothedPath } 83 | } catch { 84 | return null 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/functions/getBoundingBoxes.ts: -------------------------------------------------------------------------------- 1 | import { roundUp, roundDown } from './utils' 2 | import type { Node, XYPosition } from 'reactflow' 3 | 4 | export type NodeBoundingBox = { 5 | id: string 6 | width: number 7 | height: number 8 | topLeft: XYPosition 9 | bottomLeft: XYPosition 10 | topRight: XYPosition 11 | bottomRight: XYPosition 12 | } 13 | 14 | export type GraphBoundingBox = { 15 | width: number 16 | height: number 17 | topLeft: XYPosition 18 | bottomLeft: XYPosition 19 | topRight: XYPosition 20 | bottomRight: XYPosition 21 | xMax: number 22 | yMax: number 23 | xMin: number 24 | yMin: number 25 | } 26 | 27 | /** 28 | * Get the bounding box of all nodes and the graph itself, as X/Y coordinates 29 | * of all corner points. 30 | * 31 | * @param nodes The node list 32 | * @param nodePadding Optional padding to add to the node's and graph bounding boxes 33 | * @param roundTo Everything will be rounded to this nearest integer 34 | * @returns Graph and nodes bounding boxes. 35 | */ 36 | export const getBoundingBoxes = ( 37 | nodes: Node[], 38 | nodePadding = 2, 39 | roundTo = 2 40 | ) => { 41 | let xMax = Number.MIN_SAFE_INTEGER 42 | let yMax = Number.MIN_SAFE_INTEGER 43 | let xMin = Number.MAX_SAFE_INTEGER 44 | let yMin = Number.MAX_SAFE_INTEGER 45 | 46 | const nodeBoxes: NodeBoundingBox[] = nodes.map((node) => { 47 | const width = Math.max(node.width || 0, 1) 48 | const height = Math.max(node.height || 0, 1) 49 | 50 | const position: XYPosition = { 51 | x: node.positionAbsolute?.x || 0, 52 | y: node.positionAbsolute?.y || 0 53 | } 54 | 55 | const topLeft: XYPosition = { 56 | x: position.x - nodePadding, 57 | y: position.y - nodePadding 58 | } 59 | const bottomLeft: XYPosition = { 60 | x: position.x - nodePadding, 61 | y: position.y + height + nodePadding 62 | } 63 | const topRight: XYPosition = { 64 | x: position.x + width + nodePadding, 65 | y: position.y - nodePadding 66 | } 67 | const bottomRight: XYPosition = { 68 | x: position.x + width + nodePadding, 69 | y: position.y + height + nodePadding 70 | } 71 | 72 | if (roundTo > 0) { 73 | topLeft.x = roundDown(topLeft.x, roundTo) 74 | topLeft.y = roundDown(topLeft.y, roundTo) 75 | bottomLeft.x = roundDown(bottomLeft.x, roundTo) 76 | bottomLeft.y = roundUp(bottomLeft.y, roundTo) 77 | topRight.x = roundUp(topRight.x, roundTo) 78 | topRight.y = roundDown(topRight.y, roundTo) 79 | bottomRight.x = roundUp(bottomRight.x, roundTo) 80 | bottomRight.y = roundUp(bottomRight.y, roundTo) 81 | } 82 | 83 | if (topLeft.y < yMin) yMin = topLeft.y 84 | if (topLeft.x < xMin) xMin = topLeft.x 85 | if (bottomRight.y > yMax) yMax = bottomRight.y 86 | if (bottomRight.x > xMax) xMax = bottomRight.x 87 | 88 | return { 89 | id: node.id, 90 | width, 91 | height, 92 | topLeft, 93 | bottomLeft, 94 | topRight, 95 | bottomRight 96 | } 97 | }) 98 | 99 | const graphPadding = nodePadding * 2 100 | 101 | xMax = roundUp(xMax + graphPadding, roundTo) 102 | yMax = roundUp(yMax + graphPadding, roundTo) 103 | xMin = roundDown(xMin - graphPadding, roundTo) 104 | yMin = roundDown(yMin - graphPadding, roundTo) 105 | 106 | const topLeft: XYPosition = { 107 | x: xMin, 108 | y: yMin 109 | } 110 | 111 | const bottomLeft: XYPosition = { 112 | x: xMin, 113 | y: yMax 114 | } 115 | 116 | const topRight: XYPosition = { 117 | x: xMax, 118 | y: yMin 119 | } 120 | 121 | const bottomRight: XYPosition = { 122 | x: xMax, 123 | y: yMax 124 | } 125 | 126 | const width = Math.abs(topLeft.x - topRight.x) 127 | const height = Math.abs(topLeft.y - bottomLeft.y) 128 | 129 | const graphBox: GraphBoundingBox = { 130 | topLeft, 131 | bottomLeft, 132 | topRight, 133 | bottomRight, 134 | width, 135 | height, 136 | xMax, 137 | yMax, 138 | xMin, 139 | yMin 140 | } 141 | 142 | return { nodeBoxes, graphBox } 143 | } 144 | -------------------------------------------------------------------------------- /src/functions/guaranteeWalkablePath.ts: -------------------------------------------------------------------------------- 1 | import type { Grid } from 'pathfinding' 2 | import type { Position, XYPosition } from 'reactflow' 3 | 4 | type Direction = 'top' | 'bottom' | 'left' | 'right' 5 | 6 | export const getNextPointFromPosition = ( 7 | point: XYPosition, 8 | position: Direction 9 | ): XYPosition => { 10 | switch (position) { 11 | case 'top': 12 | return { x: point.x, y: point.y - 1 } 13 | case 'bottom': 14 | return { x: point.x, y: point.y + 1 } 15 | case 'left': 16 | return { x: point.x - 1, y: point.y } 17 | case 'right': 18 | return { x: point.x + 1, y: point.y } 19 | } 20 | } 21 | 22 | /** 23 | * Guarantee that the path is walkable, even if the point is inside a non 24 | * walkable area, by adding a walkable path in the direction of the point's 25 | * Position. 26 | */ 27 | export const guaranteeWalkablePath = ( 28 | grid: Grid, 29 | point: XYPosition, 30 | position: Position 31 | ) => { 32 | let node = grid.getNodeAt(point.x, point.y) 33 | while (!node.walkable) { 34 | grid.setWalkableAt(node.x, node.y, true) 35 | const next = getNextPointFromPosition(node, position) 36 | node = grid.getNodeAt(next.x, next.y) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/functions/index.ts: -------------------------------------------------------------------------------- 1 | export * from './createGrid' 2 | export * from './drawSvgPath' 3 | export * from './generatePath' 4 | export * from './getBoundingBoxes' 5 | export * from './guaranteeWalkablePath' 6 | export * from './pointConversion' 7 | export * from './utils' 8 | -------------------------------------------------------------------------------- /src/functions/pointConversion.ts: -------------------------------------------------------------------------------- 1 | import type { XYPosition } from 'reactflow' 2 | 3 | /** 4 | * Each bounding box is a collection of X/Y points in a graph, and we 5 | * need to convert them to "occupied" cells in a 2D grid representation. 6 | * 7 | * The top most position of the grid (grid[0][0]) needs to be equivalent 8 | * to the top most point in the graph (the graph.topLeft point). 9 | * 10 | * Since the top most point can have X/Y values different than zero, 11 | * and each cell in a grid represents a 10x10 pixel area in the grid (or a 12 | * gridRatio area), there's need to be a conversion between a point in a graph 13 | * to a point in the grid. 14 | * 15 | * We do this conversion by dividing a graph point X/Y values by the grid ratio, 16 | * and "shifting" their values up or down, depending on the values of the top 17 | * most point in the graph. The top most point in the graph will have the 18 | * smallest values for X and Y. 19 | * 20 | * We avoid setting nodes in the border of the grid (x=0 or y=0), so there's 21 | * always a "walkable" area around the grid. 22 | */ 23 | export const graphToGridPoint = ( 24 | graphPoint: XYPosition, 25 | smallestX: number, 26 | smallestY: number, 27 | gridRatio: number 28 | ): XYPosition => { 29 | let x = graphPoint.x / gridRatio 30 | let y = graphPoint.y / gridRatio 31 | 32 | let referenceX = smallestX / gridRatio 33 | let referenceY = smallestY / gridRatio 34 | 35 | if (referenceX < 1) { 36 | while (referenceX !== 1) { 37 | referenceX++ 38 | x++ 39 | } 40 | } else if (referenceX > 1) { 41 | while (referenceX !== 1) { 42 | referenceX-- 43 | x-- 44 | } 45 | } else { 46 | // Nothing to do 47 | } 48 | 49 | if (referenceY < 1) { 50 | while (referenceY !== 1) { 51 | referenceY++ 52 | y++ 53 | } 54 | } else if (referenceY > 1) { 55 | while (referenceY !== 1) { 56 | referenceY-- 57 | y-- 58 | } 59 | } else { 60 | // Nothing to do 61 | } 62 | 63 | return { x, y } 64 | } 65 | 66 | /** 67 | * Converts a grid point back to a graph point, using the reverse logic of 68 | * graphToGridPoint. 69 | */ 70 | export const gridToGraphPoint = ( 71 | gridPoint: XYPosition, 72 | smallestX: number, 73 | smallestY: number, 74 | gridRatio: number 75 | ): XYPosition => { 76 | let x = gridPoint.x * gridRatio 77 | let y = gridPoint.y * gridRatio 78 | 79 | let referenceX = smallestX 80 | let referenceY = smallestY 81 | 82 | if (referenceX < gridRatio) { 83 | while (referenceX !== gridRatio) { 84 | referenceX = referenceX + gridRatio 85 | x = x - gridRatio 86 | } 87 | } else if (referenceX > gridRatio) { 88 | while (referenceX !== gridRatio) { 89 | referenceX = referenceX - gridRatio 90 | x = x + gridRatio 91 | } 92 | } else { 93 | // Nothing to do 94 | } 95 | 96 | if (referenceY < gridRatio) { 97 | while (referenceY !== gridRatio) { 98 | referenceY = referenceY + gridRatio 99 | y = y - gridRatio 100 | } 101 | } else if (referenceY > gridRatio) { 102 | while (referenceY !== gridRatio) { 103 | referenceY = referenceY - gridRatio 104 | y = y + gridRatio 105 | } 106 | } else { 107 | // Nothing to do 108 | } 109 | 110 | return { x, y } 111 | } 112 | -------------------------------------------------------------------------------- /src/functions/utils.ts: -------------------------------------------------------------------------------- 1 | export const round = (x: number, multiple = 10) => 2 | Math.round(x / multiple) * multiple 3 | 4 | export const roundDown = (x: number, multiple = 10) => 5 | Math.floor(x / multiple) * multiple 6 | 7 | export const roundUp = (x: number, multiple = 10) => 8 | Math.ceil(x / multiple) * multiple 9 | 10 | export const toInteger = (value: number, min = 0) => { 11 | let result = Math.max(Math.round(value), min) 12 | result = Number.isInteger(result) ? result : min 13 | result = result >= min ? result : min 14 | return result 15 | } 16 | -------------------------------------------------------------------------------- /src/getSmartEdge/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createGrid, 3 | getBoundingBoxes, 4 | gridToGraphPoint, 5 | pathfindingAStarDiagonal, 6 | svgDrawSmoothLinePath, 7 | toInteger 8 | } from '../functions' 9 | import type { 10 | PointInfo, 11 | PathFindingFunction, 12 | SVGDrawFunction 13 | } from '../functions' 14 | import type { Node, EdgeProps } from 'reactflow' 15 | 16 | export type EdgeParams = Pick< 17 | EdgeProps, 18 | | 'sourceX' 19 | | 'sourceY' 20 | | 'targetX' 21 | | 'targetY' 22 | | 'sourcePosition' 23 | | 'targetPosition' 24 | > 25 | 26 | export type GetSmartEdgeOptions = { 27 | gridRatio?: number 28 | nodePadding?: number 29 | drawEdge?: SVGDrawFunction 30 | generatePath?: PathFindingFunction 31 | } 32 | 33 | export type GetSmartEdgeParams = EdgeParams & { 34 | options?: GetSmartEdgeOptions 35 | nodes: Node[] 36 | } 37 | 38 | export type GetSmartEdgeReturn = { 39 | svgPathString: string 40 | edgeCenterX: number 41 | edgeCenterY: number 42 | } 43 | 44 | export const getSmartEdge = ({ 45 | options = {}, 46 | nodes = [], 47 | sourceX, 48 | sourceY, 49 | targetX, 50 | targetY, 51 | sourcePosition, 52 | targetPosition 53 | }: GetSmartEdgeParams): GetSmartEdgeReturn | null => { 54 | try { 55 | const { 56 | drawEdge = svgDrawSmoothLinePath, 57 | generatePath = pathfindingAStarDiagonal 58 | } = options 59 | 60 | let { gridRatio = 10, nodePadding = 10 } = options 61 | gridRatio = toInteger(gridRatio) 62 | nodePadding = toInteger(nodePadding) 63 | 64 | // We use the node's information to generate bounding boxes for them 65 | // and the graph 66 | const { graphBox, nodeBoxes } = getBoundingBoxes( 67 | nodes, 68 | nodePadding, 69 | gridRatio 70 | ) 71 | 72 | const source: PointInfo = { 73 | x: sourceX, 74 | y: sourceY, 75 | position: sourcePosition 76 | } 77 | 78 | const target: PointInfo = { 79 | x: targetX, 80 | y: targetY, 81 | position: targetPosition 82 | } 83 | 84 | // With this information, we can create a 2D grid representation of 85 | // our graph, that tells us where in the graph there is a "free" space or not 86 | const { grid, start, end } = createGrid( 87 | graphBox, 88 | nodeBoxes, 89 | source, 90 | target, 91 | gridRatio 92 | ) 93 | 94 | // We then can use the grid representation to do pathfinding 95 | const generatePathResult = generatePath(grid, start, end) 96 | 97 | if (generatePathResult === null) { 98 | return null 99 | } 100 | 101 | const { fullPath, smoothedPath } = generatePathResult 102 | 103 | // Here we convert the grid path to a sequence of graph coordinates. 104 | const graphPath = smoothedPath.map((gridPoint) => { 105 | const [x, y] = gridPoint 106 | const graphPoint = gridToGraphPoint( 107 | { x, y }, 108 | graphBox.xMin, 109 | graphBox.yMin, 110 | gridRatio 111 | ) 112 | return [graphPoint.x, graphPoint.y] 113 | }) 114 | 115 | // Finally, we can use the graph path to draw the edge 116 | const svgPathString = drawEdge(source, target, graphPath) 117 | 118 | // Compute the edge's middle point using the full path, so users can use 119 | // it to position their custom labels 120 | const index = Math.floor(fullPath.length / 2) 121 | const middlePoint = fullPath[index] 122 | const [middleX, middleY] = middlePoint 123 | const { x: edgeCenterX, y: edgeCenterY } = gridToGraphPoint( 124 | { x: middleX, y: middleY }, 125 | graphBox.xMin, 126 | graphBox.yMin, 127 | gridRatio 128 | ) 129 | 130 | return { svgPathString, edgeCenterX, edgeCenterY } 131 | } catch { 132 | return null 133 | } 134 | } 135 | 136 | export type GetSmartEdgeFunction = typeof getSmartEdge 137 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import { SmartBezierEdge } from './SmartBezierEdge' 2 | 3 | export * from './SmartEdge' 4 | export * from './SmartBezierEdge' 5 | export * from './SmartStepEdge' 6 | export * from './SmartStraightEdge' 7 | export * from './getSmartEdge' 8 | export * from './functions/drawSvgPath' 9 | export * from './functions/generatePath' 10 | 11 | export default SmartBezierEdge 12 | -------------------------------------------------------------------------------- /src/stories/CustomLabel.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useNodes, BezierEdge } from 'reactflow' 3 | import { getSmartEdge } from '../getSmartEdge' 4 | import type { EdgeData, NodeData } from './DummyData' 5 | import type { EdgeProps } from 'reactflow' 6 | 7 | const size = 20 8 | 9 | export function SmartEdgeCustomLabel(props: EdgeProps) { 10 | const { 11 | id, 12 | sourcePosition, 13 | targetPosition, 14 | sourceX, 15 | sourceY, 16 | targetX, 17 | targetY, 18 | style, 19 | markerStart, 20 | markerEnd 21 | } = props 22 | 23 | const nodes = useNodes() 24 | 25 | const getSmartEdgeResponse = getSmartEdge({ 26 | sourcePosition, 27 | targetPosition, 28 | sourceX, 29 | sourceY, 30 | targetX, 31 | targetY, 32 | nodes 33 | }) 34 | 35 | if (getSmartEdgeResponse === null) { 36 | return 37 | } 38 | 39 | const { edgeCenterX, edgeCenterY, svgPathString } = getSmartEdgeResponse 40 | 41 | return ( 42 | <> 43 | 50 | 66 |
74 | 91 |
92 |
93 | 94 | ) 95 | } 96 | -------------------------------------------------------------------------------- /src/stories/DummyData.ts: -------------------------------------------------------------------------------- 1 | import { MarkerType } from 'reactflow' 2 | import { SmartBezierEdge, SmartStraightEdge, SmartStepEdge } from '../index' 3 | import { SmartEdgeCustomLabel } from './CustomLabel' 4 | import type { Node, Edge } from 'reactflow' 5 | 6 | const markerEndType = MarkerType.Arrow 7 | 8 | export const edgeTypes = { 9 | smartBezier: SmartBezierEdge, 10 | smartStraight: SmartStraightEdge, 11 | smartStep: SmartStepEdge, 12 | smartBezierLabel: SmartEdgeCustomLabel 13 | } 14 | 15 | export type NodeData = { 16 | label: string 17 | } 18 | 19 | export type EdgeData = { customField: string } | undefined 20 | 21 | export const nodes: Node[] = [ 22 | { 23 | id: '1', 24 | data: { 25 | label: 'Node 1' 26 | }, 27 | position: { 28 | x: 490, 29 | y: 40 30 | } 31 | }, 32 | { 33 | id: '2', 34 | data: { 35 | label: 'Node 2' 36 | }, 37 | position: { 38 | x: 270, 39 | y: 130 40 | } 41 | }, 42 | { 43 | id: '3', 44 | data: { 45 | label: 'Node 3' 46 | }, 47 | position: { 48 | x: 40, 49 | y: 220 50 | } 51 | }, 52 | { 53 | id: '4', 54 | data: { 55 | label: 'Node 4' 56 | }, 57 | position: { 58 | x: 270, 59 | y: 220 60 | } 61 | }, 62 | { 63 | id: '5', 64 | data: { 65 | label: 'Node 5' 66 | }, 67 | position: { 68 | x: 470, 69 | y: 220 70 | } 71 | }, 72 | { 73 | id: '6', 74 | data: { 75 | label: 'Node 6' 76 | }, 77 | position: { 78 | x: 515, 79 | y: 310 80 | } 81 | }, 82 | { 83 | id: '7', 84 | data: { 85 | label: 'Node 7' 86 | }, 87 | position: { 88 | x: 470, 89 | y: 130 90 | } 91 | } 92 | ] 93 | 94 | export const edgesBezier: Edge[] = [ 95 | { 96 | id: 'e12', 97 | source: '1', 98 | target: '2', 99 | type: 'smartBezier', 100 | markerEnd: { type: markerEndType }, 101 | label: 'Edge Label' 102 | }, 103 | { 104 | id: 'e17', 105 | source: '1', 106 | target: '7', 107 | type: 'smartBezier', 108 | markerEnd: { type: markerEndType } 109 | }, 110 | { 111 | id: 'e23', 112 | source: '2', 113 | target: '3', 114 | type: 'smartBezier', 115 | markerEnd: { type: markerEndType } 116 | }, 117 | { 118 | id: 'e24', 119 | source: '2', 120 | target: '4', 121 | type: 'smartBezier', 122 | markerEnd: { type: markerEndType } 123 | }, 124 | { 125 | id: 'e25', 126 | source: '2', 127 | target: '5', 128 | type: 'smartBezier', 129 | markerEnd: { type: markerEndType } 130 | }, 131 | { 132 | id: 'e56', 133 | source: '5', 134 | target: '6', 135 | type: 'smartBezier', 136 | markerEnd: { type: markerEndType }, 137 | data: { 138 | customField: 'custom data' 139 | } 140 | }, 141 | { 142 | id: 'e65', 143 | source: '6', 144 | target: '5', 145 | type: 'smartBezier', 146 | markerEnd: { type: markerEndType } 147 | }, 148 | { 149 | id: 'e61', 150 | source: '6', 151 | target: '1', 152 | type: 'smartBezier', 153 | markerEnd: { type: markerEndType } 154 | }, 155 | { 156 | id: 'e3', 157 | source: '3', 158 | target: '3', 159 | type: 'smartBezier', 160 | markerEnd: { type: markerEndType } 161 | } 162 | ] 163 | 164 | export const edgesStraight: Edge[] = edgesBezier.map((edge) => ({ 165 | ...edge, 166 | type: 'smartStraight' 167 | })) 168 | 169 | export const edgesStep: Edge[] = edgesBezier.map((edge) => ({ 170 | ...edge, 171 | type: 'smartStep' 172 | })) 173 | 174 | export const edgesLabel: Edge[] = edgesBezier.map((edge) => ({ 175 | ...edge, 176 | type: 'smartBezierLabel' 177 | })) 178 | -------------------------------------------------------------------------------- /src/stories/GraphWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { ReactFlow } from 'reactflow' 3 | import type { ReactFlowProps } from 'reactflow' 4 | 5 | const style = { 6 | background: '#fafafa', 7 | width: '100%', 8 | height: '500px' 9 | } 10 | 11 | export const GraphWrapper = (args: ReactFlowProps) => ( 12 |
13 | 14 |
15 | ) 16 | -------------------------------------------------------------------------------- /src/stories/SimulateDragAndDrop.ts: -------------------------------------------------------------------------------- 1 | // https://testing-library.com/docs/example-drag/ 2 | import { fireEvent } from '@storybook/testing-library' 3 | 4 | const isElement = (obj: unknown): obj is HTMLElement => { 5 | if (typeof obj !== 'object') { 6 | return false 7 | } 8 | 9 | let prototypeStr: string 10 | let prototype: unknown 11 | 12 | do { 13 | prototype = Object.getPrototypeOf(obj) 14 | prototypeStr = Object.prototype.toString.call(prototype) 15 | if ( 16 | prototypeStr === '[object Element]' || 17 | prototypeStr === '[object Document]' 18 | ) { 19 | return true 20 | } 21 | obj = prototype 22 | } while (prototype !== null) 23 | 24 | return false 25 | } 26 | 27 | const getElementClientCenter = (element: HTMLElement): Point => { 28 | const { left, top, width, height } = element.getBoundingClientRect() 29 | return { 30 | x: left + width / 2, 31 | y: top + height / 2 32 | } 33 | } 34 | 35 | const getCoords = (charlie: HTMLElement | Point) => 36 | isElement(charlie) ? getElementClientCenter(charlie) : charlie 37 | 38 | export const wait = (ms: number) => 39 | new Promise((resolve) => { 40 | setTimeout(resolve, ms) 41 | }) 42 | 43 | type Point = { 44 | x: number 45 | y: number 46 | } 47 | 48 | type DragOptions = { 49 | delta?: Point 50 | to?: HTMLElement | Point 51 | } 52 | 53 | export const SimulateDragAndDrop = ( 54 | element: HTMLElement, 55 | { to: inTo, delta }: DragOptions 56 | ) => { 57 | const from = getElementClientCenter(element) 58 | let to: Point 59 | 60 | if (delta) { 61 | to = { 62 | x: from.x + delta.x, 63 | y: from.y + delta.y 64 | } 65 | } else if (inTo) { 66 | to = getCoords(inTo) 67 | } else { 68 | throw new Error('drag requires either a delta or a to') 69 | } 70 | 71 | const fromOptions = { 72 | clientX: from.x, 73 | clientY: from.y, 74 | view: window 75 | } 76 | 77 | const toOptions = { 78 | clientX: to.x, 79 | clientY: to.y, 80 | view: window 81 | } 82 | 83 | fireEvent.mouseEnter(element, fromOptions) 84 | fireEvent.mouseOver(element, fromOptions) 85 | fireEvent.mouseMove(element, fromOptions) 86 | fireEvent.mouseDown(element, fromOptions) 87 | fireEvent.mouseMove(element, toOptions) 88 | fireEvent.mouseUp(element, toOptions) 89 | } 90 | -------------------------------------------------------------------------------- /src/stories/SmartEdge.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { 3 | edgesBezier, 4 | edgesStraight, 5 | edgesStep, 6 | edgesLabel, 7 | nodes, 8 | edgeTypes 9 | } from './DummyData' 10 | import { GraphWrapper } from './GraphWrapper' 11 | import type { Meta, Story } from '@storybook/react' 12 | import type { ReactFlowProps } from 'reactflow' 13 | 14 | export default { 15 | title: 'Smart Edge', 16 | component: GraphWrapper 17 | } as Meta 18 | 19 | const Template: Story = (args) => 20 | 21 | export const SmartBezier = Template.bind({}) 22 | SmartBezier.args = { 23 | edgeTypes, 24 | defaultNodes: nodes, 25 | defaultEdges: edgesBezier 26 | } 27 | 28 | export const SmartStraight = Template.bind({}) 29 | SmartStraight.args = { 30 | ...SmartBezier.args, 31 | defaultEdges: edgesStraight 32 | } 33 | 34 | export const SmartStep = Template.bind({}) 35 | SmartStep.args = { 36 | ...SmartBezier.args, 37 | defaultEdges: edgesStep 38 | } 39 | 40 | export const SmartBezierWithCustomLabel = Template.bind({}) 41 | SmartBezierWithCustomLabel.args = { 42 | ...SmartBezier.args, 43 | defaultEdges: edgesLabel 44 | } 45 | -------------------------------------------------------------------------------- /src/stories/SmartEdgeInteractions.stories.tsx: -------------------------------------------------------------------------------- 1 | import { within } from '@storybook/testing-library' 2 | import React from 'react' 3 | import { GraphWrapper } from './GraphWrapper' 4 | import { SimulateDragAndDrop, wait } from './SimulateDragAndDrop' 5 | import { SmartBezier, SmartStraight, SmartStep } from './SmartEdge.stories' 6 | import type { Meta, Story } from '@storybook/react' 7 | import type { ReactFlowProps } from 'reactflow' 8 | 9 | export default { 10 | title: 'Interactions', 11 | component: GraphWrapper, 12 | argTypes: { 13 | edgeTypes: { table: { disable: true } }, 14 | defaultNodes: { table: { disable: true } }, 15 | defaultEdges: { table: { disable: true } } 16 | } 17 | } as Meta 18 | 19 | const Template: Story = (args) => 20 | 21 | export const SmartBezierInteraction = Template.bind({}) 22 | SmartBezierInteraction.args = SmartBezier.args 23 | SmartBezierInteraction.play = async ({ canvasElement }) => { 24 | await wait(500) 25 | const canvas = within(canvasElement) 26 | const node4 = canvas.getByText('Node 4') 27 | await SimulateDragAndDrop(node4, { delta: { x: -300, y: -250 } }) 28 | const node1 = canvas.getByText('Node 1') 29 | await SimulateDragAndDrop(node1, { delta: { x: -250, y: 300 } }) 30 | const node6 = canvas.getByText('Node 6') 31 | await SimulateDragAndDrop(node6, { delta: { x: 250, y: -50 } }) 32 | const node3 = canvas.getByText('Node 3') 33 | await SimulateDragAndDrop(node3, { delta: { x: 300, y: -100 } }) 34 | } 35 | 36 | export const SmartStraightInteraction = Template.bind({}) 37 | SmartStraightInteraction.args = SmartStraight.args 38 | SmartStraightInteraction.play = SmartBezierInteraction.play 39 | 40 | export const SmartStepInteraction = Template.bind({}) 41 | SmartStepInteraction.args = SmartStep.args 42 | SmartStepInteraction.play = SmartBezierInteraction.play 43 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", ".eslintrc.js", "jest.config.js"], 3 | "compilerOptions": { 4 | "baseUrl": "./src", 5 | "declaration": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "importHelpers": true, 9 | "jsx": "react", 10 | "lib": ["dom", "esnext"], 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "noEmit": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "noImplicitReturns": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "rootDir": "./src", 19 | "skipLibCheck": true, 20 | "sourceMap": true, 21 | "strict": true 22 | } 23 | } 24 | --------------------------------------------------------------------------------