reactflow-edge-routing 0.1.3 → 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.3",
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
  };
@@ -199,6 +203,108 @@ function getRouterFlags(connectorType: ConnectorType | undefined): number {
199
203
  }
200
204
  }
201
205
 
206
+ // ---- Label Positioning Helpers ----
207
+
208
+ /** Walk path points and return the point at fraction t (0–1) of total arc length. */
209
+ function pointAtFraction(points: { x: number; y: number }[], t: number): { x: number; y: number } {
210
+ if (points.length === 1) return points[0];
211
+ let total = 0;
212
+ for (let i = 1; i < points.length; i++) {
213
+ const dx = points[i].x - points[i - 1].x;
214
+ const dy = points[i].y - points[i - 1].y;
215
+ total += Math.sqrt(dx * dx + dy * dy);
216
+ }
217
+ const target = total * Math.max(0, Math.min(1, t));
218
+ let walked = 0;
219
+ for (let i = 1; i < points.length; i++) {
220
+ const dx = points[i].x - points[i - 1].x;
221
+ const dy = points[i].y - points[i - 1].y;
222
+ const segLen = Math.sqrt(dx * dx + dy * dy);
223
+ if (walked + segLen >= target) {
224
+ const frac = segLen > 0 ? (target - walked) / segLen : 0;
225
+ return { x: points[i - 1].x + dx * frac, y: points[i - 1].y + dy * frac };
226
+ }
227
+ walked += segLen;
228
+ }
229
+ return points[points.length - 1];
230
+ }
231
+
232
+ /**
233
+ * For each edge that has routed points, compute the fraction t (0–1) along
234
+ * the path where its label should sit. Edges sharing the same source handle
235
+ * are staggered so their labels don't overlap.
236
+ */
237
+ function buildLabelFractions(
238
+ edges: FlowEdge[],
239
+ edgePoints: Map<string, { x: number; y: number }[]>
240
+ ): Map<string, number> {
241
+ const groups = new Map<string, string[]>();
242
+ for (const edge of edges) {
243
+ if (!edgePoints.has(edge.id)) continue;
244
+ const key = `${edge.source}|${edge.sourceHandle ?? ""}`;
245
+ if (!groups.has(key)) groups.set(key, []);
246
+ groups.get(key)!.push(edge.id);
247
+ }
248
+ const fractions = new Map<string, number>();
249
+ for (const group of groups.values()) {
250
+ const n = group.length;
251
+ const step = Math.min(0.12, 0.4 / Math.max(1, n - 1));
252
+ for (let i = 0; i < n; i++) {
253
+ fractions.set(group[i], 0.5 + (i - (n - 1) / 2) * step);
254
+ }
255
+ }
256
+ return fractions;
257
+ }
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
+
202
308
  // ---- Geometry ----
203
309
 
204
310
  export class Geometry {
@@ -867,16 +973,53 @@ export class RoutingEngine {
867
973
  }
868
974
  }
869
975
 
976
+ // For unblocked edges, generate the React Flow fallback path directly.
870
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
+
1008
+ const labelFractions = buildLabelFractions(edges, edgePoints);
871
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
+ }
872
1015
  const edgeRounding = opts.edgeRounding ?? 0;
873
1016
  const path = connType === "bezier"
874
1017
  ? PathBuilder.routedBezierPath(points)
875
1018
  : edgeRounding > 0
876
1019
  ? PathBuilder.polylineToPath(points.length, (i) => points[i], { cornerRadius: edgeRounding })
877
1020
  : PathBuilder.pointsToSvgPath(points);
878
- const mid = Math.floor(points.length / 2);
879
- const midP = points[mid];
1021
+ const t = labelFractions.get(edgeId) ?? 0.5;
1022
+ const midP = pointAtFraction(points, t);
880
1023
  const labelP = gridSize > 0 ? Geometry.snapToGrid(midP.x, midP.y, gridSize) : midP;
881
1024
  const first = points[0];
882
1025
  const last = points[points.length - 1];
@@ -1036,18 +1179,53 @@ export class PersistentRouter {
1036
1179
  }
1037
1180
  }
1038
1181
 
1182
+ // For unblocked edges, generate the React Flow fallback path directly.
1039
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
+
1214
+ const labelFractions = buildLabelFractions(this.prevEdges, edgePoints);
1040
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
+ }
1041
1221
  const edgeRounding = opts.edgeRounding ?? 0;
1042
1222
  const path = connType === "bezier"
1043
1223
  ? PathBuilder.routedBezierPath(points)
1044
1224
  : edgeRounding > 0
1045
1225
  ? PathBuilder.polylineToPath(points.length, (i) => points[i], { cornerRadius: edgeRounding })
1046
1226
  : PathBuilder.pointsToSvgPath(points);
1047
-
1048
-
1049
- const mid = Math.floor(points.length / 2);
1050
- const midP = points[mid];
1227
+ const t = labelFractions.get(edgeId) ?? 0.5;
1228
+ const midP = pointAtFraction(points, t);
1051
1229
  const labelP = gridSize > 0 ? Geometry.snapToGrid(midP.x, midP.y, gridSize) : midP;
1052
1230
  const first = points[0];
1053
1231
  const last = points[points.length - 1];
@@ -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);