reactflow-edge-routing 0.1.4 → 0.1.6
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 +141 -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.6",
|
|
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,71 @@ 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,
|
|
291
|
+
* including the source/target nodes themselves (e.g. edge going backward through its own node). */
|
|
292
|
+
function isEdgeDirectPathBlocked(
|
|
293
|
+
srcPt: { x: number; y: number },
|
|
294
|
+
tgtPt: { x: number; y: number },
|
|
295
|
+
srcNodeId: string,
|
|
296
|
+
tgtNodeId: string,
|
|
297
|
+
nodes: FlowNode[],
|
|
298
|
+
nodeById: Map<string, FlowNode>,
|
|
299
|
+
buffer: number
|
|
300
|
+
): boolean {
|
|
301
|
+
const dx = tgtPt.x - srcPt.x, dy = tgtPt.y - srcPt.y;
|
|
302
|
+
const len = Math.sqrt(dx * dx + dy * dy);
|
|
303
|
+
for (const node of nodes) {
|
|
304
|
+
const bounds = Geometry.getNodeBoundsAbsolute(node, nodeById);
|
|
305
|
+
if (node.id === srcNodeId || node.id === tgtNodeId) {
|
|
306
|
+
// The handle point sits on the node border, so a plain intersection check would
|
|
307
|
+
// always trigger. Nudge 1 px along the segment away from the handle and check
|
|
308
|
+
// from there — avoids the border false-positive while still catching lines that
|
|
309
|
+
// go backward through the node body.
|
|
310
|
+
if (len < 1e-6) continue;
|
|
311
|
+
const isSrc = node.id === srcNodeId;
|
|
312
|
+
const nudged = isSrc
|
|
313
|
+
? { x: srcPt.x + dx / len, y: srcPt.y + dy / len }
|
|
314
|
+
: { x: tgtPt.x - dx / len, y: tgtPt.y - dy / len };
|
|
315
|
+
const otherEnd = isSrc ? tgtPt : srcPt;
|
|
316
|
+
if (segmentIntersectsRect(nudged, otherEnd, bounds, buffer)) return true;
|
|
317
|
+
} else {
|
|
318
|
+
if (segmentIntersectsRect(srcPt, tgtPt, bounds, buffer)) return true;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return false;
|
|
322
|
+
}
|
|
323
|
+
|
|
255
324
|
// ---- Geometry ----
|
|
256
325
|
|
|
257
326
|
export class Geometry {
|
|
@@ -920,9 +989,45 @@ export class RoutingEngine {
|
|
|
920
989
|
}
|
|
921
990
|
}
|
|
922
991
|
|
|
992
|
+
// For unblocked edges, generate the React Flow fallback path directly.
|
|
923
993
|
const connType = opts.connectorType ?? "orthogonal";
|
|
994
|
+
const borderRadius = opts.edgeRounding ?? 0;
|
|
995
|
+
const directRoutes = new Map<string, { path: string; labelX: number; labelY: number; srcPt: { x: number; y: number }; tgtPt: { x: number; y: number } }>();
|
|
996
|
+
if (opts.routeOnlyWhenBlocked !== false) {
|
|
997
|
+
const edgeById = new Map(edges.map((e) => [e.id, e]));
|
|
998
|
+
const buffer = opts.shapeBufferDistance ?? 8;
|
|
999
|
+
for (const [edgeId, points] of edgePoints) {
|
|
1000
|
+
const edge = edgeById.get(edgeId);
|
|
1001
|
+
if (!edge) continue;
|
|
1002
|
+
const src = points[0], tgt = points[points.length - 1];
|
|
1003
|
+
if (!isEdgeDirectPathBlocked(src, tgt, edge.source, edge.target, nodes, nodeById, buffer)) {
|
|
1004
|
+
const stub = stubMap.get(edgeId);
|
|
1005
|
+
if (stub) {
|
|
1006
|
+
const srcPos = sideFromStub(stub.srcHandlePt, stub.srcStubPt);
|
|
1007
|
+
const tgtPos = sideFromStub(stub.tgtHandlePt, stub.tgtStubPt);
|
|
1008
|
+
const { x: sx, y: sy } = stub.srcHandlePt;
|
|
1009
|
+
const { x: tx, y: ty } = stub.tgtHandlePt;
|
|
1010
|
+
let path: string; let labelX: number; let labelY: number;
|
|
1011
|
+
if (connType === "bezier") {
|
|
1012
|
+
[path, labelX, labelY] = getBezierPath({ sourceX: sx, sourceY: sy, sourcePosition: srcPos, targetX: tx, targetY: ty, targetPosition: tgtPos });
|
|
1013
|
+
} else if (connType === "polyline") {
|
|
1014
|
+
[path, labelX, labelY] = getStraightPath({ sourceX: sx, sourceY: sy, targetX: tx, targetY: ty });
|
|
1015
|
+
} else {
|
|
1016
|
+
[path, labelX, labelY] = getSmoothStepPath({ sourceX: sx, sourceY: sy, sourcePosition: srcPos, targetX: tx, targetY: ty, targetPosition: tgtPos, borderRadius });
|
|
1017
|
+
}
|
|
1018
|
+
directRoutes.set(edgeId, { path, labelX, labelY, srcPt: stub.srcHandlePt, tgtPt: stub.tgtHandlePt });
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
924
1024
|
const labelFractions = buildLabelFractions(edges, edgePoints);
|
|
925
1025
|
for (const [edgeId, points] of edgePoints) {
|
|
1026
|
+
const direct = directRoutes.get(edgeId);
|
|
1027
|
+
if (direct) {
|
|
1028
|
+
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 };
|
|
1029
|
+
continue;
|
|
1030
|
+
}
|
|
926
1031
|
const edgeRounding = opts.edgeRounding ?? 0;
|
|
927
1032
|
const path = connType === "bezier"
|
|
928
1033
|
? PathBuilder.routedBezierPath(points)
|
|
@@ -1090,9 +1195,45 @@ export class PersistentRouter {
|
|
|
1090
1195
|
}
|
|
1091
1196
|
}
|
|
1092
1197
|
|
|
1198
|
+
// For unblocked edges, generate the React Flow fallback path directly.
|
|
1093
1199
|
const connType = opts.connectorType ?? "orthogonal";
|
|
1200
|
+
const borderRadius = opts.edgeRounding ?? 0;
|
|
1201
|
+
const directRoutes = new Map<string, { path: string; labelX: number; labelY: number; srcPt: { x: number; y: number }; tgtPt: { x: number; y: number } }>();
|
|
1202
|
+
if (opts.routeOnlyWhenBlocked !== false) {
|
|
1203
|
+
const edgeById = new Map(this.prevEdges.map((e) => [e.id, e]));
|
|
1204
|
+
const buffer = opts.shapeBufferDistance ?? 8;
|
|
1205
|
+
for (const [edgeId, points] of edgePoints) {
|
|
1206
|
+
const edge = edgeById.get(edgeId);
|
|
1207
|
+
if (!edge) continue;
|
|
1208
|
+
const src = points[0], tgt = points[points.length - 1];
|
|
1209
|
+
if (!isEdgeDirectPathBlocked(src, tgt, edge.source, edge.target, this.prevNodes, this.nodeById, buffer)) {
|
|
1210
|
+
const stub = stubMap.get(edgeId);
|
|
1211
|
+
if (stub) {
|
|
1212
|
+
const srcPos = sideFromStub(stub.srcHandlePt, stub.srcStubPt);
|
|
1213
|
+
const tgtPos = sideFromStub(stub.tgtHandlePt, stub.tgtStubPt);
|
|
1214
|
+
const { x: sx, y: sy } = stub.srcHandlePt;
|
|
1215
|
+
const { x: tx, y: ty } = stub.tgtHandlePt;
|
|
1216
|
+
let path: string; let labelX: number; let labelY: number;
|
|
1217
|
+
if (connType === "bezier") {
|
|
1218
|
+
[path, labelX, labelY] = getBezierPath({ sourceX: sx, sourceY: sy, sourcePosition: srcPos, targetX: tx, targetY: ty, targetPosition: tgtPos });
|
|
1219
|
+
} else if (connType === "polyline") {
|
|
1220
|
+
[path, labelX, labelY] = getStraightPath({ sourceX: sx, sourceY: sy, targetX: tx, targetY: ty });
|
|
1221
|
+
} else {
|
|
1222
|
+
[path, labelX, labelY] = getSmoothStepPath({ sourceX: sx, sourceY: sy, sourcePosition: srcPos, targetX: tx, targetY: ty, targetPosition: tgtPos, borderRadius });
|
|
1223
|
+
}
|
|
1224
|
+
directRoutes.set(edgeId, { path, labelX, labelY, srcPt: stub.srcHandlePt, tgtPt: stub.tgtHandlePt });
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1094
1230
|
const labelFractions = buildLabelFractions(this.prevEdges, edgePoints);
|
|
1095
1231
|
for (const [edgeId, points] of edgePoints) {
|
|
1232
|
+
const direct = directRoutes.get(edgeId);
|
|
1233
|
+
if (direct) {
|
|
1234
|
+
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 };
|
|
1235
|
+
continue;
|
|
1236
|
+
}
|
|
1096
1237
|
const edgeRounding = opts.edgeRounding ?? 0;
|
|
1097
1238
|
const path = connType === "bezier"
|
|
1098
1239
|
? 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);
|