reactflow-edge-routing 0.1.6 → 0.1.8

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.6",
3
+ "version": "0.1.8",
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",
@@ -60,6 +60,8 @@ export type AvoidRoute = {
60
60
  sourceY: number;
61
61
  targetX: number;
62
62
  targetY: number;
63
+ /** Raw routed waypoints (including handle stubs). Use slice(1,-1) for editable edge control points. */
64
+ points?: { x: number; y: number }[];
63
65
  };
64
66
 
65
67
  export type ConnectorType = "orthogonal" | "polyline" | "bezier";
@@ -287,8 +289,7 @@ function segmentIntersectsRect(
287
289
  return tMin <= tMax;
288
290
  }
289
291
 
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
+ /** Returns true if the direct line from srcPt to tgtPt is blocked by any node except the source/target nodes. */
292
293
  function isEdgeDirectPathBlocked(
293
294
  srcPt: { x: number; y: number },
294
295
  tgtPt: { x: number; y: number },
@@ -298,25 +299,10 @@ function isEdgeDirectPathBlocked(
298
299
  nodeById: Map<string, FlowNode>,
299
300
  buffer: number
300
301
  ): boolean {
301
- const dx = tgtPt.x - srcPt.x, dy = tgtPt.y - srcPt.y;
302
- const len = Math.sqrt(dx * dx + dy * dy);
303
302
  for (const node of nodes) {
304
303
  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
- }
304
+ if (node.id === srcNodeId || node.id === tgtNodeId) continue;
305
+ if (segmentIntersectsRect(srcPt, tgtPt, bounds, buffer)) return true;
320
306
  }
321
307
  return false;
322
308
  }
@@ -538,53 +524,62 @@ export class PathBuilder {
538
524
  }
539
525
 
540
526
  /**
541
- * Convert routed waypoints to a smooth bezier spline.
527
+ * Convert routed waypoints to a smooth cubic Bezier spline using
528
+ * Catmull-Rom → Bezier conversion with adaptive tension.
542
529
  *
543
- * Takes the orthogonal waypoints from libavoid, keeps only corners
544
- * (where direction changes), then draws a smooth cubic bezier spline
545
- * through them. The result is a flowing curve not orthogonal at all —
546
- * that still follows the obstacle-avoiding route.
530
+ * Steps:
531
+ * 1. Deduplicate and remove collinear intermediate points (keep corners only).
532
+ * 2. For each segment, compute control points from neighboring points.
533
+ * 3. Clamp control-point reach to prevent overshooting / edge crossings.
534
+ * 4. Adapt tension based on segment length — short segments get less curvature.
547
535
  */
548
536
  static routedBezierPath(
549
537
  points: { x: number; y: number }[],
550
- options: { gridSize?: number } = {}
538
+ options: { gridSize?: number; baseTension?: number } = {}
551
539
  ): string {
552
540
  if (points.length < 2) return "";
553
541
  const gridSize = options.gridSize ?? 0;
542
+ const baseTension = options.baseTension ?? 0.2;
554
543
  const snap = (p: { x: number; y: number }) =>
555
544
  gridSize > 0 ? Geometry.snapToGrid(p.x, p.y, gridSize) : p;
556
545
 
557
546
  const raw = points.map(snap);
558
547
 
559
- // Deduplicate
548
+ // Deduplicate near-identical consecutive points
560
549
  const deduped: { x: number; y: number }[] = [raw[0]];
561
550
  for (let i = 1; i < raw.length; i++) {
562
551
  const prev = deduped[deduped.length - 1];
563
- if (Math.abs(raw[i].x - prev.x) > 0.5 || Math.abs(raw[i].y - prev.y) > 0.5) {
552
+ if (Math.abs(raw[i].x - prev.x) > 1 || Math.abs(raw[i].y - prev.y) > 1) {
564
553
  deduped.push(raw[i]);
565
554
  }
566
555
  }
567
556
 
568
557
  // Remove collinear midpoints — keep only corners + endpoints
569
- const pts: { x: number; y: number }[] = [deduped[0]];
570
- for (let i = 1; i < deduped.length - 1; i++) {
571
- const prev = deduped[i - 1];
572
- const curr = deduped[i];
573
- const next = deduped[i + 1];
574
- const sameX = Math.abs(prev.x - curr.x) < 1 && Math.abs(curr.x - next.x) < 1;
575
- const sameY = Math.abs(prev.y - curr.y) < 1 && Math.abs(curr.y - next.y) < 1;
576
- if (!sameX || !sameY) pts.push(curr);
558
+ let pts: { x: number; y: number }[];
559
+ if (deduped.length <= 2) {
560
+ pts = deduped;
561
+ } else {
562
+ pts = [deduped[0]];
563
+ for (let i = 1; i < deduped.length - 1; i++) {
564
+ const prev = deduped[i - 1];
565
+ const curr = deduped[i];
566
+ const next = deduped[i + 1];
567
+ const sameX = Math.abs(prev.x - curr.x) < 1 && Math.abs(curr.x - next.x) < 1;
568
+ const sameY = Math.abs(prev.y - curr.y) < 1 && Math.abs(curr.y - next.y) < 1;
569
+ if (!sameX || !sameY) pts.push(curr);
570
+ }
571
+ pts.push(deduped[deduped.length - 1]);
577
572
  }
578
- pts.push(deduped[deduped.length - 1]);
579
573
 
580
- if (pts.length < 2) return `M ${pts[0].x} ${pts[0].y}`;
574
+ if (pts.length === 0) return "";
575
+ if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`;
581
576
 
582
- // 2 points: simple bezier with offset control points
577
+ // 2 points: simple bezier with offset control points scaled to distance
583
578
  if (pts.length === 2) {
584
579
  const [s, t] = pts;
585
580
  const dx = t.x - s.x;
586
581
  const dy = t.y - s.y;
587
- const offset = Math.max(50, Math.max(Math.abs(dx), Math.abs(dy)) * 0.5);
582
+ const offset = Math.max(30, Math.max(Math.abs(dx), Math.abs(dy)) * 0.4);
588
583
  if (Math.abs(dx) >= Math.abs(dy)) {
589
584
  const sign = dx >= 0 ? 1 : -1;
590
585
  return `M ${s.x} ${s.y} C ${s.x + offset * sign} ${s.y}, ${t.x - offset * sign} ${t.y}, ${t.x} ${t.y}`;
@@ -593,10 +588,6 @@ export class PathBuilder {
593
588
  return `M ${s.x} ${s.y} C ${s.x} ${s.y + offset * sign}, ${t.x} ${t.y - offset * sign}, ${t.x} ${t.y}`;
594
589
  }
595
590
 
596
- // 3+ points: smooth cubic bezier spline through all corner points.
597
- // For each segment i→i+1, compute tangent-based control points using
598
- // neighbors (Catmull-Rom style, tension 0.3).
599
- const tension = 0.3;
600
591
  const dist = (a: { x: number; y: number }, b: { x: number; y: number }) =>
601
592
  Math.hypot(b.x - a.x, b.y - a.y);
602
593
 
@@ -610,10 +601,21 @@ export class PathBuilder {
610
601
 
611
602
  const segLen = dist(p1, p2);
612
603
 
613
- // Tangent at p1: direction from p0 to p2
604
+ // Skip curvature for very short segments
605
+ if (segLen < 5) {
606
+ d += ` L ${p2.x} ${p2.y}`;
607
+ continue;
608
+ }
609
+
610
+ // Adaptive tension: shorter segments get less curvature to avoid overshooting
611
+ const tension =
612
+ segLen < 40 ? baseTension * 0.3 :
613
+ segLen < 80 ? baseTension * 0.6 :
614
+ baseTension;
615
+
616
+ // Catmull-Rom to cubic bezier control points
614
617
  let cp1x = p1.x + (p2.x - p0.x) * tension;
615
618
  let cp1y = p1.y + (p2.y - p0.y) * tension;
616
- // Tangent at p2: direction from p1 to p3
617
619
  let cp2x = p2.x - (p3.x - p1.x) * tension;
618
620
  let cp2y = p2.y - (p3.y - p1.y) * tension;
619
621
 
@@ -1039,7 +1041,7 @@ export class RoutingEngine {
1039
1041
  const labelP = gridSize > 0 ? Geometry.snapToGrid(midP.x, midP.y, gridSize) : midP;
1040
1042
  const first = points[0];
1041
1043
  const last = points[points.length - 1];
1042
- result[edgeId] = { path, labelX: labelP.x, labelY: labelP.y, sourceX: first.x, sourceY: first.y, targetX: last.x, targetY: last.y };
1044
+ result[edgeId] = { path, labelX: labelP.x, labelY: labelP.y, sourceX: first.x, sourceY: first.y, targetX: last.x, targetY: last.y, points };
1043
1045
  }
1044
1046
 
1045
1047
  RoutingEngine.cleanup(router, connRefs, shapeRefList);
@@ -1245,7 +1247,7 @@ export class PersistentRouter {
1245
1247
  const labelP = gridSize > 0 ? Geometry.snapToGrid(midP.x, midP.y, gridSize) : midP;
1246
1248
  const first = points[0];
1247
1249
  const last = points[points.length - 1];
1248
- result[edgeId] = { path, labelX: labelP.x, labelY: labelP.y, sourceX: first.x, sourceY: first.y, targetX: last.x, targetY: last.y };
1250
+ result[edgeId] = { path, labelX: labelP.x, labelY: labelP.y, sourceX: first.x, sourceY: first.y, targetX: last.x, targetY: last.y, points };
1249
1251
  }
1250
1252
  return result;
1251
1253
  }
@@ -57,15 +57,20 @@ function getFallback(
57
57
  }
58
58
 
59
59
  /**
60
- * Returns [path, labelX, labelY, wasRouted] for a routed edge.
60
+ * Returns [path, labelX, labelY, wasRouted, controlPoints] for a routed edge.
61
61
  *
62
62
  * - While a connected node is being dragged → dashed fallback
63
63
  * - If a routed path exists for this edge → solid routed path
64
64
  * - No route yet (worker hasn't responded) → dashed fallback
65
+ *
66
+ * `controlPoints` are the intermediate waypoints of the routed path (excluding
67
+ * the source and target anchor points). Use them to build an editable edge on
68
+ * top of auto-routing — e.g. render draggable handles at each waypoint.
69
+ * When no route is available (fallback), `controlPoints` is an empty array.
65
70
  */
66
71
  export function useRoutedEdgePath(
67
72
  params: UseRoutedEdgePathParams
68
- ): [path: string, labelX: number, labelY: number, wasRouted: boolean] {
73
+ ): [path: string, labelX: number, labelY: number, wasRouted: boolean, controlPoints: { x: number; y: number }[]] {
69
74
  const {
70
75
  id, sourceX, sourceY, targetX, targetY,
71
76
  sourcePosition, targetPosition,
@@ -84,16 +89,21 @@ export function useRoutedEdgePath(
84
89
 
85
90
  if (isDragging) {
86
91
  const [path, lx, ly] = getFallback(sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, connectorType, params.borderRadius, offset);
87
- return [path, lx, ly, false];
92
+ return [path, lx, ly, false, []];
88
93
  }
89
94
 
90
95
  // If we have a routed path, use it (no stale check — trust the router)
91
96
  if (route?.path) {
92
- return [route.path, route.labelX, route.labelY, true];
97
+ // Strip the first (source anchor) and last (target anchor) points — the
98
+ // middle points are the editable control points for the connector.
99
+ const controlPoints = route.points && route.points.length > 2
100
+ ? route.points.slice(1, -1)
101
+ : [];
102
+ return [route.path, route.labelX, route.labelY, true, controlPoints];
93
103
  }
94
104
 
95
105
  // No route yet — show fallback
96
106
  const [path, lx, ly] = getFallback(sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, connectorType, params.borderRadius, offset);
97
- return [path, lx, ly, false];
107
+ return [path, lx, ly, false, []];
98
108
  }, [route, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, offset, connectorType, params.borderRadius, source, target, draggingNodeIds]);
99
109
  }