reactflow-edge-routing 0.1.2 → 0.1.3

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.2",
3
+ "version": "0.1.3",
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",
@@ -114,10 +114,10 @@ export type AvoidRouterOptions = {
114
114
  edgeRounding?: number;
115
115
  /** Snap waypoints to grid. Default: 0 (no grid) */
116
116
  diagramGridSize?: number;
117
- /** When true, edges spread out along the node border near handles (pin-based). When false, edges converge to exact handle point. Default: true */
118
- shouldSplitEdgesNearHandle?: boolean;
119
- /** Length (px) of the stub segment when shouldSplitEdgesNearHandle is off. Default: 20 */
117
+ /** Length (px) of the stub exit segment from the node border. Default: 20 */
120
118
  stubSize?: number;
119
+ /** When true, each edge gets its own stub spread laterally by handleSpacing (fan-out at handle). When false, all edges share one stub exit point and libavoid routes them apart after. Default: true */
120
+ shouldSplitEdgesNearHandle?: boolean;
121
121
  /** Auto-select best connection side based on relative node positions. Default: true */
122
122
  autoBestSideConnection?: boolean;
123
123
  /** Debounce delay for routing updates (ms). Default: 0 */
@@ -613,10 +613,8 @@ function configureRouter(router: AvoidRouter, options: AvoidRouterOptions): void
613
613
  }
614
614
 
615
615
  // --- Routing options ---
616
- const splitNearHandle = options.shouldSplitEdgesNearHandle ?? true;
617
- // When splitNearHandle is off, disable nudging at shapes so edges converge to exact handle point
618
- router.setRoutingOption(nudgeOrthogonalSegmentsConnectedToShapesOpt, splitNearHandle ? (options.nudgeOrthogonalSegmentsConnectedToShapes ?? true) : false);
619
- router.setRoutingOption(nudgeSharedPathsWithCommonEndPointOpt, splitNearHandle ? (options.nudgeSharedPathsWithCommonEndPoint ?? true) : false);
616
+ router.setRoutingOption(nudgeOrthogonalSegmentsConnectedToShapesOpt, options.nudgeOrthogonalSegmentsConnectedToShapes ?? true);
617
+ router.setRoutingOption(nudgeSharedPathsWithCommonEndPointOpt, options.nudgeSharedPathsWithCommonEndPoint ?? true);
620
618
  router.setRoutingOption(performUnifyingNudgingPreprocessingStepOpt, options.performUnifyingNudgingPreprocessingStep ?? true);
621
619
  router.setRoutingOption(nudgeOrthogonalTouchingColinearSegmentsOpt, options.nudgeOrthogonalTouchingColinearSegments ?? false);
622
620
  router.setRoutingOption(improveHyperedgeRoutesMovingJunctionsOpt, options.improveHyperedgeRoutesMovingJunctions ?? true);
@@ -686,17 +684,6 @@ function createObstacles(
686
684
  }
687
685
 
688
686
 
689
- /**
690
- * Find the first enriched pin matching a handle type (source/target).
691
- * Used when edge.sourceHandle/targetHandle is null (default handles).
692
- */
693
- function findDefaultHandle(node: FlowNode, kind: "source" | "target"): string | null {
694
- const pins = (node._handlePins as HandlePin[] | undefined) ?? [];
695
- // Enriched pins from default handles are named __source_N or __target_N
696
- const prefix = `__${kind}_`;
697
- const pin = pins.find((p) => p.handleId.startsWith(prefix));
698
- return pin?.handleId ?? null;
699
- }
700
687
 
701
688
 
702
689
  /** Offset a point away from the node border by stubLength in the direction of the side */
@@ -709,87 +696,109 @@ function offsetFromSide(pt: { x: number; y: number }, side: HandlePosition, stub
709
696
  }
710
697
  }
711
698
 
712
- /** Info needed to add stubs after routing when splitNearHandle is off */
699
+ /** Shift a point laterally along a node border (perpendicular to the exit direction) */
700
+ function applyLateralOffset(pt: { x: number; y: number }, side: HandlePosition, offset: number): { x: number; y: number } {
701
+ switch (side) {
702
+ case "left":
703
+ case "right": return { x: pt.x, y: pt.y + offset };
704
+ case "top":
705
+ case "bottom": return { x: pt.x + offset, y: pt.y };
706
+ }
707
+ }
708
+
709
+
710
+ /** Info needed to add stubs after routing */
713
711
  type StubInfo = {
714
712
  edgeId: string;
715
713
  srcHandlePt: { x: number; y: number };
716
714
  tgtHandlePt: { x: number; y: number };
715
+ srcStubPt: { x: number; y: number };
716
+ tgtStubPt: { x: number; y: number };
717
+ merged: boolean; // true = shared stub mode (splitNearHandle=false)
717
718
  };
718
719
 
719
720
  function createConnections(
720
721
  router: AvoidRouter,
721
722
  edges: FlowEdge[],
722
723
  nodeById: Map<string, FlowNode>,
723
- shapeRefMap: Map<string, AvoidShapeRef>,
724
- pinRegistry: PinRegistry,
725
724
  options: AvoidRouterOptions
726
725
  ): { connRefs: { edgeId: string; connRef: AvoidConnRef }[]; stubs: StubInfo[] } {
727
726
  const connRefs: { edgeId: string; connRef: AvoidConnRef }[] = [];
728
727
  const stubs: StubInfo[] = [];
729
728
  const autoBestSide = options.autoBestSideConnection ?? true;
730
- const splitNearHandle = options.shouldSplitEdgesNearHandle ?? true;
731
729
  const stubLength = options.stubSize ?? 20;
730
+ const handleSpacing = options.handleNudgingDistance ?? 0;
732
731
  const connType = getConnType(options.connectorType);
733
732
  const hateCrossings = options.hateCrossings ?? false;
734
733
 
734
+ // Pre-pass: group edges by node+side and compute lateral fan-out offsets so each edge
735
+ // gets its own stub spread by handleSpacing pixels along the node border.
736
+ const stubLateralOffsets = new Map<string, { srcOffset: number; tgtOffset: number }>();
737
+ const splitNearHandle = options.shouldSplitEdgesNearHandle ?? true;
738
+ if (splitNearHandle && handleSpacing > 0) {
739
+ const srcGroups = new Map<string, string[]>();
740
+ const tgtGroups = new Map<string, string[]>();
741
+ for (const edge of edges) {
742
+ const src = nodeById.get(edge.source);
743
+ const tgt = nodeById.get(edge.target);
744
+ if (!src || !tgt) continue;
745
+ const sb = Geometry.getNodeBoundsAbsolute(src, nodeById);
746
+ const tb = Geometry.getNodeBoundsAbsolute(tgt, nodeById);
747
+ const srcSide = autoBestSide ? Geometry.getBestSides(sb, tb).sourcePos : Geometry.getHandlePosition(src, "source");
748
+ const tgtSide = autoBestSide ? Geometry.getBestSides(sb, tb).targetPos : Geometry.getHandlePosition(tgt, "target");
749
+ const sk = `${edge.source}:${srcSide}`;
750
+ const tk = `${edge.target}:${tgtSide}`;
751
+ if (!srcGroups.has(sk)) srcGroups.set(sk, []);
752
+ srcGroups.get(sk)!.push(edge.id);
753
+ if (!tgtGroups.has(tk)) tgtGroups.set(tk, []);
754
+ tgtGroups.get(tk)!.push(edge.id);
755
+ }
756
+ for (const [, ids] of srcGroups) {
757
+ const n = ids.length;
758
+ ids.forEach((id, i) => {
759
+ const prev = stubLateralOffsets.get(id) ?? { srcOffset: 0, tgtOffset: 0 };
760
+ prev.srcOffset = (i - (n - 1) / 2) * handleSpacing;
761
+ stubLateralOffsets.set(id, prev);
762
+ });
763
+ }
764
+ for (const [, ids] of tgtGroups) {
765
+ const n = ids.length;
766
+ ids.forEach((id, i) => {
767
+ const prev = stubLateralOffsets.get(id) ?? { srcOffset: 0, tgtOffset: 0 };
768
+ prev.tgtOffset = (i - (n - 1) / 2) * handleSpacing;
769
+ stubLateralOffsets.set(id, prev);
770
+ });
771
+ }
772
+ }
773
+
735
774
  for (const edge of edges) {
736
775
  const src = nodeById.get(edge.source);
737
776
  const tgt = nodeById.get(edge.target);
738
777
  if (!src || !tgt) continue;
739
778
 
740
- const srcShapeRef = shapeRefMap.get(edge.source);
741
- const tgtShapeRef = shapeRefMap.get(edge.target);
742
-
743
779
  const srcBounds = Geometry.getNodeBoundsAbsolute(src, nodeById);
744
780
  const tgtBounds = Geometry.getNodeBoundsAbsolute(tgt, nodeById);
745
781
 
746
782
  let srcEnd: AvoidConnEnd;
747
783
  let tgtEnd: AvoidConnEnd;
748
784
 
749
- if (splitNearHandle) {
750
- // Pin-based: edges spread out along the node border near handles
751
- // When autoBestSide is on, ignore explicit sourceHandle/targetHandle on edges
752
- // and let the router pick the optimal side. Only use explicit handles when
753
- // autoBestSide is off (pin-based routing with fixed handle positions).
754
- const srcHandle = autoBestSide ? null : (edge.sourceHandle ?? findDefaultHandle(src, "source"));
755
- if (srcShapeRef && srcHandle) {
756
- const pinId = pinRegistry.getOrCreate(edge.source, srcHandle);
757
- srcEnd = AvoidConnEnd.fromShapePin(srcShapeRef as any, pinId);
758
- } else if (srcShapeRef) {
759
- srcEnd = AvoidConnEnd.fromShapePin(srcShapeRef as any, pinRegistry.getOrCreate(edge.source, `__auto_best`));
760
- } else {
761
- const side = autoBestSide ? Geometry.getBestSides(srcBounds, tgtBounds).sourcePos : Geometry.getHandlePosition(src, "source");
762
- srcEnd = (() => { const pt = Geometry.getHandlePoint(srcBounds, side); return AvoidConnEnd.fromPoint(new AvoidPoint(pt.x, pt.y)); })();
763
- }
764
-
765
- const tgtHandle = autoBestSide ? null : (edge.targetHandle ?? findDefaultHandle(tgt, "target"));
766
- if (tgtShapeRef && tgtHandle) {
767
- const pinId = pinRegistry.getOrCreate(edge.target, tgtHandle);
768
- tgtEnd = AvoidConnEnd.fromShapePin(tgtShapeRef as any, pinId);
769
- } else if (tgtShapeRef) {
770
- tgtEnd = AvoidConnEnd.fromShapePin(tgtShapeRef as any, pinRegistry.getOrCreate(edge.target, `__auto_best`));
771
- } else {
772
- const side = autoBestSide ? Geometry.getBestSides(srcBounds, tgtBounds).targetPos : Geometry.getHandlePosition(tgt, "target");
773
- tgtEnd = (() => { const pt = Geometry.getHandlePoint(tgtBounds, side); return AvoidConnEnd.fromPoint(new AvoidPoint(pt.x, pt.y)); })();
774
- }
775
- } else {
776
- // Point-based with stubs: all edges from the same side converge to center of side,
777
- // then route from a stub point offset outward
785
+ {
786
+ // Each edge fans out laterally from its side center by handleSpacing,
787
+ // then exits outward via a stub of stubLength before libavoid routes the middle.
778
788
  const srcSide = autoBestSide ? Geometry.getBestSides(srcBounds, tgtBounds).sourcePos : Geometry.getHandlePosition(src, "source");
779
789
  const tgtSide = autoBestSide ? Geometry.getBestSides(srcBounds, tgtBounds).targetPos : Geometry.getHandlePosition(tgt, "target");
780
790
 
781
- // Use center of the side (ignore individual handle positions)
782
- const srcHandlePt = Geometry.getHandlePoint(srcBounds, srcSide);
783
- const tgtHandlePt = Geometry.getHandlePoint(tgtBounds, tgtSide);
791
+ const lateral = stubLateralOffsets.get(edge.id) ?? { srcOffset: 0, tgtOffset: 0 };
792
+ const srcHandlePt = applyLateralOffset(Geometry.getHandlePoint(srcBounds, srcSide), srcSide, lateral.srcOffset);
793
+ const tgtHandlePt = applyLateralOffset(Geometry.getHandlePoint(tgtBounds, tgtSide), tgtSide, lateral.tgtOffset);
784
794
 
785
- // Route from stub endpoints (offset from center of side)
786
795
  const srcStubPt = offsetFromSide(srcHandlePt, srcSide, stubLength);
787
796
  const tgtStubPt = offsetFromSide(tgtHandlePt, tgtSide, stubLength);
788
797
 
789
798
  srcEnd = AvoidConnEnd.fromPoint(new AvoidPoint(srcStubPt.x, srcStubPt.y));
790
799
  tgtEnd = AvoidConnEnd.fromPoint(new AvoidPoint(tgtStubPt.x, tgtStubPt.y));
791
800
 
792
- stubs.push({ edgeId: edge.id, srcHandlePt, tgtHandlePt });
801
+ stubs.push({ edgeId: edge.id, srcHandlePt, tgtHandlePt, srcStubPt, tgtStubPt, merged: !splitNearHandle });
793
802
  }
794
803
 
795
804
  const connRef = new AvoidConnRef(router as any, srcEnd, tgtEnd);
@@ -832,8 +841,8 @@ export class RoutingEngine {
832
841
  const router = createRouter(routerFlags);
833
842
  configureRouter(router, opts);
834
843
 
835
- const { shapeRefMap, shapeRefList } = createObstacles(router, nodes, nodeById, pinRegistry, opts);
836
- const { connRefs, stubs } = createConnections(router, edges, nodeById, shapeRefMap, pinRegistry, opts);
844
+ const { shapeRefList } = createObstacles(router, nodes, nodeById, pinRegistry, opts);
845
+ const { connRefs, stubs } = createConnections(router, edges, nodeById, opts);
837
846
 
838
847
  const result: Record<string, AvoidRoute> = {};
839
848
  try { router.processTransaction(); } catch (e) { console.error("[edge-routing] processTransaction failed:", e); RoutingEngine.cleanup(router, connRefs, shapeRefList); return result; }
@@ -848,21 +857,13 @@ export class RoutingEngine {
848
857
  if (stub) {
849
858
  points.unshift(stub.srcHandlePt);
850
859
  points.push(stub.tgtHandlePt);
851
- }
852
- }
853
-
854
- // Adjust spacing at shared handles (fan-out effect).
855
- // Only for splitNearHandle=false (convergence mode): edges all start from the same center
856
- // point, libavoid nudges them apart by idealNudgingDistance, and HandleSpacing.adjust scales
857
- // that spread by handleNudging/idealNudging → final fan-out = handleNudging (clean decoupling).
858
- // For splitNearHandle=true (pin-based), pins already fix the fan-out; scaling would incorrectly
859
- // compress or expand existing pin spread, causing edgeToEdgeSpacing to reduce visible spacing.
860
- const splitNearHandle = opts.shouldSplitEdgesNearHandle ?? true;
861
- if (!splitNearHandle) {
862
- const idealNudging = opts.idealNudgingDistance ?? 10;
863
- const handleNudging = opts.handleNudgingDistance ?? idealNudging;
864
- if (handleNudging !== idealNudging && edgePoints.size > 0) {
865
- HandleSpacing.adjust(edges, edgePoints, handleNudging, idealNudging);
860
+ // In merged mode (splitNearHandle=false), force the adjacent stub waypoints
861
+ // back to the exact stub endpoints so libavoid's nudging doesn't spread the
862
+ // entry/exit segments — all edges share one visible trunk line to the stub point.
863
+ if (stub.merged && points.length >= 3) {
864
+ points[1] = { ...stub.srcStubPt };
865
+ points[points.length - 2] = { ...stub.tgtStubPt };
866
+ }
866
867
  }
867
868
  }
868
869
 
@@ -1000,7 +1001,7 @@ export class PersistentRouter {
1000
1001
  const result = createObstacles(this.router, this.prevNodes, this.nodeById, this.pinRegistry, opts);
1001
1002
  this.shapeRefMap = result.shapeRefMap;
1002
1003
  this.shapeRefList = result.shapeRefList;
1003
- const conn = createConnections(this.router, this.prevEdges, this.nodeById, this.shapeRefMap, this.pinRegistry, opts);
1004
+ const conn = createConnections(this.router, this.prevEdges, this.nodeById, opts);
1004
1005
  this.connRefList = conn.connRefs;
1005
1006
  this.stubList = conn.stubs;
1006
1007
 
@@ -1012,8 +1013,6 @@ export class PersistentRouter {
1012
1013
 
1013
1014
  private readRoutes(): Record<string, AvoidRoute> {
1014
1015
  const opts = this.prevOptions;
1015
- const idealNudging = opts.idealNudgingDistance ?? 10;
1016
- const handleNudging = opts.handleNudgingDistance ?? idealNudging;
1017
1016
  const gridSize = opts.diagramGridSize ?? 0;
1018
1017
  const result: Record<string, AvoidRoute> = {};
1019
1018
 
@@ -1027,15 +1026,16 @@ export class PersistentRouter {
1027
1026
  if (stub) {
1028
1027
  points.unshift(stub.srcHandlePt);
1029
1028
  points.push(stub.tgtHandlePt);
1029
+ // In merged mode (splitNearHandle=false), force the adjacent stub waypoints
1030
+ // back to the exact stub endpoints so libavoid's nudging doesn't spread the
1031
+ // entry/exit segments — all edges share one visible trunk line to the stub point.
1032
+ if (stub.merged && points.length >= 3) {
1033
+ points[1] = { ...stub.srcStubPt };
1034
+ points[points.length - 2] = { ...stub.tgtStubPt };
1035
+ }
1030
1036
  }
1031
1037
  }
1032
1038
 
1033
- // Adjust spacing at shared handles (fan-out effect) — only for splitNearHandle=false (convergence mode)
1034
- const splitNearHandle = opts.shouldSplitEdgesNearHandle ?? true;
1035
- if (!splitNearHandle && handleNudging !== idealNudging && edgePoints.size > 0) {
1036
- HandleSpacing.adjust(this.prevEdges, edgePoints, handleNudging, idealNudging);
1037
- }
1038
-
1039
1039
  const connType = opts.connectorType ?? "orthogonal";
1040
1040
  for (const [edgeId, points] of edgePoints) {
1041
1041
  const edgeRounding = opts.edgeRounding ?? 0;
@@ -64,10 +64,10 @@ export interface UseEdgeRoutingOptions {
64
64
  // --- Rendering / layout ---
65
65
  edgeRounding?: number;
66
66
  diagramGridSize?: number;
67
- /** When true, edges spread out along the node border near handles. When false, edges converge to exact handle point. Default: true */
68
- shouldSplitEdgesNearHandle?: boolean;
69
- /** Length (px) of the stub segment when shouldSplitEdgesNearHandle is off. Default: 20 */
67
+ /** Length (px) of the stub exit segment from the node border. Default: 20 */
70
68
  stubSize?: number;
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
+ shouldSplitEdgesNearHandle?: boolean;
71
71
  autoBestSideConnection?: boolean;
72
72
  debounceMs?: number;
73
73
 
@@ -90,7 +90,6 @@ const DEFAULT_OPTIONS: UseEdgeRoutingOptions = {
90
90
  edgeToNodeSpacing: 8,
91
91
  handleSpacing: 2,
92
92
  diagramGridSize: 0,
93
- shouldSplitEdgesNearHandle: true,
94
93
  autoBestSideConnection: false,
95
94
  debounceMs: 0,
96
95
  };
@@ -100,7 +99,7 @@ function toRouterOptions(opts?: UseEdgeRoutingOptions): AvoidRouterOptions {
100
99
  // Core spacing
101
100
  idealNudgingDistance: opts?.edgeToEdgeSpacing ?? DEFAULT_OPTIONS.edgeToEdgeSpacing,
102
101
  shapeBufferDistance: opts?.edgeToNodeSpacing ?? DEFAULT_OPTIONS.edgeToNodeSpacing,
103
- handleNudgingDistance: opts?.handleSpacing ?? DEFAULT_OPTIONS.handleSpacing,
102
+ handleNudgingDistance: opts?.handleSpacing ?? opts?.edgeToEdgeSpacing ?? DEFAULT_OPTIONS.edgeToEdgeSpacing,
104
103
 
105
104
  // Routing parameters
106
105
  segmentPenalty: opts?.segmentPenalty,
@@ -128,8 +127,8 @@ function toRouterOptions(opts?: UseEdgeRoutingOptions): AvoidRouterOptions {
128
127
  // Rendering
129
128
  edgeRounding: opts?.edgeRounding ?? DEFAULT_OPTIONS.edgeRounding,
130
129
  diagramGridSize: opts?.diagramGridSize ?? DEFAULT_OPTIONS.diagramGridSize,
131
- shouldSplitEdgesNearHandle: opts?.shouldSplitEdgesNearHandle ?? DEFAULT_OPTIONS.shouldSplitEdgesNearHandle,
132
- stubSize: opts?.stubSize,
130
+ stubSize: opts?.stubSize ?? opts?.edgeToEdgeSpacing ?? DEFAULT_OPTIONS.edgeToEdgeSpacing,
131
+ shouldSplitEdgesNearHandle: opts?.shouldSplitEdgesNearHandle ?? true,
133
132
  // bezier defaults to autoBestSideConnection: true — explicit handles
134
133
  // make no visual sense on curved paths, so auto-side is the right default.
135
134
  autoBestSideConnection: opts?.autoBestSideConnection ?? (opts?.connectorType === "bezier" ? true : DEFAULT_OPTIONS.autoBestSideConnection),