reactflow-edge-routing 0.1.4 → 0.1.5
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.
- package/package.json +1 -1
- package/src/routing-core.ts +125 -0
- package/src/use-edge-routing.ts +33 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reactflow-edge-routing",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "Orthogonal edge routing for React Flow using obstacle-router. Edges route around nodes while pins stay fixed at their anchor points.",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
package/src/routing-core.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* shape an edge belongs to and won't route through it.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { getBezierPath, getSmoothStepPath, getStraightPath, Position } from "@xyflow/system";
|
|
10
|
+
|
|
9
11
|
import {
|
|
10
12
|
Router as AvoidRouter,
|
|
11
13
|
Point as AvoidPoint,
|
|
@@ -120,6 +122,8 @@ export type AvoidRouterOptions = {
|
|
|
120
122
|
shouldSplitEdgesNearHandle?: boolean;
|
|
121
123
|
/** Auto-select best connection side based on relative node positions. Default: true */
|
|
122
124
|
autoBestSideConnection?: boolean;
|
|
125
|
+
/** When true, only route edges whose direct path is blocked by an obstacle. Unblocked edges get a straight line. Default: true */
|
|
126
|
+
routeOnlyWhenBlocked?: boolean;
|
|
123
127
|
/** Debounce delay for routing updates (ms). Default: 0 */
|
|
124
128
|
debounceMs?: number;
|
|
125
129
|
};
|
|
@@ -252,6 +256,55 @@ function buildLabelFractions(
|
|
|
252
256
|
return fractions;
|
|
253
257
|
}
|
|
254
258
|
|
|
259
|
+
/** Derive the React Flow Position (Left/Right/Top/Bottom) from a handle point and its stub point. */
|
|
260
|
+
function sideFromStub(
|
|
261
|
+
handlePt: { x: number; y: number },
|
|
262
|
+
stubPt: { x: number; y: number }
|
|
263
|
+
): Position {
|
|
264
|
+
const dx = stubPt.x - handlePt.x;
|
|
265
|
+
const dy = stubPt.y - handlePt.y;
|
|
266
|
+
if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? Position.Right : Position.Left;
|
|
267
|
+
return dy >= 0 ? Position.Bottom : Position.Top;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Returns true if the segment p1→p2 passes through the rectangle (with optional buffer). Uses slab method. */
|
|
271
|
+
function segmentIntersectsRect(
|
|
272
|
+
p1: { x: number; y: number },
|
|
273
|
+
p2: { x: number; y: number },
|
|
274
|
+
rect: { x: number; y: number; w: number; h: number },
|
|
275
|
+
buffer = 0
|
|
276
|
+
): boolean {
|
|
277
|
+
const rx = rect.x - buffer, ry = rect.y - buffer;
|
|
278
|
+
const rw = rect.w + buffer * 2, rh = rect.h + buffer * 2;
|
|
279
|
+
const inside = (p: { x: number; y: number }) => p.x >= rx && p.x <= rx + rw && p.y >= ry && p.y <= ry + rh;
|
|
280
|
+
if (inside(p1) || inside(p2)) return true;
|
|
281
|
+
const dx = p2.x - p1.x, dy = p2.y - p1.y;
|
|
282
|
+
let tMin = 0, tMax = 1;
|
|
283
|
+
if (Math.abs(dx) < 1e-10) { if (p1.x < rx || p1.x > rx + rw) return false; }
|
|
284
|
+
else { const t1 = (rx - p1.x) / dx, t2 = (rx + rw - p1.x) / dx; tMin = Math.max(tMin, Math.min(t1, t2)); tMax = Math.min(tMax, Math.max(t1, t2)); }
|
|
285
|
+
if (Math.abs(dy) < 1e-10) { if (p1.y < ry || p1.y > ry + rh) return false; }
|
|
286
|
+
else { const t1 = (ry - p1.y) / dy, t2 = (ry + rh - p1.y) / dy; tMin = Math.max(tMin, Math.min(t1, t2)); tMax = Math.min(tMax, Math.max(t1, t2)); }
|
|
287
|
+
return tMin <= tMax;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** Returns true if the direct line from srcPt to tgtPt is blocked by any node except the source/target nodes. */
|
|
291
|
+
function isEdgeDirectPathBlocked(
|
|
292
|
+
srcPt: { x: number; y: number },
|
|
293
|
+
tgtPt: { x: number; y: number },
|
|
294
|
+
srcNodeId: string,
|
|
295
|
+
tgtNodeId: string,
|
|
296
|
+
nodes: FlowNode[],
|
|
297
|
+
nodeById: Map<string, FlowNode>,
|
|
298
|
+
buffer: number
|
|
299
|
+
): boolean {
|
|
300
|
+
for (const node of nodes) {
|
|
301
|
+
if (node.id === srcNodeId || node.id === tgtNodeId) continue;
|
|
302
|
+
const bounds = Geometry.getNodeBoundsAbsolute(node, nodeById);
|
|
303
|
+
if (segmentIntersectsRect(srcPt, tgtPt, bounds, buffer)) return true;
|
|
304
|
+
}
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
|
|
255
308
|
// ---- Geometry ----
|
|
256
309
|
|
|
257
310
|
export class Geometry {
|
|
@@ -920,9 +973,45 @@ export class RoutingEngine {
|
|
|
920
973
|
}
|
|
921
974
|
}
|
|
922
975
|
|
|
976
|
+
// For unblocked edges, generate the React Flow fallback path directly.
|
|
923
977
|
const connType = opts.connectorType ?? "orthogonal";
|
|
978
|
+
const borderRadius = opts.edgeRounding ?? 0;
|
|
979
|
+
const directRoutes = new Map<string, { path: string; labelX: number; labelY: number; srcPt: { x: number; y: number }; tgtPt: { x: number; y: number } }>();
|
|
980
|
+
if (opts.routeOnlyWhenBlocked !== false) {
|
|
981
|
+
const edgeById = new Map(edges.map((e) => [e.id, e]));
|
|
982
|
+
const buffer = opts.shapeBufferDistance ?? 8;
|
|
983
|
+
for (const [edgeId, points] of edgePoints) {
|
|
984
|
+
const edge = edgeById.get(edgeId);
|
|
985
|
+
if (!edge) continue;
|
|
986
|
+
const src = points[0], tgt = points[points.length - 1];
|
|
987
|
+
if (!isEdgeDirectPathBlocked(src, tgt, edge.source, edge.target, nodes, nodeById, buffer)) {
|
|
988
|
+
const stub = stubMap.get(edgeId);
|
|
989
|
+
if (stub) {
|
|
990
|
+
const srcPos = sideFromStub(stub.srcHandlePt, stub.srcStubPt);
|
|
991
|
+
const tgtPos = sideFromStub(stub.tgtHandlePt, stub.tgtStubPt);
|
|
992
|
+
const { x: sx, y: sy } = stub.srcHandlePt;
|
|
993
|
+
const { x: tx, y: ty } = stub.tgtHandlePt;
|
|
994
|
+
let path: string; let labelX: number; let labelY: number;
|
|
995
|
+
if (connType === "bezier") {
|
|
996
|
+
[path, labelX, labelY] = getBezierPath({ sourceX: sx, sourceY: sy, sourcePosition: srcPos, targetX: tx, targetY: ty, targetPosition: tgtPos });
|
|
997
|
+
} else if (connType === "polyline") {
|
|
998
|
+
[path, labelX, labelY] = getStraightPath({ sourceX: sx, sourceY: sy, targetX: tx, targetY: ty });
|
|
999
|
+
} else {
|
|
1000
|
+
[path, labelX, labelY] = getSmoothStepPath({ sourceX: sx, sourceY: sy, sourcePosition: srcPos, targetX: tx, targetY: ty, targetPosition: tgtPos, borderRadius });
|
|
1001
|
+
}
|
|
1002
|
+
directRoutes.set(edgeId, { path, labelX, labelY, srcPt: stub.srcHandlePt, tgtPt: stub.tgtHandlePt });
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
924
1008
|
const labelFractions = buildLabelFractions(edges, edgePoints);
|
|
925
1009
|
for (const [edgeId, points] of edgePoints) {
|
|
1010
|
+
const direct = directRoutes.get(edgeId);
|
|
1011
|
+
if (direct) {
|
|
1012
|
+
result[edgeId] = { path: direct.path, labelX: direct.labelX, labelY: direct.labelY, sourceX: direct.srcPt.x, sourceY: direct.srcPt.y, targetX: direct.tgtPt.x, targetY: direct.tgtPt.y };
|
|
1013
|
+
continue;
|
|
1014
|
+
}
|
|
926
1015
|
const edgeRounding = opts.edgeRounding ?? 0;
|
|
927
1016
|
const path = connType === "bezier"
|
|
928
1017
|
? PathBuilder.routedBezierPath(points)
|
|
@@ -1090,9 +1179,45 @@ export class PersistentRouter {
|
|
|
1090
1179
|
}
|
|
1091
1180
|
}
|
|
1092
1181
|
|
|
1182
|
+
// For unblocked edges, generate the React Flow fallback path directly.
|
|
1093
1183
|
const connType = opts.connectorType ?? "orthogonal";
|
|
1184
|
+
const borderRadius = opts.edgeRounding ?? 0;
|
|
1185
|
+
const directRoutes = new Map<string, { path: string; labelX: number; labelY: number; srcPt: { x: number; y: number }; tgtPt: { x: number; y: number } }>();
|
|
1186
|
+
if (opts.routeOnlyWhenBlocked !== false) {
|
|
1187
|
+
const edgeById = new Map(this.prevEdges.map((e) => [e.id, e]));
|
|
1188
|
+
const buffer = opts.shapeBufferDistance ?? 8;
|
|
1189
|
+
for (const [edgeId, points] of edgePoints) {
|
|
1190
|
+
const edge = edgeById.get(edgeId);
|
|
1191
|
+
if (!edge) continue;
|
|
1192
|
+
const src = points[0], tgt = points[points.length - 1];
|
|
1193
|
+
if (!isEdgeDirectPathBlocked(src, tgt, edge.source, edge.target, this.prevNodes, this.nodeById, buffer)) {
|
|
1194
|
+
const stub = stubMap.get(edgeId);
|
|
1195
|
+
if (stub) {
|
|
1196
|
+
const srcPos = sideFromStub(stub.srcHandlePt, stub.srcStubPt);
|
|
1197
|
+
const tgtPos = sideFromStub(stub.tgtHandlePt, stub.tgtStubPt);
|
|
1198
|
+
const { x: sx, y: sy } = stub.srcHandlePt;
|
|
1199
|
+
const { x: tx, y: ty } = stub.tgtHandlePt;
|
|
1200
|
+
let path: string; let labelX: number; let labelY: number;
|
|
1201
|
+
if (connType === "bezier") {
|
|
1202
|
+
[path, labelX, labelY] = getBezierPath({ sourceX: sx, sourceY: sy, sourcePosition: srcPos, targetX: tx, targetY: ty, targetPosition: tgtPos });
|
|
1203
|
+
} else if (connType === "polyline") {
|
|
1204
|
+
[path, labelX, labelY] = getStraightPath({ sourceX: sx, sourceY: sy, targetX: tx, targetY: ty });
|
|
1205
|
+
} else {
|
|
1206
|
+
[path, labelX, labelY] = getSmoothStepPath({ sourceX: sx, sourceY: sy, sourcePosition: srcPos, targetX: tx, targetY: ty, targetPosition: tgtPos, borderRadius });
|
|
1207
|
+
}
|
|
1208
|
+
directRoutes.set(edgeId, { path, labelX, labelY, srcPt: stub.srcHandlePt, tgtPt: stub.tgtHandlePt });
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1094
1214
|
const labelFractions = buildLabelFractions(this.prevEdges, edgePoints);
|
|
1095
1215
|
for (const [edgeId, points] of edgePoints) {
|
|
1216
|
+
const direct = directRoutes.get(edgeId);
|
|
1217
|
+
if (direct) {
|
|
1218
|
+
result[edgeId] = { path: direct.path, labelX: direct.labelX, labelY: direct.labelY, sourceX: direct.srcPt.x, sourceY: direct.srcPt.y, targetX: direct.tgtPt.x, targetY: direct.tgtPt.y };
|
|
1219
|
+
continue;
|
|
1220
|
+
}
|
|
1096
1221
|
const edgeRounding = opts.edgeRounding ?? 0;
|
|
1097
1222
|
const path = connType === "bezier"
|
|
1098
1223
|
? PathBuilder.routedBezierPath(points)
|
package/src/use-edge-routing.ts
CHANGED
|
@@ -69,6 +69,8 @@ export interface UseEdgeRoutingOptions {
|
|
|
69
69
|
/** When true, each edge gets its own stub spread by handleSpacing (fan-out). When false, all edges share one stub and libavoid routes them apart after. Default: true */
|
|
70
70
|
shouldSplitEdgesNearHandle?: boolean;
|
|
71
71
|
autoBestSideConnection?: boolean;
|
|
72
|
+
/** When true, only route edges whose direct path is blocked by an obstacle. Default: true */
|
|
73
|
+
routeOnlyWhenBlocked?: boolean;
|
|
72
74
|
debounceMs?: number;
|
|
73
75
|
|
|
74
76
|
/** If true, re-route in real time while dragging instead of on drag stop. Default: false */
|
|
@@ -132,6 +134,7 @@ function toRouterOptions(opts?: UseEdgeRoutingOptions): AvoidRouterOptions {
|
|
|
132
134
|
// bezier defaults to autoBestSideConnection: true — explicit handles
|
|
133
135
|
// make no visual sense on curved paths, so auto-side is the right default.
|
|
134
136
|
autoBestSideConnection: opts?.autoBestSideConnection ?? (opts?.connectorType === "bezier" ? true : DEFAULT_OPTIONS.autoBestSideConnection),
|
|
137
|
+
routeOnlyWhenBlocked: opts?.routeOnlyWhenBlocked ?? true,
|
|
135
138
|
debounceMs: opts?.debounceMs ?? DEFAULT_OPTIONS.debounceMs,
|
|
136
139
|
};
|
|
137
140
|
}
|
|
@@ -195,9 +198,37 @@ export function useEdgeRouting(
|
|
|
195
198
|
|
|
196
199
|
// Full reset on position changes — nodesRef.current must have updated positions
|
|
197
200
|
// by the time this fires (ensured by the debounce + rAF delay).
|
|
201
|
+
// For bezier, only re-route edges connected to the changed nodes; all others
|
|
202
|
+
// keep their current routes (merged by the worker listener).
|
|
198
203
|
const sendIncrementalChanges = useCallback(
|
|
199
|
-
(
|
|
200
|
-
|
|
204
|
+
(nodeIds: string[]) => {
|
|
205
|
+
if (optsRef.current.connectorType !== "bezier" || nodeIds.length === 0) {
|
|
206
|
+
sendReset();
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const changedSet = new Set(nodeIds);
|
|
211
|
+
const affectedEdges = edgesRef.current.filter(
|
|
212
|
+
(e) => changedSet.has(e.source) || changedSet.has(e.target)
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
if (affectedEdges.length === 0 || affectedEdges.length === edgesRef.current.length) {
|
|
216
|
+
sendReset();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (!workerLoaded) return;
|
|
221
|
+
const enrich = enrichNodeRef.current;
|
|
222
|
+
const nodes = nodesRef.current;
|
|
223
|
+
const enrichedNodes = enrich ? nodes.map(enrich) : nodes;
|
|
224
|
+
post({
|
|
225
|
+
command: "route",
|
|
226
|
+
nodes: enrichedNodes as unknown as FlowNode[],
|
|
227
|
+
edges: affectedEdges as unknown as FlowEdge[],
|
|
228
|
+
options: optsRef.current,
|
|
229
|
+
});
|
|
230
|
+
},
|
|
231
|
+
[sendReset, post, workerLoaded]
|
|
201
232
|
);
|
|
202
233
|
|
|
203
234
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|