@joshrp/react-flow-smart-edge 4.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,92 @@
1
+ import { BezierEdge, BaseEdge } from "@xyflow/react";
2
+ import type { ComponentType } from "react";
3
+ import { getSmartEdge } from "../getSmartEdge";
4
+ import { useSmartEdgeDebug } from "../internal/useSmartEdgeDebug";
5
+ import type { GetSmartEdgeOptions } from "../getSmartEdge";
6
+ import type { EdgeProps, Node, Edge } from "@xyflow/react";
7
+
8
+ export type SmartEdgeOptions = GetSmartEdgeOptions & {
9
+ fallback?: ComponentType<EdgeProps<Edge>>;
10
+ };
11
+
12
+ export interface SmartEdgeProps<
13
+ EdgeType extends Edge = Edge,
14
+ NodeType extends Node = Node,
15
+ > extends EdgeProps<EdgeType> {
16
+ nodes: NodeType[];
17
+ options: SmartEdgeOptions;
18
+ }
19
+
20
+ export function SmartEdge<
21
+ EdgeType extends Edge = Edge,
22
+ NodeType extends Node = Node,
23
+ >({
24
+ nodes,
25
+ options,
26
+ ...edgeProps
27
+ }: Readonly<SmartEdgeProps<EdgeType, NodeType>>) {
28
+ const { enabled: isDebugEnabled, setGraphBox } = useSmartEdgeDebug();
29
+ const {
30
+ sourceX,
31
+ sourceY,
32
+ sourcePosition,
33
+ targetX,
34
+ targetY,
35
+ targetPosition,
36
+ style,
37
+ label,
38
+ labelStyle,
39
+ labelShowBg,
40
+ labelBgStyle,
41
+ labelBgPadding,
42
+ labelBgBorderRadius,
43
+ markerEnd,
44
+ markerStart,
45
+ interactionWidth,
46
+ } = edgeProps;
47
+
48
+ const smartResponse = getSmartEdge({
49
+ sourcePosition,
50
+ targetPosition,
51
+ sourceX,
52
+ sourceY,
53
+ targetX,
54
+ targetY,
55
+ options: {
56
+ ...options,
57
+ debug: { enabled: isDebugEnabled, setGraphBox },
58
+ } as GetSmartEdgeOptions,
59
+ nodes,
60
+ });
61
+
62
+ const FallbackEdge = options.fallback ?? BezierEdge;
63
+
64
+ if (smartResponse instanceof Error) {
65
+ if (isDebugEnabled) {
66
+ console.error(smartResponse);
67
+ }
68
+ return <FallbackEdge {...edgeProps} />;
69
+ }
70
+
71
+ const { edgeCenterX, edgeCenterY, svgPathString } = smartResponse;
72
+
73
+ return (
74
+ <BaseEdge
75
+ path={svgPathString}
76
+ labelX={edgeCenterX}
77
+ labelY={edgeCenterY}
78
+ label={label}
79
+ labelStyle={labelStyle}
80
+ labelShowBg={labelShowBg}
81
+ labelBgStyle={labelBgStyle}
82
+ labelBgPadding={labelBgPadding}
83
+ labelBgBorderRadius={labelBgBorderRadius}
84
+ style={style}
85
+ markerStart={markerStart}
86
+ markerEnd={markerEnd}
87
+ interactionWidth={interactionWidth}
88
+ />
89
+ );
90
+ }
91
+
92
+ export type SmartEdgeFunction = typeof SmartEdge;
@@ -0,0 +1,29 @@
1
+ import { useNodes, StepEdge } from "@xyflow/react";
2
+ import { SmartEdge } from "../SmartEdge";
3
+ import {
4
+ svgDrawStraightLinePath,
5
+ pathfindingAStarNoDiagonal,
6
+ } from "../functions";
7
+ import type { SmartEdgeOptions } from "../SmartEdge";
8
+ import type { Edge, EdgeProps, Node } from "@xyflow/react";
9
+
10
+ const StepConfiguration: SmartEdgeOptions = {
11
+ drawEdge: svgDrawStraightLinePath,
12
+ generatePath: pathfindingAStarNoDiagonal,
13
+ fallback: StepEdge,
14
+ };
15
+
16
+ export function SmartStepEdge<
17
+ EdgeType extends Edge = Edge,
18
+ NodeType extends Node = Node,
19
+ >(props: EdgeProps<EdgeType>) {
20
+ const nodes = useNodes<NodeType>();
21
+
22
+ return (
23
+ <SmartEdge<EdgeType, NodeType>
24
+ {...props}
25
+ options={StepConfiguration}
26
+ nodes={nodes}
27
+ />
28
+ );
29
+ }
@@ -0,0 +1,29 @@
1
+ import { useNodes, StraightEdge } from "@xyflow/react";
2
+ import { SmartEdge } from "../SmartEdge";
3
+ import {
4
+ svgDrawStraightLinePath,
5
+ pathfindingAStarDiagonal,
6
+ } from "../functions";
7
+ import type { SmartEdgeOptions } from "../SmartEdge";
8
+ import type { Edge, EdgeProps, Node } from "@xyflow/react";
9
+
10
+ const StraightConfiguration: SmartEdgeOptions = {
11
+ drawEdge: svgDrawStraightLinePath,
12
+ generatePath: pathfindingAStarDiagonal,
13
+ fallback: StraightEdge,
14
+ };
15
+
16
+ export function SmartStraightEdge<
17
+ EdgeType extends Edge = Edge,
18
+ NodeType extends Node = Node,
19
+ >(props: EdgeProps<EdgeType>) {
20
+ const nodes = useNodes<NodeType>();
21
+
22
+ return (
23
+ <SmartEdge<EdgeType, NodeType>
24
+ {...props}
25
+ options={StraightConfiguration}
26
+ nodes={nodes}
27
+ />
28
+ );
29
+ }
@@ -0,0 +1,81 @@
1
+ import { createGrid as createLocalGrid } from "../pathfinding/grid";
2
+ import type { Grid } from "../pathfinding/grid";
3
+ import {
4
+ guaranteeWalkablePath,
5
+ getNextPointFromPosition,
6
+ } from "./guaranteeWalkablePath";
7
+ import { graphToGridPoint } from "./pointConversion";
8
+ import { round, roundUp } from "./utils";
9
+ import type { NodeBoundingBox, GraphBoundingBox } from "./getBoundingBoxes";
10
+ import type { Position } from "@xyflow/react";
11
+
12
+ export interface PointInfo {
13
+ x: number;
14
+ y: number;
15
+ position: Position;
16
+ }
17
+
18
+ export const createGrid = (
19
+ graph: GraphBoundingBox,
20
+ nodes: NodeBoundingBox[],
21
+ source: PointInfo,
22
+ target: PointInfo,
23
+ gridRatio = 2,
24
+ ) => {
25
+ const { xMin, yMin, width, height } = graph;
26
+
27
+ // Create a grid representation of the graph box, where each cell is
28
+ // equivalent to 10x10 pixels (or the grid ratio) on the graph. We'll use
29
+ // this simplified grid to do pathfinding.
30
+ const mapColumns = roundUp(width, gridRatio) / gridRatio + 1;
31
+ const mapRows = roundUp(height, gridRatio) / gridRatio + 1;
32
+ const grid: Grid = createLocalGrid(mapColumns, mapRows);
33
+
34
+ // Update the grid representation with the space the nodes take up
35
+ nodes.forEach((node) => {
36
+ const nodeStart = graphToGridPoint(node.topLeft, xMin, yMin, gridRatio);
37
+ const nodeEnd = graphToGridPoint(node.bottomRight, xMin, yMin, gridRatio);
38
+
39
+ for (let x = nodeStart.x; x < nodeEnd.x; x++) {
40
+ for (let y = nodeStart.y; y < nodeEnd.y; y++) {
41
+ grid.setWalkableAt(x, y, false);
42
+ }
43
+ }
44
+ });
45
+
46
+ // Convert the starting and ending graph points to grid points
47
+ const startGrid = graphToGridPoint(
48
+ {
49
+ x: round(source.x, gridRatio),
50
+ y: round(source.y, gridRatio),
51
+ },
52
+ xMin,
53
+ yMin,
54
+ gridRatio,
55
+ );
56
+
57
+ const endGrid = graphToGridPoint(
58
+ {
59
+ x: round(target.x, gridRatio),
60
+ y: round(target.y, gridRatio),
61
+ },
62
+ xMin,
63
+ yMin,
64
+ gridRatio,
65
+ );
66
+
67
+ // Guarantee a walkable path between the start and end points, even if the
68
+ // source or target where covered by another node or by padding
69
+ const startingNode = grid.getNodeAt(startGrid.x, startGrid.y);
70
+ guaranteeWalkablePath(grid, startingNode, source.position);
71
+
72
+ const endingNode = grid.getNodeAt(endGrid.x, endGrid.y);
73
+ guaranteeWalkablePath(grid, endingNode, target.position);
74
+
75
+ // Use the next closest points as the start and end points, so
76
+ // pathfinding does not start too close to the nodes
77
+ const start = getNextPointFromPosition(startingNode, source.position);
78
+ const end = getNextPointFromPosition(endingNode, target.position);
79
+
80
+ return { grid, start, end };
81
+ };
@@ -0,0 +1,72 @@
1
+ import type { XYPosition } from "@xyflow/react";
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 ${String(source.x)}, ${String(source.y)} `;
23
+
24
+ path.forEach((point) => {
25
+ const [x, y] = point;
26
+ svgPathString += `L ${String(x)}, ${String(y)} `;
27
+ });
28
+
29
+ svgPathString += `L ${String(target.x)}, ${String(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${String(first[X])},${String(first[Y])}M`;
53
+
54
+ for (const next of points) {
55
+ const midPoint = getMidPoint(point[X], point[Y], next[X], next[Y]);
56
+
57
+ svgPath += ` ${String(midPoint[X])},${String(midPoint[Y])}`;
58
+ svgPath += `Q${String(next[X])},${String(next[Y])}`;
59
+ point = next;
60
+ }
61
+
62
+ const last = points[points.length - 1];
63
+ svgPath += ` ${String(last[0])},${String(last[1])}`;
64
+
65
+ return svgPath;
66
+ };
67
+
68
+ const getMidPoint = (Ax: number, Ay: number, Bx: number, By: number) => {
69
+ const Zx = (Ax - Bx) / 2 + Bx;
70
+ const Zy = (Ay - By) / 2 + By;
71
+ return [Zx, Zy];
72
+ };
@@ -0,0 +1,60 @@
1
+ import { createAStarFinder } from "../pathfinding/aStar";
2
+ import type { Grid } from "../pathfinding/grid";
3
+ import type { XYPosition } from "@xyflow/react";
4
+
5
+ /**
6
+ * Takes source and target {x, y} points, together with an grid representation
7
+ * of the graph, and returns an array of number tuples [x, y], representing
8
+ * the full path from source to target.
9
+ */
10
+ export type PathFindingFunction = (
11
+ grid: Grid,
12
+ start: XYPosition,
13
+ end: XYPosition,
14
+ ) => number[][];
15
+
16
+ export const pathfindingAStarDiagonal: PathFindingFunction = (
17
+ grid,
18
+ start,
19
+ end,
20
+ ) => {
21
+ try {
22
+ const finder = createAStarFinder({
23
+ diagonalMovement: "Always",
24
+ });
25
+ const fullPath = finder.findPath(start.x, start.y, end.x, end.y, grid);
26
+
27
+ if (fullPath.length === 0) {
28
+ throw new Error("No path found");
29
+ }
30
+ return fullPath;
31
+ } catch (error) {
32
+ if (error instanceof Error) {
33
+ throw error;
34
+ }
35
+ throw new Error(`Unknown error: ${String(error)}`);
36
+ }
37
+ };
38
+
39
+ export const pathfindingAStarNoDiagonal: PathFindingFunction = (
40
+ grid,
41
+ start,
42
+ end,
43
+ ) => {
44
+ try {
45
+ const finder = createAStarFinder({
46
+ diagonalMovement: "Never",
47
+ });
48
+ const fullPath = finder.findPath(start.x, start.y, end.x, end.y, grid);
49
+
50
+ if (fullPath.length === 0) {
51
+ throw new Error("No path found");
52
+ }
53
+ return fullPath;
54
+ } catch (error) {
55
+ if (error instanceof Error) {
56
+ throw error;
57
+ }
58
+ throw new Error(`Unknown error: ${String(error)}`);
59
+ }
60
+ };
@@ -0,0 +1,138 @@
1
+ import { roundUp, roundDown } from "./utils";
2
+ import type { Node, XYPosition } from "@xyflow/react";
3
+
4
+ export interface 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 interface 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
+ export const getBoundingBoxes = (
32
+ nodes: Node[],
33
+ nodePadding = 2,
34
+ roundTo = 2,
35
+ ) => {
36
+ let xMax = Number.MIN_SAFE_INTEGER;
37
+ let yMax = Number.MIN_SAFE_INTEGER;
38
+ let xMin = Number.MAX_SAFE_INTEGER;
39
+ let yMin = Number.MAX_SAFE_INTEGER;
40
+
41
+ const nodeBoxes: NodeBoundingBox[] = nodes.map((node) => {
42
+ const width = Math.max(node.measured?.width ?? 0, 1);
43
+ const height = Math.max(node.measured?.height ?? 0, 1);
44
+
45
+ const position: XYPosition = {
46
+ x: node.position.x,
47
+ y: node.position.y,
48
+ };
49
+
50
+ const topLeft: XYPosition = {
51
+ x: position.x - nodePadding,
52
+ y: position.y - nodePadding,
53
+ };
54
+ const bottomLeft: XYPosition = {
55
+ x: position.x - nodePadding,
56
+ y: position.y + height + nodePadding,
57
+ };
58
+ const topRight: XYPosition = {
59
+ x: position.x + width + nodePadding,
60
+ y: position.y - nodePadding,
61
+ };
62
+ const bottomRight: XYPosition = {
63
+ x: position.x + width + nodePadding,
64
+ y: position.y + height + nodePadding,
65
+ };
66
+
67
+ if (roundTo > 0) {
68
+ topLeft.x = roundDown(topLeft.x, roundTo);
69
+ topLeft.y = roundDown(topLeft.y, roundTo);
70
+ bottomLeft.x = roundDown(bottomLeft.x, roundTo);
71
+ bottomLeft.y = roundUp(bottomLeft.y, roundTo);
72
+ topRight.x = roundUp(topRight.x, roundTo);
73
+ topRight.y = roundDown(topRight.y, roundTo);
74
+ bottomRight.x = roundUp(bottomRight.x, roundTo);
75
+ bottomRight.y = roundUp(bottomRight.y, roundTo);
76
+ }
77
+
78
+ if (topLeft.y < yMin) yMin = topLeft.y;
79
+ if (topLeft.x < xMin) xMin = topLeft.x;
80
+ if (bottomRight.y > yMax) yMax = bottomRight.y;
81
+ if (bottomRight.x > xMax) xMax = bottomRight.x;
82
+
83
+ return {
84
+ id: node.id,
85
+ width,
86
+ height,
87
+ topLeft,
88
+ bottomLeft,
89
+ topRight,
90
+ bottomRight,
91
+ };
92
+ });
93
+
94
+ const graphPadding = nodePadding * 2;
95
+
96
+ xMax = roundUp(xMax + graphPadding, roundTo);
97
+ yMax = roundUp(yMax + graphPadding, roundTo);
98
+ xMin = roundDown(xMin - graphPadding, roundTo);
99
+ yMin = roundDown(yMin - graphPadding, roundTo);
100
+
101
+ const topLeft: XYPosition = {
102
+ x: xMin,
103
+ y: yMin,
104
+ };
105
+
106
+ const bottomLeft: XYPosition = {
107
+ x: xMin,
108
+ y: yMax,
109
+ };
110
+
111
+ const topRight: XYPosition = {
112
+ x: xMax,
113
+ y: yMin,
114
+ };
115
+
116
+ const bottomRight: XYPosition = {
117
+ x: xMax,
118
+ y: yMax,
119
+ };
120
+
121
+ const width = Math.abs(topLeft.x - topRight.x);
122
+ const height = Math.abs(topLeft.y - bottomLeft.y);
123
+
124
+ const graphBox: GraphBoundingBox = {
125
+ topLeft,
126
+ bottomLeft,
127
+ topRight,
128
+ bottomRight,
129
+ width,
130
+ height,
131
+ xMax,
132
+ yMax,
133
+ xMin,
134
+ yMin,
135
+ };
136
+
137
+ return { nodeBoxes, graphBox };
138
+ };
@@ -0,0 +1,38 @@
1
+ import type { Grid } from "../pathfinding/grid";
2
+ import type { Position, XYPosition } from "@xyflow/react";
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
+ };
@@ -0,0 +1,7 @@
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";
@@ -0,0 +1,49 @@
1
+ import type { XYPosition } from "@xyflow/react";
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
+ // Affine transform: translate by top-left, scale by grid size, then offset border (1 cell)
30
+ const x = (graphPoint.x - smallestX) / gridRatio + 1;
31
+ const y = (graphPoint.y - smallestY) / gridRatio + 1;
32
+ return { x, y };
33
+ };
34
+
35
+ /**
36
+ * Converts a grid point back to a graph point, using the reverse logic of
37
+ * graphToGridPoint.
38
+ */
39
+ export const gridToGraphPoint = (
40
+ gridPoint: XYPosition,
41
+ smallestX: number,
42
+ smallestY: number,
43
+ gridRatio: number,
44
+ ): XYPosition => {
45
+ // Inverse affine transform: remove border, scale by grid size, then translate by top-left
46
+ const x = (gridPoint.x - 1) * gridRatio + smallestX;
47
+ const y = (gridPoint.y - 1) * gridRatio + smallestY;
48
+ return { x, y };
49
+ };
@@ -0,0 +1,15 @@
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
+ };