From 2497a36db3078ffaa71cd9cfdf746aeaf274bfc2 Mon Sep 17 00:00:00 2001 From: moklick Date: Wed, 22 Feb 2023 10:23:05 +0100 Subject: [PATCH] feat(packages): add edge-utils --- packages/edge-utils/.eslintrc.js | 4 + packages/edge-utils/README.md | 10 ++ packages/edge-utils/package.json | 59 +++++++ packages/edge-utils/src/bezier-edge.ts | 115 ++++++++++++ packages/edge-utils/src/general.ts | 30 ++++ packages/edge-utils/src/index.ts | 4 + packages/edge-utils/src/smoothstep-edge.ts | 195 +++++++++++++++++++++ packages/edge-utils/src/straight-edge.ts | 24 +++ packages/edge-utils/tsconfig.json | 6 + 9 files changed, 447 insertions(+) create mode 100644 packages/edge-utils/.eslintrc.js create mode 100644 packages/edge-utils/README.md create mode 100644 packages/edge-utils/package.json create mode 100644 packages/edge-utils/src/bezier-edge.ts create mode 100644 packages/edge-utils/src/general.ts create mode 100644 packages/edge-utils/src/index.ts create mode 100644 packages/edge-utils/src/smoothstep-edge.ts create mode 100644 packages/edge-utils/src/straight-edge.ts create mode 100644 packages/edge-utils/tsconfig.json diff --git a/packages/edge-utils/.eslintrc.js b/packages/edge-utils/.eslintrc.js new file mode 100644 index 00000000..31cfbd11 --- /dev/null +++ b/packages/edge-utils/.eslintrc.js @@ -0,0 +1,4 @@ +module.exports = { + root: true, + extends: ['@reactflow/eslint-config'], +}; diff --git a/packages/edge-utils/README.md b/packages/edge-utils/README.md new file mode 100644 index 00000000..d87523ed --- /dev/null +++ b/packages/edge-utils/README.md @@ -0,0 +1,10 @@ +# @reactflow/core + +Core components and util functions of React Flow. + +## Installation + +```sh +npm install @reactflow/core +``` + diff --git a/packages/edge-utils/package.json b/packages/edge-utils/package.json new file mode 100644 index 00000000..108ac79a --- /dev/null +++ b/packages/edge-utils/package.json @@ -0,0 +1,59 @@ +{ + "name": "@reactflow/edge-utils", + "version": "11.5.5", + "description": "Core edge utils of React Flow for creating edge paths.", + "keywords": [ + "react", + "node-based UI", + "graph", + "diagram", + "workflow", + "react-flow" + ], + "files": [ + "dist" + ], + "source": "src/index.ts", + "main": "dist/umd/index.js", + "module": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "sideEffects": [ + "*.css" + ], + "license": "MIT", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/wbkd/react-flow.git", + "directory": "packages/edge-utils" + }, + "scripts": { + "dev": "rollup --config node:@reactflow/rollup-config --watch", + "build": "rollup --config node:@reactflow/rollup-config --environment NODE_ENV:production", + "lint": "eslint --ext .js,.jsx,.ts,.tsx src", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@reactflow/system": "workspace:*" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + }, + "devDependencies": { + "@reactflow/eslint-config": "workspace:*", + "@reactflow/rollup-config": "workspace:*", + "@reactflow/tsconfig": "workspace:*", + "@types/node": "^18.7.16", + "@types/react": ">=17", + "@types/react-dom": ">=17", + "react": "^18.2.0", + "typescript": "^4.9.4" + }, + "rollup": { + "globals": {}, + "name": "ReactFlowEdgeUtils" + } +} diff --git a/packages/edge-utils/src/bezier-edge.ts b/packages/edge-utils/src/bezier-edge.ts new file mode 100644 index 00000000..50a1af08 --- /dev/null +++ b/packages/edge-utils/src/bezier-edge.ts @@ -0,0 +1,115 @@ +import { Position } from '@reactflow/system'; + +export type GetBezierPathParams = { + sourceX: number; + sourceY: number; + sourcePosition?: Position; + targetX: number; + targetY: number; + targetPosition?: Position; + curvature?: number; +}; + +export type GetControlWithCurvatureParams = { + pos: Position; + x1: number; + y1: number; + x2: number; + y2: number; + c: number; +}; + +export function getBezierEdgeCenter({ + sourceX, + sourceY, + targetX, + targetY, + sourceControlX, + sourceControlY, + targetControlX, + targetControlY, +}: { + sourceX: number; + sourceY: number; + targetX: number; + targetY: number; + sourceControlX: number; + sourceControlY: number; + targetControlX: number; + targetControlY: number; +}): [number, number, number, number] { + // cubic bezier t=0.5 mid point, not the actual mid point, but easy to calculate + // https://stackoverflow.com/questions/67516101/how-to-find-distance-mid-point-of-bezier-curve + const centerX = sourceX * 0.125 + sourceControlX * 0.375 + targetControlX * 0.375 + targetX * 0.125; + const centerY = sourceY * 0.125 + sourceControlY * 0.375 + targetControlY * 0.375 + targetY * 0.125; + const offsetX = Math.abs(centerX - sourceX); + const offsetY = Math.abs(centerY - sourceY); + + return [centerX, centerY, offsetX, offsetY]; +} + +function calculateControlOffset(distance: number, curvature: number): number { + if (distance >= 0) { + return 0.5 * distance; + } + + return curvature * 25 * Math.sqrt(-distance); +} + +function getControlWithCurvature({ pos, x1, y1, x2, y2, c }: GetControlWithCurvatureParams): [number, number] { + switch (pos) { + case Position.Left: + return [x1 - calculateControlOffset(x1 - x2, c), y1]; + case Position.Right: + return [x1 + calculateControlOffset(x2 - x1, c), y1]; + case Position.Top: + return [x1, y1 - calculateControlOffset(y1 - y2, c)]; + case Position.Bottom: + return [x1, y1 + calculateControlOffset(y2 - y1, c)]; + } +} + +export function getBezierPath({ + sourceX, + sourceY, + sourcePosition = Position.Bottom, + targetX, + targetY, + targetPosition = Position.Top, + curvature = 0.25, +}: GetBezierPathParams): [path: string, labelX: number, labelY: number, offsetX: number, offsetY: number] { + const [sourceControlX, sourceControlY] = getControlWithCurvature({ + pos: sourcePosition, + x1: sourceX, + y1: sourceY, + x2: targetX, + y2: targetY, + c: curvature, + }); + const [targetControlX, targetControlY] = getControlWithCurvature({ + pos: targetPosition, + x1: targetX, + y1: targetY, + x2: sourceX, + y2: sourceY, + c: curvature, + }); + const [labelX, labelY, offsetX, offsetY] = getBezierEdgeCenter({ + sourceX, + sourceY, + targetX, + targetY, + sourceControlX, + sourceControlY, + targetControlX, + targetControlY, + }); + + return [ + `M${sourceX},${sourceY} C${sourceControlX},${sourceControlY} ${targetControlX},${targetControlY} ${targetX},${targetY}`, + labelX, + labelY, + offsetX, + offsetY, + ]; +} diff --git a/packages/edge-utils/src/general.ts b/packages/edge-utils/src/general.ts new file mode 100644 index 00000000..16fcf90b --- /dev/null +++ b/packages/edge-utils/src/general.ts @@ -0,0 +1,30 @@ +import { MarkerType } from '@reactflow/system'; + +// this is used for straight edges and simple smoothstep edges (LTR, RTL, BTT, TTB) +export function getEdgeCenter({ + sourceX, + sourceY, + targetX, + targetY, +}: { + sourceX: number; + sourceY: number; + targetX: number; + targetY: number; +}): [number, number, number, number] { + const xOffset = Math.abs(targetX - sourceX) / 2; + const centerX = targetX < sourceX ? targetX + xOffset : targetX - xOffset; + + const yOffset = Math.abs(targetY - sourceY) / 2; + const centerY = targetY < sourceY ? targetY + yOffset : targetY - yOffset; + + return [centerX, centerY, xOffset, yOffset]; +} + +export const getMarkerEnd = (markerType?: MarkerType, markerEndId?: string): string => { + if (typeof markerEndId !== 'undefined' && markerEndId) { + return `url(#${markerEndId})`; + } + + return typeof markerType !== 'undefined' ? `url(#react-flow__${markerType})` : 'none'; +}; diff --git a/packages/edge-utils/src/index.ts b/packages/edge-utils/src/index.ts new file mode 100644 index 00000000..78833a71 --- /dev/null +++ b/packages/edge-utils/src/index.ts @@ -0,0 +1,4 @@ +export * from './bezier-edge'; +export * from './straight-edge'; +export * from './smoothstep-edge'; +export * from './general'; diff --git a/packages/edge-utils/src/smoothstep-edge.ts b/packages/edge-utils/src/smoothstep-edge.ts new file mode 100644 index 00000000..84912f19 --- /dev/null +++ b/packages/edge-utils/src/smoothstep-edge.ts @@ -0,0 +1,195 @@ +import { Position, XYPosition } from '@reactflow/system'; +import { getEdgeCenter } from './general'; + +export interface GetSmoothStepPathParams { + sourceX: number; + sourceY: number; + sourcePosition?: Position; + targetX: number; + targetY: number; + targetPosition?: Position; + borderRadius?: number; + centerX?: number; + centerY?: number; + offset?: number; +} + +const handleDirections = { + [Position.Left]: { x: -1, y: 0 }, + [Position.Right]: { x: 1, y: 0 }, + [Position.Top]: { x: 0, y: -1 }, + [Position.Bottom]: { x: 0, y: 1 }, +}; + +const getDirection = ({ + source, + sourcePosition = Position.Bottom, + target, +}: { + source: XYPosition; + sourcePosition: Position; + target: XYPosition; +}): XYPosition => { + if (sourcePosition === Position.Left || sourcePosition === Position.Right) { + return source.x < target.x ? { x: 1, y: 0 } : { x: -1, y: 0 }; + } + return source.y < target.y ? { x: 0, y: 1 } : { x: 0, y: -1 }; +}; + +const distance = (a: XYPosition, b: XYPosition) => Math.sqrt(Math.pow(b.x - a.x, 2) + Math.pow(b.y - a.y, 2)); + +// ith this function we try to mimic a orthogonal edge routing behaviour +// It's not as good as a real orthogonal edge routing but it's faster and good enough as a default for step and smooth step edges +function getPoints({ + source, + sourcePosition = Position.Bottom, + target, + targetPosition = Position.Top, + center, + offset, +}: { + source: XYPosition; + sourcePosition: Position; + target: XYPosition; + targetPosition: Position; + center: Partial; + offset: number; +}): [XYPosition[], number, number, number, number] { + const sourceDir = handleDirections[sourcePosition]; + const targetDir = handleDirections[targetPosition]; + const sourceGapped: XYPosition = { x: source.x + sourceDir.x * offset, y: source.y + sourceDir.y * offset }; + const targetGapped: XYPosition = { x: target.x + targetDir.x * offset, y: target.y + targetDir.y * offset }; + const dir = getDirection({ + source: sourceGapped, + sourcePosition, + target: targetGapped, + }); + const dirAccessor = dir.x !== 0 ? 'x' : 'y'; + const currDir = dir[dirAccessor]; + + let points: XYPosition[] = []; + let centerX, centerY; + const [defaultCenterX, defaultCenterY, defaultOffsetX, defaultOffsetY] = getEdgeCenter({ + sourceX: source.x, + sourceY: source.y, + targetX: target.x, + targetY: target.y, + }); + + // opposite handle positions, default case + if (sourceDir[dirAccessor] * targetDir[dirAccessor] === -1) { + centerX = center.x || defaultCenterX; + centerY = center.y || defaultCenterY; + // ---> + // | + // >--- + const verticalSplit: XYPosition[] = [ + { x: centerX, y: sourceGapped.y }, + { x: centerX, y: targetGapped.y }, + ]; + // | + // --- + // | + const horizontalSplit: XYPosition[] = [ + { x: sourceGapped.x, y: centerY }, + { x: targetGapped.x, y: centerY }, + ]; + + if (sourceDir[dirAccessor] === currDir) { + points = dirAccessor === 'x' ? verticalSplit : horizontalSplit; + } else { + points = dirAccessor === 'x' ? horizontalSplit : verticalSplit; + } + } else { + // sourceTarget means we take x from source and y from target, targetSource is the opposite + const sourceTarget: XYPosition[] = [{ x: sourceGapped.x, y: targetGapped.y }]; + const targetSource: XYPosition[] = [{ x: targetGapped.x, y: sourceGapped.y }]; + // this handles edges with same handle positions + if (dirAccessor === 'x') { + points = sourceDir.x === currDir ? targetSource : sourceTarget; + } else { + points = sourceDir.y === currDir ? sourceTarget : targetSource; + } + + // these are conditions for handling mixed handle positions like Right -> Bottom for example + if (sourcePosition !== targetPosition) { + const dirAccessorOpposite = dirAccessor === 'x' ? 'y' : 'x'; + const isSameDir = sourceDir[dirAccessor] === targetDir[dirAccessorOpposite]; + const sourceGtTargetOppo = sourceGapped[dirAccessorOpposite] > targetGapped[dirAccessorOpposite]; + const sourceLtTargetOppo = sourceGapped[dirAccessorOpposite] < targetGapped[dirAccessorOpposite]; + const flipSourceTarget = + (sourceDir[dirAccessor] === 1 && ((!isSameDir && sourceGtTargetOppo) || (isSameDir && sourceLtTargetOppo))) || + (sourceDir[dirAccessor] !== 1 && ((!isSameDir && sourceLtTargetOppo) || (isSameDir && sourceGtTargetOppo))); + + if (flipSourceTarget) { + points = dirAccessor === 'x' ? sourceTarget : targetSource; + } + } + + centerX = points[0].x; + centerY = points[0].y; + } + + const pathPoints = [source, sourceGapped, ...points, targetGapped, target]; + + return [pathPoints, centerX, centerY, defaultOffsetX, defaultOffsetY]; +} + +function getBend(a: XYPosition, b: XYPosition, c: XYPosition, size: number): string { + const bendSize = Math.min(distance(a, b) / 2, distance(b, c) / 2, size); + const { x, y } = b; + + // no bend + if ((a.x === x && x === c.x) || (a.y === y && y === c.y)) { + return `L${x} ${y}`; + } + + // first segment is horizontal + if (a.y === y) { + const xDir = a.x < c.x ? -1 : 1; + const yDir = a.y < c.y ? 1 : -1; + return `L ${x + bendSize * xDir},${y}Q ${x},${y} ${x},${y + bendSize * yDir}`; + } + + const xDir = a.x < c.x ? 1 : -1; + const yDir = a.y < c.y ? -1 : 1; + return `L ${x},${y + bendSize * yDir}Q ${x},${y} ${x + bendSize * xDir},${y}`; +} + +export function getSmoothStepPath({ + sourceX, + sourceY, + sourcePosition = Position.Bottom, + targetX, + targetY, + targetPosition = Position.Top, + borderRadius = 5, + centerX, + centerY, + offset = 20, +}: GetSmoothStepPathParams): [path: string, labelX: number, labelY: number, offsetX: number, offsetY: number] { + const [points, labelX, labelY, offsetX, offsetY] = getPoints({ + source: { x: sourceX, y: sourceY }, + sourcePosition, + target: { x: targetX, y: targetY }, + targetPosition, + center: { x: centerX, y: centerY }, + offset, + }); + + const path = points.reduce((res, p, i) => { + let segment = ''; + + if (i > 0 && i < points.length - 1) { + segment = getBend(points[i - 1], p, points[i + 1], borderRadius); + } else { + segment = `${i === 0 ? 'M' : 'L'}${p.x} ${p.y}`; + } + + res += segment; + + return res; + }, ''); + + return [path, labelX, labelY, offsetX, offsetY]; +} diff --git a/packages/edge-utils/src/straight-edge.ts b/packages/edge-utils/src/straight-edge.ts new file mode 100644 index 00000000..9cc6d928 --- /dev/null +++ b/packages/edge-utils/src/straight-edge.ts @@ -0,0 +1,24 @@ +import { getEdgeCenter } from './general'; + +export type GetStraightPathParams = { + sourceX: number; + sourceY: number; + targetX: number; + targetY: number; +}; + +export function getStraightPath({ + sourceX, + sourceY, + targetX, + targetY, +}: GetStraightPathParams): [path: string, labelX: number, labelY: number, offsetX: number, offsetY: number] { + const [labelX, labelY, offsetX, offsetY] = getEdgeCenter({ + sourceX, + sourceY, + targetX, + targetY, + }); + + return [`M ${sourceX},${sourceY}L ${targetX},${targetY}`, labelX, labelY, offsetX, offsetY]; +} diff --git a/packages/edge-utils/tsconfig.json b/packages/edge-utils/tsconfig.json new file mode 100644 index 00000000..b61349bb --- /dev/null +++ b/packages/edge-utils/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "@reactflow/tsconfig/react.json", + "display": "@reactflow/utils", + "include": ["**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", "dist"] +}