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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reactflow-edge-routing",
3
- "version": "0.1.4",
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",
@@ -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)
@@ -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
- (_nodeIds: string[]) => { sendReset(); },
200
- [sendReset]
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);