UNPKG

@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
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