@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,160 @@
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 "@xyflow/react";
15
+
16
+ export type EdgeParams = Pick<
17
+ EdgeProps,
18
+ | "sourceX"
19
+ | "sourceY"
20
+ | "targetX"
21
+ | "targetY"
22
+ | "sourcePosition"
23
+ | "targetPosition"
24
+ >;
25
+
26
+ export interface GetSmartEdgeOptions {
27
+ gridRatio?: number;
28
+ nodePadding?: number;
29
+ drawEdge?: SVGDrawFunction;
30
+ generatePath?: PathFindingFunction;
31
+ // Internal-only debug hook. Not intended for public consumption.
32
+ debug?: {
33
+ enabled?: boolean;
34
+ setGraphBox?: (box: {
35
+ x: number;
36
+ y: number;
37
+ width: number;
38
+ height: number;
39
+ }) => void;
40
+ };
41
+ }
42
+
43
+ export type GetSmartEdgeParams<
44
+ NodeDataType extends Record<string, unknown> = Record<string, unknown>,
45
+ > = EdgeParams & {
46
+ options?: GetSmartEdgeOptions;
47
+ nodes: Node<NodeDataType>[];
48
+ };
49
+
50
+ export interface GetSmartEdgeReturn {
51
+ svgPathString: string;
52
+ edgeCenterX: number;
53
+ edgeCenterY: number;
54
+ }
55
+
56
+ export const getSmartEdge = <
57
+ NodeDataType extends Record<string, unknown> = Record<string, unknown>,
58
+ >({
59
+ options = {},
60
+ nodes = [],
61
+ sourceX,
62
+ sourceY,
63
+ targetX,
64
+ targetY,
65
+ sourcePosition,
66
+ targetPosition,
67
+ }: GetSmartEdgeParams<NodeDataType>): GetSmartEdgeReturn | Error => {
68
+ try {
69
+ const {
70
+ drawEdge = svgDrawSmoothLinePath,
71
+ generatePath = pathfindingAStarDiagonal,
72
+ } = options;
73
+
74
+ let { gridRatio = 10, nodePadding = 10 } = options;
75
+ gridRatio = toInteger(gridRatio);
76
+ nodePadding = toInteger(nodePadding);
77
+
78
+ // We use the node's information to generate bounding boxes for them
79
+ // and the graph
80
+ const { graphBox, nodeBoxes } = getBoundingBoxes(
81
+ nodes,
82
+ nodePadding,
83
+ gridRatio,
84
+ );
85
+
86
+ // Internal: publish computed bounding box for debugging visualization
87
+ if (options.debug?.enabled && options.debug.setGraphBox) {
88
+ options.debug.setGraphBox({
89
+ x: graphBox.topLeft.x,
90
+ y: graphBox.topLeft.y,
91
+ width: graphBox.width,
92
+ height: graphBox.height,
93
+ });
94
+ }
95
+
96
+ const source: PointInfo = {
97
+ x: sourceX,
98
+ y: sourceY,
99
+ position: sourcePosition,
100
+ };
101
+
102
+ const target: PointInfo = {
103
+ x: targetX,
104
+ y: targetY,
105
+ position: targetPosition,
106
+ };
107
+
108
+ // With this information, we can create a 2D grid representation of
109
+ // our graph, that tells us where in the graph there is a "free" space or not
110
+ const { grid, start, end } = createGrid(
111
+ graphBox,
112
+ nodeBoxes,
113
+ source,
114
+ target,
115
+ gridRatio,
116
+ );
117
+
118
+ // We then can use the grid representation to do pathfinding
119
+ const generatePathResult = generatePath(grid, start, end);
120
+
121
+ const fullPath = generatePathResult;
122
+
123
+ // Here we convert the grid path to a sequence of graph coordinates.
124
+ const graphPath = fullPath.map((gridPoint) => {
125
+ const [x, y] = gridPoint;
126
+ const graphPoint = gridToGraphPoint(
127
+ { x, y },
128
+ graphBox.xMin,
129
+ graphBox.yMin,
130
+ gridRatio,
131
+ );
132
+ return [graphPoint.x, graphPoint.y];
133
+ });
134
+
135
+ // Finally, we can use the graph path to draw the edge
136
+ const svgPathString = drawEdge(source, target, graphPath);
137
+
138
+ // Compute the edge's middle point using the full path, so users can use
139
+ // it to position their custom labels
140
+ const index = Math.floor(fullPath.length / 2);
141
+ const middlePoint = fullPath[index];
142
+ const [middleX, middleY] = middlePoint;
143
+ const { x: edgeCenterX, y: edgeCenterY } = gridToGraphPoint(
144
+ { x: middleX, y: middleY },
145
+ graphBox.xMin,
146
+ graphBox.yMin,
147
+ gridRatio,
148
+ );
149
+
150
+ return { svgPathString, edgeCenterX, edgeCenterY };
151
+ } catch (error) {
152
+ if (error instanceof Error) {
153
+ return error;
154
+ } else {
155
+ return new Error(`Unknown error: ${String(error)}`);
156
+ }
157
+ }
158
+ };
159
+
160
+ export type GetSmartEdgeFunction = typeof getSmartEdge;
package/src/index.tsx ADDED
@@ -0,0 +1,18 @@
1
+ export { SmartBezierEdge } from "./SmartBezierEdge";
2
+ export { SmartStraightEdge } from "./SmartStraightEdge";
3
+ export { SmartStepEdge } from "./SmartStepEdge";
4
+ export { getSmartEdge } from "./getSmartEdge";
5
+ export {
6
+ svgDrawSmoothLinePath,
7
+ svgDrawStraightLinePath,
8
+ } from "./functions/drawSvgPath";
9
+ export {
10
+ pathfindingAStarDiagonal,
11
+ pathfindingAStarNoDiagonal,
12
+ } from "./functions/generatePath";
13
+
14
+ export type { GetSmartEdgeOptions } from "./getSmartEdge";
15
+ export type { SVGDrawFunction } from "./functions/drawSvgPath";
16
+ export type { PathFindingFunction } from "./functions/generatePath";
17
+ export type { Grid, GridNode } from "./pathfinding/grid";
18
+ export type { XYPosition } from "@xyflow/react";
@@ -0,0 +1,43 @@
1
+ import { useMemo, useState } from "react";
2
+ import type { PropsWithChildren } from "react";
3
+ import type {
4
+ SmartEdgeGraphBox,
5
+ SmartEdgeDebugContextValue,
6
+ } from "./useSmartEdgeDebug";
7
+ import { SmartEdgeDebugContext } from "./useSmartEdgeDebug";
8
+
9
+ interface SmartEdgeDebugProviderProps {
10
+ value?: boolean;
11
+ }
12
+
13
+ export const SmartEdgeDebugProvider = ({
14
+ value = true,
15
+ children,
16
+ }: PropsWithChildren<SmartEdgeDebugProviderProps>) => {
17
+ const [graphBox, setGraphBoxState] = useState<SmartEdgeGraphBox>(null);
18
+
19
+ const setGraphBox = (next: SmartEdgeGraphBox) => {
20
+ setGraphBoxState((prev) => {
21
+ if (
22
+ prev?.x === next?.x &&
23
+ prev?.y === next?.y &&
24
+ prev?.width === next?.width &&
25
+ prev?.height === next?.height
26
+ ) {
27
+ return prev;
28
+ }
29
+ return next;
30
+ });
31
+ };
32
+
33
+ const contextValue = useMemo<SmartEdgeDebugContextValue>(
34
+ () => ({ enabled: value, graphBox, setGraphBox }),
35
+ [value, graphBox],
36
+ );
37
+
38
+ return (
39
+ <SmartEdgeDebugContext.Provider value={contextValue}>
40
+ {children}
41
+ </SmartEdgeDebugContext.Provider>
42
+ );
43
+ };
@@ -0,0 +1,24 @@
1
+ import { memo } from "react";
2
+ import type { CSSProperties } from "react";
3
+ import { useSmartEdgeDebug } from "./useSmartEdgeDebug";
4
+
5
+ export const SmartEdgeDebugOverlay = memo(() => {
6
+ const { enabled, graphBox } = useSmartEdgeDebug();
7
+
8
+ if (!enabled || !graphBox) return null;
9
+
10
+ const style: CSSProperties = {
11
+ position: "absolute",
12
+ left: graphBox.x,
13
+ top: graphBox.y,
14
+ width: graphBox.width,
15
+ height: graphBox.height,
16
+ pointerEvents: "none",
17
+ border: "1px solid red",
18
+ backgroundColor: "transparent",
19
+ boxSizing: "border-box",
20
+ zIndex: 1,
21
+ };
22
+
23
+ return <div style={style} data-testid="smart-edge-debug-overlay" />;
24
+ });
@@ -0,0 +1,26 @@
1
+ import { createContext, useContext } from "react";
2
+
3
+ export type SmartEdgeGraphBox = {
4
+ x: number;
5
+ y: number;
6
+ width: number;
7
+ height: number;
8
+ } | null;
9
+
10
+ export interface SmartEdgeDebugContextValue {
11
+ enabled: boolean;
12
+ graphBox: SmartEdgeGraphBox;
13
+ setGraphBox: (next: SmartEdgeGraphBox) => void;
14
+ }
15
+
16
+ export const SmartEdgeDebugContext = createContext<SmartEdgeDebugContextValue>({
17
+ enabled: false,
18
+ graphBox: null,
19
+ setGraphBox: () => {
20
+ // Do nothing
21
+ },
22
+ });
23
+
24
+ export const useSmartEdgeDebug = (): SmartEdgeDebugContextValue => {
25
+ return useContext(SmartEdgeDebugContext);
26
+ };
@@ -0,0 +1,134 @@
1
+ // Based on https://github.com/qiao/PathFinding.js
2
+
3
+ import type { Grid, GridNode } from "./grid";
4
+ import type { DiagonalMovement } from "./types.ts";
5
+
6
+ export interface AStarOptions {
7
+ diagonalMovement?: DiagonalMovement;
8
+ heuristic?: (dx: number, dy: number) => number;
9
+ weight?: number;
10
+ }
11
+
12
+ const manhattan = (dx: number, dy: number): number => dx + dy;
13
+
14
+ const octile = (dx: number, dy: number): number => {
15
+ const F = Math.SQRT2 - 1;
16
+ return dx < dy ? F * dx + dy : F * dy + dx;
17
+ };
18
+
19
+ const reconstructPath = (endNode: GridNode): number[][] => {
20
+ const path: number[][] = [];
21
+ let node: GridNode | undefined = endNode;
22
+
23
+ while (node) {
24
+ path.push([node.x, node.y]);
25
+ node = node.parent;
26
+ }
27
+
28
+ return path.reverse();
29
+ };
30
+
31
+ const getHeuristic = (
32
+ diagonalMovement: DiagonalMovement,
33
+ ): ((dx: number, dy: number) => number) => {
34
+ if (diagonalMovement === "Never") return manhattan;
35
+ return octile;
36
+ };
37
+
38
+ const selectNodeWithLowestEstimatedTotalCost = (
39
+ openList: GridNode[],
40
+ ): GridNode => {
41
+ let bestIdx = 0;
42
+
43
+ for (let i = 1; i < openList.length; i++) {
44
+ if (
45
+ (openList[i].estimatedTotalCost ?? Infinity) <
46
+ (openList[bestIdx].estimatedTotalCost ?? Infinity)
47
+ ) {
48
+ bestIdx = i;
49
+ }
50
+ }
51
+
52
+ return openList.splice(bestIdx, 1)[0];
53
+ };
54
+
55
+ const processNeighbor = (
56
+ neighbor: GridNode,
57
+ current: GridNode,
58
+ end: GridNode,
59
+ openList: GridNode[],
60
+ heuristic: (dx: number, dy: number) => number,
61
+ weight: number,
62
+ ): void => {
63
+ if (neighbor.closed) return;
64
+
65
+ const dx = Math.abs(neighbor.x - current.x);
66
+ const dy = Math.abs(neighbor.y - current.y);
67
+
68
+ const tentativeG =
69
+ (current.costFromStart ?? 0) + (dx === 0 || dy === 0 ? 1 : Math.SQRT2);
70
+
71
+ if (!neighbor.opened || tentativeG < (neighbor.costFromStart ?? Infinity)) {
72
+ neighbor.costFromStart = tentativeG;
73
+
74
+ neighbor.heuristicCostToGoal =
75
+ neighbor.heuristicCostToGoal ??
76
+ weight *
77
+ heuristic(Math.abs(neighbor.x - end.x), Math.abs(neighbor.y - end.y));
78
+
79
+ neighbor.estimatedTotalCost =
80
+ (neighbor.costFromStart ?? 0) + (neighbor.heuristicCostToGoal ?? 0);
81
+
82
+ neighbor.parent = current;
83
+
84
+ if (!neighbor.opened) {
85
+ neighbor.opened = true;
86
+ openList.push(neighbor);
87
+ }
88
+ }
89
+ };
90
+
91
+ export const createAStarFinder = (opts: AStarOptions = {}) => {
92
+ const diagonalMovement: DiagonalMovement = opts.diagonalMovement ?? "Never";
93
+ const heuristic = opts.heuristic ?? getHeuristic(diagonalMovement);
94
+ const weight = opts.weight ?? 1;
95
+
96
+ const findPath = (
97
+ startX: number,
98
+ startY: number,
99
+ endX: number,
100
+ endY: number,
101
+ grid: Grid,
102
+ ): number[][] => {
103
+ const start = grid.getNodeAt(startX, startY);
104
+ const end = grid.getNodeAt(endX, endY);
105
+
106
+ // Open list implemented as a simple array with linear min search for clarity
107
+ const openList: GridNode[] = [];
108
+
109
+ start.costFromStart = 0;
110
+ start.heuristicCostToGoal = 0;
111
+ start.estimatedTotalCost = 0;
112
+ start.opened = true;
113
+ openList.push(start);
114
+
115
+ while (openList.length > 0) {
116
+ const node = selectNodeWithLowestEstimatedTotalCost(openList);
117
+ node.closed = true;
118
+
119
+ if (node === end) {
120
+ return reconstructPath(end);
121
+ }
122
+
123
+ const neighbors = grid.getNeighbors(node, diagonalMovement);
124
+ for (const neighbor of neighbors) {
125
+ processNeighbor(neighbor, node, end, openList, heuristic, weight);
126
+ }
127
+ }
128
+
129
+ // no path found
130
+ return [];
131
+ };
132
+
133
+ return { findPath };
134
+ };
@@ -0,0 +1,141 @@
1
+ // Based on https://github.com/qiao/PathFinding.js
2
+ import type { DiagonalMovement } from "./types.ts";
3
+
4
+ // A modern, typed, functional replacement for PathFinding.js Grid
5
+ // Provides the same runtime API shape used by finders/utilities:
6
+ // - width, height, nodes[][]
7
+ // - getNodeAt, isWalkableAt, setWalkableAt, getNeighbors, clone
8
+
9
+ export interface GridNode {
10
+ x: number;
11
+ y: number;
12
+ walkable: boolean;
13
+ // A* search metadata (set during pathfinding)
14
+ costFromStart?: number;
15
+ heuristicCostToGoal?: number;
16
+ estimatedTotalCost?: number;
17
+ opened?: boolean;
18
+ closed?: boolean;
19
+ parent?: GridNode;
20
+ }
21
+
22
+ export interface Grid {
23
+ width: number;
24
+ height: number;
25
+ nodes: GridNode[][]; // nodes[row][col] i.e., nodes[y][x]
26
+
27
+ getNodeAt: (x: number, y: number) => GridNode;
28
+ isWalkableAt: (x: number, y: number) => boolean;
29
+ setWalkableAt: (x: number, y: number, walkable: boolean) => void;
30
+ getNeighbors: (
31
+ node: GridNode,
32
+ diagonalMovement: DiagonalMovement,
33
+ ) => GridNode[];
34
+ isInside: (x: number, y: number) => boolean;
35
+ clone: () => Grid;
36
+ }
37
+
38
+ const createNodes = (
39
+ width: number,
40
+ height: number,
41
+ matrix?: (number | boolean)[][],
42
+ ): GridNode[][] => {
43
+ const rows: GridNode[][] = new Array<GridNode[]>(height);
44
+ for (let y = 0; y < height; y++) {
45
+ const row: GridNode[] = new Array<GridNode>(width);
46
+ for (let x = 0; x < width; x++) {
47
+ // PathFinding.js semantics: a truthy matrix cell means non-walkable
48
+ // (e.g., 1 indicates obstacle). Falsy (0) means walkable.
49
+ const cell = matrix ? matrix[y]?.[x] : undefined;
50
+ const isBlocked = !!cell;
51
+ const walkable = matrix ? !isBlocked : true;
52
+ row[x] = { x, y, walkable };
53
+ }
54
+ rows[y] = row;
55
+ }
56
+ return rows;
57
+ };
58
+
59
+ const withinBounds = (width: number, height: number, x: number, y: number) =>
60
+ x >= 0 && x < width && y >= 0 && y < height;
61
+
62
+ /**
63
+ * Create a grid with the given width/height. Optionally accepts a matrix
64
+ * of booleans/numbers indicating obstacles (truthy = blocked, falsy/0 = walkable).
65
+ */
66
+ export const createGrid = (
67
+ width: number,
68
+ height: number,
69
+ matrix?: (number | boolean)[][],
70
+ ): Grid => {
71
+ const nodes = createNodes(width, height, matrix);
72
+
73
+ const getNodeAt = (x: number, y: number): GridNode => nodes[y][x];
74
+
75
+ const isWalkableAt = (x: number, y: number): boolean =>
76
+ withinBounds(width, height, x, y) && nodes[y][x].walkable;
77
+
78
+ const setWalkableAt = (x: number, y: number, walkable: boolean): void => {
79
+ if (!withinBounds(width, height, x, y)) return;
80
+ nodes[y][x].walkable = walkable;
81
+ };
82
+
83
+ // Diagonal movement policy using string literal union values:
84
+ // "Always", "Never", "IfAtMostOneObstacle", "OnlyWhenNoObstacles"
85
+ const getNeighbors = (
86
+ node: GridNode,
87
+ diagonalMovement: import("./types.ts").DiagonalMovement,
88
+ ): GridNode[] => {
89
+ const x = node.x;
90
+ const y = node.y;
91
+ const neighbors: GridNode[] = [];
92
+
93
+ // ↑, →, ↓, ←
94
+ const s0 = isWalkableAt(x, y - 1);
95
+ const s1 = isWalkableAt(x + 1, y);
96
+ const s2 = isWalkableAt(x, y + 1);
97
+ const s3 = isWalkableAt(x - 1, y);
98
+
99
+ if (s0) neighbors.push(getNodeAt(x, y - 1));
100
+ if (s1) neighbors.push(getNodeAt(x + 1, y));
101
+ if (s2) neighbors.push(getNodeAt(x, y + 1));
102
+ if (s3) neighbors.push(getNodeAt(x - 1, y));
103
+
104
+ // Diagonals: ↗, ↘, ↙, ↖
105
+ const d0Walkable = isWalkableAt(x + 1, y - 1);
106
+ const d1Walkable = isWalkableAt(x + 1, y + 1);
107
+ const d2Walkable = isWalkableAt(x - 1, y + 1);
108
+ const d3Walkable = isWalkableAt(x - 1, y - 1);
109
+
110
+ if (diagonalMovement === "Never") {
111
+ return neighbors;
112
+ }
113
+
114
+ // default: "Always"
115
+ if (d0Walkable) neighbors.push(getNodeAt(x + 1, y - 1));
116
+ if (d1Walkable) neighbors.push(getNodeAt(x + 1, y + 1));
117
+ if (d2Walkable) neighbors.push(getNodeAt(x - 1, y + 1));
118
+ if (d3Walkable) neighbors.push(getNodeAt(x - 1, y - 1));
119
+ return neighbors;
120
+ };
121
+
122
+ const clone = (): Grid => {
123
+ // Recreate the original matrix semantics: truthy = blocked
124
+ const clonedMatrix: number[][] = nodes.map((row) =>
125
+ row.map((node) => (node.walkable ? 0 : 1)),
126
+ );
127
+ return createGrid(width, height, clonedMatrix);
128
+ };
129
+
130
+ return {
131
+ width,
132
+ height,
133
+ nodes,
134
+ getNodeAt,
135
+ isWalkableAt,
136
+ setWalkableAt,
137
+ getNeighbors,
138
+ isInside: (x: number, y: number) => withinBounds(width, height, x, y),
139
+ clone,
140
+ };
141
+ };
@@ -0,0 +1,3 @@
1
+ // Shared types for pathfinding
2
+
3
+ export type DiagonalMovement = "Always" | "Never";
@@ -0,0 +1,94 @@
1
+ import { useNodes, BezierEdge } from "@xyflow/react";
2
+ import { getSmartEdge } from "../getSmartEdge";
3
+ import type { EdgeProps } from "@xyflow/react";
4
+
5
+ const size = 20;
6
+
7
+ export function SmartEdgeCustomLabel(props: EdgeProps) {
8
+ const {
9
+ id,
10
+ sourcePosition,
11
+ targetPosition,
12
+ sourceX,
13
+ sourceY,
14
+ targetX,
15
+ targetY,
16
+ style,
17
+ markerStart,
18
+ markerEnd,
19
+ } = props;
20
+
21
+ const nodes = useNodes();
22
+
23
+ const getSmartEdgeResponse = getSmartEdge({
24
+ sourcePosition,
25
+ targetPosition,
26
+ sourceX,
27
+ sourceY,
28
+ targetX,
29
+ targetY,
30
+ nodes,
31
+ });
32
+
33
+ if (getSmartEdgeResponse instanceof Error) {
34
+ return <BezierEdge {...props} />;
35
+ }
36
+
37
+ const { edgeCenterX, edgeCenterY, svgPathString } = getSmartEdgeResponse;
38
+
39
+ return (
40
+ <>
41
+ <path
42
+ style={style}
43
+ className="react-flow__edge-path"
44
+ d={svgPathString}
45
+ markerEnd={markerEnd}
46
+ markerStart={markerStart}
47
+ />
48
+ <foreignObject
49
+ width={size}
50
+ height={size}
51
+ x={edgeCenterX - size / 2}
52
+ y={edgeCenterY - size / 2}
53
+ style={{
54
+ background: "transparent",
55
+ width: "minContent",
56
+ height: "minContent",
57
+ display: "flex",
58
+ justifyContent: "center",
59
+ alignItems: "center",
60
+ minHeight: "40px",
61
+ }}
62
+ requiredExtensions="http://www.w3.org/1999/xhtml"
63
+ >
64
+ <div
65
+ style={{
66
+ display: "flex",
67
+ justifyContent: "center",
68
+ alignItems: "center",
69
+ flexDirection: "column",
70
+ }}
71
+ >
72
+ <button
73
+ style={{
74
+ width: "20px",
75
+ height: "20px",
76
+ background: "#eee",
77
+ border: "1px solid #fff",
78
+ cursor: "pointer",
79
+ borderRadius: "50%",
80
+ fontSize: "12px",
81
+ lineHeight: "1",
82
+ }}
83
+ onClick={() => {
84
+ alert(`Clicked on edge with id ${id}`);
85
+ }}
86
+ type="button"
87
+ >
88
+ x
89
+ </button>
90
+ </div>
91
+ </foreignObject>
92
+ </>
93
+ );
94
+ }