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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reactflow-edge-routing",
3
- "version": "0.1.4",
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",
@@ -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)
@@ -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);