@jalez/react-flow-smart-edge
Version:
Smart edge routing for @xyflow/react v12+ (maintained fork of @tisoap/react-flow-smart-edge)
613 lines (599 loc) • 17.3 kB
JavaScript
import { BaseEdge, BezierEdge, useNodes, StepEdge, StraightEdge } from '@xyflow/react';
import React from 'react';
import { Grid, AStarFinder, DiagonalMovement, Util, JumpPointFinder } from 'pathfinding';
const getNextPointFromPosition = (point, position) => {
switch (position) {
case 'top':
return {
x: point.x,
y: point.y - 1
};
case 'bottom':
return {
x: point.x,
y: point.y + 1
};
case 'left':
return {
x: point.x - 1,
y: point.y
};
case 'right':
return {
x: point.x + 1,
y: point.y
};
}
};
/**
* Guarantee that the path is walkable, even if the point is inside a non
* walkable area, by adding a walkable path in the direction of the point's
* Position.
*/
const guaranteeWalkablePath = (grid, point, position) => {
let node = grid.getNodeAt(point.x, point.y);
while (!node.walkable) {
grid.setWalkableAt(node.x, node.y, true);
const next = getNextPointFromPosition(node, position);
node = grid.getNodeAt(next.x, next.y);
}
};
/**
* Each bounding box is a collection of X/Y points in a graph, and we
* need to convert them to "occupied" cells in a 2D grid representation.
*
* The top most position of the grid (grid[0][0]) needs to be equivalent
* to the top most point in the graph (the graph.topLeft point).
*
* Since the top most point can have X/Y values different than zero,
* and each cell in a grid represents a 10x10 pixel area in the grid (or a
* gridRatio area), there's need to be a conversion between a point in a graph
* to a point in the grid.
*
* We do this conversion by dividing a graph point X/Y values by the grid ratio,
* and "shifting" their values up or down, depending on the values of the top
* most point in the graph. The top most point in the graph will have the
* smallest values for X and Y.
*
* We avoid setting nodes in the border of the grid (x=0 or y=0), so there's
* always a "walkable" area around the grid.
*/
const graphToGridPoint = (graphPoint, smallestX, smallestY, gridRatio) => {
let x = graphPoint.x / gridRatio;
let y = graphPoint.y / gridRatio;
let referenceX = smallestX / gridRatio;
let referenceY = smallestY / gridRatio;
if (referenceX < 1) {
while (referenceX !== 1) {
referenceX++;
x++;
}
} else if (referenceX > 1) {
while (referenceX !== 1) {
referenceX--;
x--;
}
} else ;
if (referenceY < 1) {
while (referenceY !== 1) {
referenceY++;
y++;
}
} else if (referenceY > 1) {
while (referenceY !== 1) {
referenceY--;
y--;
}
} else ;
return {
x,
y
};
};
/**
* Converts a grid point back to a graph point, using the reverse logic of
* graphToGridPoint.
*/
const gridToGraphPoint = (gridPoint, smallestX, smallestY, gridRatio) => {
let x = gridPoint.x * gridRatio;
let y = gridPoint.y * gridRatio;
let referenceX = smallestX;
let referenceY = smallestY;
if (referenceX < gridRatio) {
while (referenceX !== gridRatio) {
referenceX = referenceX + gridRatio;
x = x - gridRatio;
}
} else if (referenceX > gridRatio) {
while (referenceX !== gridRatio) {
referenceX = referenceX - gridRatio;
x = x + gridRatio;
}
} else ;
if (referenceY < gridRatio) {
while (referenceY !== gridRatio) {
referenceY = referenceY + gridRatio;
y = y - gridRatio;
}
} else if (referenceY > gridRatio) {
while (referenceY !== gridRatio) {
referenceY = referenceY - gridRatio;
y = y + gridRatio;
}
} else ;
return {
x,
y
};
};
const round = function (x, multiple) {
if (multiple === void 0) {
multiple = 10;
}
return Math.round(x / multiple) * multiple;
};
const roundDown = function (x, multiple) {
if (multiple === void 0) {
multiple = 10;
}
return Math.floor(x / multiple) * multiple;
};
const roundUp = function (x, multiple) {
if (multiple === void 0) {
multiple = 10;
}
return Math.ceil(x / multiple) * multiple;
};
const toInteger = function (value, min) {
if (min === void 0) {
min = 0;
}
let result = Math.max(Math.round(value), min);
result = Number.isInteger(result) ? result : min;
result = result >= min ? result : min;
return result;
};
const createGrid = function (graph, nodes, source, target, gridRatio) {
if (gridRatio === void 0) {
gridRatio = 2;
}
const {
xMin,
yMin,
width,
height
} = graph;
// Create a grid representation of the graph box, where each cell is
// equivalent to 10x10 pixels (or the grid ratio) on the graph. We'll use
// this simplified grid to do pathfinding.
const mapColumns = roundUp(width, gridRatio) / gridRatio + 1;
const mapRows = roundUp(height, gridRatio) / gridRatio + 1;
const grid = new Grid(mapColumns, mapRows);
// Update the grid representation with the space the nodes take up
nodes.forEach(node => {
const nodeStart = graphToGridPoint(node.topLeft, xMin, yMin, gridRatio);
const nodeEnd = graphToGridPoint(node.bottomRight, xMin, yMin, gridRatio);
for (let x = nodeStart.x; x < nodeEnd.x; x++) {
for (let y = nodeStart.y; y < nodeEnd.y; y++) {
grid.setWalkableAt(x, y, false);
}
}
});
// Convert the starting and ending graph points to grid points
const startGrid = graphToGridPoint({
x: round(source.x, gridRatio),
y: round(source.y, gridRatio)
}, xMin, yMin, gridRatio);
const endGrid = graphToGridPoint({
x: round(target.x, gridRatio),
y: round(target.y, gridRatio)
}, xMin, yMin, gridRatio);
// Guarantee a walkable path between the start and end points, even if the
// source or target where covered by another node or by padding
const startingNode = grid.getNodeAt(startGrid.x, startGrid.y);
guaranteeWalkablePath(grid, startingNode, source.position);
const endingNode = grid.getNodeAt(endGrid.x, endGrid.y);
guaranteeWalkablePath(grid, endingNode, target.position);
// Use the next closest points as the start and end points, so
// pathfinding does not start too close to the nodes
const start = getNextPointFromPosition(startingNode, source.position);
const end = getNextPointFromPosition(endingNode, target.position);
return {
grid,
start,
end
};
};
/**
* Draws a SVG path from a list of points, using straight lines.
*/
const svgDrawStraightLinePath = (source, target, path) => {
let svgPathString = `M ${source.x}, ${source.y} `;
path.forEach(point => {
const [x, y] = point;
svgPathString += `L ${x}, ${y} `;
});
svgPathString += `L ${target.x}, ${target.y} `;
return svgPathString;
};
/**
* Draws a SVG path from a list of points, using rounded lines.
*/
const svgDrawSmoothLinePath = (source, target, path) => {
const points = [[source.x, source.y], ...path, [target.x, target.y]];
return quadraticBezierCurve(points);
};
const quadraticBezierCurve = points => {
const X = 0;
const Y = 1;
let point = points[0];
const first = points[0];
let svgPath = `M${first[X]},${first[Y]}M`;
for (let i = 0; i < points.length; i++) {
const next = points[i];
const midPoint = getMidPoint(point[X], point[Y], next[X], next[Y]);
svgPath += ` ${midPoint[X]},${midPoint[Y]}`;
svgPath += `Q${next[X]},${next[Y]}`;
point = next;
}
const last = points[points.length - 1];
svgPath += ` ${last[0]},${last[1]}`;
return svgPath;
};
const getMidPoint = (Ax, Ay, Bx, By) => {
const Zx = (Ax - Bx) / 2 + Bx;
const Zy = (Ay - By) / 2 + By;
return [Zx, Zy];
};
// FIXME: The "pathfinding" module doe not have proper typings.
/* eslint-disable
@typescript-eslint/no-unsafe-call,
@typescript-eslint/no-unsafe-member-access,
@typescript-eslint/no-unsafe-assignment,
@typescript-eslint/ban-ts-comment,
*/
const pathfindingAStarDiagonal = (grid, start, end) => {
try {
const finder = new AStarFinder({
diagonalMovement: DiagonalMovement.Always
});
const fullPath = finder.findPath(start.x, start.y, end.x, end.y, grid);
const smoothedPath = Util.smoothenPath(grid, fullPath);
if (fullPath.length === 0 || smoothedPath.length === 0) return null;
return {
fullPath,
smoothedPath
};
} catch {
return null;
}
};
const pathfindingAStarNoDiagonal = (grid, start, end) => {
try {
const finder = new AStarFinder({
diagonalMovement: DiagonalMovement.Never
});
const fullPath = finder.findPath(start.x, start.y, end.x, end.y, grid);
const smoothedPath = Util.smoothenPath(grid, fullPath);
if (fullPath.length === 0 || smoothedPath.length === 0) return null;
return {
fullPath,
smoothedPath
};
} catch {
return null;
}
};
const pathfindingJumpPointNoDiagonal = (grid, start, end) => {
try {
// FIXME: The "pathfinding" module doe not have proper typings.
// @ts-ignore
const finder = new JumpPointFinder({
diagonalMovement: DiagonalMovement.Never
});
const fullPath = finder.findPath(start.x, start.y, end.x, end.y, grid);
const smoothedPath = fullPath;
if (fullPath.length === 0 || smoothedPath.length === 0) return null;
return {
fullPath,
smoothedPath
};
} catch {
return null;
}
};
/**
* Get the bounding box of all nodes and the graph itself, as X/Y coordinates
* of all corner points.
*
* @param nodes The node list
* @param nodePadding Optional padding to add to the node's and graph bounding boxes
* @param roundTo Everything will be rounded to this nearest integer
* @returns Graph and nodes bounding boxes.
*/
const getBoundingBoxes = function (nodes, nodePadding, roundTo) {
if (nodePadding === void 0) {
nodePadding = 2;
}
if (roundTo === void 0) {
roundTo = 2;
}
let xMax = Number.MIN_SAFE_INTEGER;
let yMax = Number.MIN_SAFE_INTEGER;
let xMin = Number.MAX_SAFE_INTEGER;
let yMin = Number.MAX_SAFE_INTEGER;
const nodeBoxes = nodes.map(node => {
const width = Math.max(node.width || 0, 1);
const height = Math.max(node.height || 0, 1);
const position = {
x: node.position.x || 0,
y: node.position.y || 0
};
const topLeft = {
x: position.x - nodePadding,
y: position.y - nodePadding
};
const bottomLeft = {
x: position.x - nodePadding,
y: position.y + height + nodePadding
};
const topRight = {
x: position.x + width + nodePadding,
y: position.y - nodePadding
};
const bottomRight = {
x: position.x + width + nodePadding,
y: position.y + height + nodePadding
};
if (roundTo > 0) {
topLeft.x = roundDown(topLeft.x, roundTo);
topLeft.y = roundDown(topLeft.y, roundTo);
bottomLeft.x = roundDown(bottomLeft.x, roundTo);
bottomLeft.y = roundUp(bottomLeft.y, roundTo);
topRight.x = roundUp(topRight.x, roundTo);
topRight.y = roundDown(topRight.y, roundTo);
bottomRight.x = roundUp(bottomRight.x, roundTo);
bottomRight.y = roundUp(bottomRight.y, roundTo);
}
if (topLeft.y < yMin) yMin = topLeft.y;
if (topLeft.x < xMin) xMin = topLeft.x;
if (bottomRight.y > yMax) yMax = bottomRight.y;
if (bottomRight.x > xMax) xMax = bottomRight.x;
return {
id: node.id,
width,
height,
topLeft,
bottomLeft,
topRight,
bottomRight
};
});
const graphPadding = nodePadding * 2;
xMax = roundUp(xMax + graphPadding, roundTo);
yMax = roundUp(yMax + graphPadding, roundTo);
xMin = roundDown(xMin - graphPadding, roundTo);
yMin = roundDown(yMin - graphPadding, roundTo);
const topLeft = {
x: xMin,
y: yMin
};
const bottomLeft = {
x: xMin,
y: yMax
};
const topRight = {
x: xMax,
y: yMin
};
const bottomRight = {
x: xMax,
y: yMax
};
const width = Math.abs(topLeft.x - topRight.x);
const height = Math.abs(topLeft.y - bottomLeft.y);
const graphBox = {
topLeft,
bottomLeft,
topRight,
bottomRight,
width,
height,
xMax,
yMax,
xMin,
yMin
};
return {
nodeBoxes,
graphBox
};
};
const getSmartEdge = _ref => {
let {
options = {},
nodes = [],
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition
} = _ref;
try {
const {
drawEdge = svgDrawSmoothLinePath,
generatePath = pathfindingAStarDiagonal
} = options;
let {
gridRatio = 10,
nodePadding = 10
} = options;
gridRatio = toInteger(gridRatio);
nodePadding = toInteger(nodePadding);
// We use the node's information to generate bounding boxes for them
// and the graph
const {
graphBox,
nodeBoxes
} = getBoundingBoxes(nodes, nodePadding, gridRatio);
const source = {
x: sourceX,
y: sourceY,
position: sourcePosition
};
const target = {
x: targetX,
y: targetY,
position: targetPosition
};
// With this information, we can create a 2D grid representation of
// our graph, that tells us where in the graph there is a "free" space or not
const {
grid,
start,
end
} = createGrid(graphBox, nodeBoxes, source, target, gridRatio);
// We then can use the grid representation to do pathfinding
const generatePathResult = generatePath(grid, start, end);
if (generatePathResult === null) {
return null;
}
const {
fullPath,
smoothedPath
} = generatePathResult;
// Here we convert the grid path to a sequence of graph coordinates.
const graphPath = smoothedPath.map(gridPoint => {
const [x, y] = gridPoint;
const graphPoint = gridToGraphPoint({
x,
y
}, graphBox.xMin, graphBox.yMin, gridRatio);
return [graphPoint.x, graphPoint.y];
});
// Finally, we can use the graph path to draw the edge
const svgPathString = drawEdge(source, target, graphPath);
// Compute the edge's middle point using the full path, so users can use
// it to position their custom labels
const index = Math.floor(fullPath.length / 2);
const middlePoint = fullPath[index];
const [middleX, middleY] = middlePoint;
const {
x: edgeCenterX,
y: edgeCenterY
} = gridToGraphPoint({
x: middleX,
y: middleY
}, graphBox.xMin, graphBox.yMin, gridRatio);
return {
svgPathString,
edgeCenterX,
edgeCenterY
};
} catch {
return null;
}
};
function SmartEdge(_ref) {
let {
nodes,
options,
...edgeProps
} = _ref;
const {
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
style,
label,
labelStyle,
labelShowBg,
labelBgStyle,
labelBgPadding,
labelBgBorderRadius,
markerEnd,
markerStart,
interactionWidth
} = edgeProps;
const smartResponse = getSmartEdge({
sourcePosition,
targetPosition,
sourceX,
sourceY,
targetX,
targetY,
options,
nodes
});
const FallbackEdge = options.fallback || BezierEdge;
if (smartResponse === null) {
return /*#__PURE__*/React.createElement(FallbackEdge, {
...edgeProps
});
}
const {
edgeCenterX,
edgeCenterY,
svgPathString
} = smartResponse;
return /*#__PURE__*/React.createElement(BaseEdge, {
path: svgPathString,
labelX: edgeCenterX,
labelY: edgeCenterY,
label: label,
labelStyle: labelStyle,
labelShowBg: labelShowBg,
labelBgStyle: labelBgStyle,
labelBgPadding: labelBgPadding,
labelBgBorderRadius: labelBgBorderRadius,
style: style,
markerStart: markerStart,
markerEnd: markerEnd,
interactionWidth: interactionWidth
});
}
const BezierConfiguration = {
drawEdge: svgDrawSmoothLinePath,
generatePath: pathfindingAStarDiagonal,
fallback: BezierEdge // eslint-disable-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
};
function SmartBezierEdge(props) {
const nodes = useNodes();
return /*#__PURE__*/React.createElement(SmartEdge, {
...props,
options: BezierConfiguration,
nodes: nodes
});
}
const StepConfiguration = {
drawEdge: svgDrawStraightLinePath,
generatePath: pathfindingJumpPointNoDiagonal,
fallback: StepEdge // eslint-disable-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
};
function SmartStepEdge(props) {
const nodes = useNodes();
return /*#__PURE__*/React.createElement(SmartEdge, {
...props,
options: StepConfiguration,
nodes: nodes
});
}
const StraightConfiguration = {
drawEdge: svgDrawStraightLinePath,
generatePath: pathfindingAStarNoDiagonal,
fallback: StraightEdge // eslint-disable-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
};
function SmartStraightEdge(props) {
const nodes = useNodes();
return /*#__PURE__*/React.createElement(SmartEdge, {
...props,
options: StraightConfiguration,
nodes: nodes
});
}
export { SmartBezierEdge, SmartEdge, SmartStepEdge, SmartStraightEdge, SmartBezierEdge as default, getSmartEdge, pathfindingAStarDiagonal, pathfindingAStarNoDiagonal, pathfindingJumpPointNoDiagonal, svgDrawSmoothLinePath, svgDrawStraightLinePath };
//# sourceMappingURL=react-flow-smart-edge.esm.js.map