reactflow-edge-routing 0.1.1 → 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.1",
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,84 +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
- const srcHandle = edge.sourceHandle ?? (autoBestSide ? null : findDefaultHandle(src, "source"));
752
- if (srcShapeRef && srcHandle) {
753
- const pinId = pinRegistry.getOrCreate(edge.source, srcHandle);
754
- srcEnd = AvoidConnEnd.fromShapePin(srcShapeRef as any, pinId);
755
- } else if (srcShapeRef) {
756
- srcEnd = AvoidConnEnd.fromShapePin(srcShapeRef as any, pinRegistry.getOrCreate(edge.source, `__auto_best`));
757
- } else {
758
- const side = autoBestSide ? Geometry.getBestSides(srcBounds, tgtBounds).sourcePos : Geometry.getHandlePosition(src, "source");
759
- srcEnd = (() => { const pt = Geometry.getHandlePoint(srcBounds, side); return AvoidConnEnd.fromPoint(new AvoidPoint(pt.x, pt.y)); })();
760
- }
761
-
762
- const tgtHandle = edge.targetHandle ?? (autoBestSide ? null : findDefaultHandle(tgt, "target"));
763
- if (tgtShapeRef && tgtHandle) {
764
- const pinId = pinRegistry.getOrCreate(edge.target, tgtHandle);
765
- tgtEnd = AvoidConnEnd.fromShapePin(tgtShapeRef as any, pinId);
766
- } else if (tgtShapeRef) {
767
- tgtEnd = AvoidConnEnd.fromShapePin(tgtShapeRef as any, pinRegistry.getOrCreate(edge.target, `__auto_best`));
768
- } else {
769
- const side = autoBestSide ? Geometry.getBestSides(srcBounds, tgtBounds).targetPos : Geometry.getHandlePosition(tgt, "target");
770
- tgtEnd = (() => { const pt = Geometry.getHandlePoint(tgtBounds, side); return AvoidConnEnd.fromPoint(new AvoidPoint(pt.x, pt.y)); })();
771
- }
772
- } else {
773
- // Point-based with stubs: all edges from the same side converge to center of side,
774
- // 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.
775
788
  const srcSide = autoBestSide ? Geometry.getBestSides(srcBounds, tgtBounds).sourcePos : Geometry.getHandlePosition(src, "source");
776
789
  const tgtSide = autoBestSide ? Geometry.getBestSides(srcBounds, tgtBounds).targetPos : Geometry.getHandlePosition(tgt, "target");
777
790
 
778
- // Use center of the side (ignore individual handle positions)
779
- const srcHandlePt = Geometry.getHandlePoint(srcBounds, srcSide);
780
- 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);
781
794
 
782
- // Route from stub endpoints (offset from center of side)
783
795
  const srcStubPt = offsetFromSide(srcHandlePt, srcSide, stubLength);
784
796
  const tgtStubPt = offsetFromSide(tgtHandlePt, tgtSide, stubLength);
785
797
 
786
798
  srcEnd = AvoidConnEnd.fromPoint(new AvoidPoint(srcStubPt.x, srcStubPt.y));
787
799
  tgtEnd = AvoidConnEnd.fromPoint(new AvoidPoint(tgtStubPt.x, tgtStubPt.y));
788
800
 
789
- stubs.push({ edgeId: edge.id, srcHandlePt, tgtHandlePt });
801
+ stubs.push({ edgeId: edge.id, srcHandlePt, tgtHandlePt, srcStubPt, tgtStubPt, merged: !splitNearHandle });
790
802
  }
791
803
 
792
804
  const connRef = new AvoidConnRef(router as any, srcEnd, tgtEnd);
@@ -829,8 +841,8 @@ export class RoutingEngine {
829
841
  const router = createRouter(routerFlags);
830
842
  configureRouter(router, opts);
831
843
 
832
- const { shapeRefMap, shapeRefList } = createObstacles(router, nodes, nodeById, pinRegistry, opts);
833
- 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);
834
846
 
835
847
  const result: Record<string, AvoidRoute> = {};
836
848
  try { router.processTransaction(); } catch (e) { console.error("[edge-routing] processTransaction failed:", e); RoutingEngine.cleanup(router, connRefs, shapeRefList); return result; }
@@ -845,16 +857,13 @@ export class RoutingEngine {
845
857
  if (stub) {
846
858
  points.unshift(stub.srcHandlePt);
847
859
  points.push(stub.tgtHandlePt);
848
- }
849
- }
850
-
851
- // Adjust spacing at shared handles (fan-out effect) — skip when splitNearHandle is off
852
- const splitNearHandle = opts.shouldSplitEdgesNearHandle ?? true;
853
- if (splitNearHandle) {
854
- const idealNudging = opts.idealNudgingDistance ?? 10;
855
- const handleNudging = opts.handleNudgingDistance ?? idealNudging;
856
- if (handleNudging !== idealNudging && edgePoints.size > 0) {
857
- 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
+ }
858
867
  }
859
868
  }
860
869
 
@@ -992,7 +1001,7 @@ export class PersistentRouter {
992
1001
  const result = createObstacles(this.router, this.prevNodes, this.nodeById, this.pinRegistry, opts);
993
1002
  this.shapeRefMap = result.shapeRefMap;
994
1003
  this.shapeRefList = result.shapeRefList;
995
- 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);
996
1005
  this.connRefList = conn.connRefs;
997
1006
  this.stubList = conn.stubs;
998
1007
 
@@ -1004,8 +1013,6 @@ export class PersistentRouter {
1004
1013
 
1005
1014
  private readRoutes(): Record<string, AvoidRoute> {
1006
1015
  const opts = this.prevOptions;
1007
- const idealNudging = opts.idealNudgingDistance ?? 10;
1008
- const handleNudging = opts.handleNudgingDistance ?? idealNudging;
1009
1016
  const gridSize = opts.diagramGridSize ?? 0;
1010
1017
  const result: Record<string, AvoidRoute> = {};
1011
1018
 
@@ -1019,15 +1026,16 @@ export class PersistentRouter {
1019
1026
  if (stub) {
1020
1027
  points.unshift(stub.srcHandlePt);
1021
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
+ }
1022
1036
  }
1023
1037
  }
1024
1038
 
1025
- // Adjust spacing at shared handles (fan-out effect) — skip when splitNearHandle is off
1026
- const splitNearHandle = opts.shouldSplitEdgesNearHandle ?? true;
1027
- if (splitNearHandle && handleNudging !== idealNudging && edgePoints.size > 0) {
1028
- HandleSpacing.adjust(this.prevEdges, edgePoints, handleNudging, idealNudging);
1029
- }
1030
-
1031
1039
  const connType = opts.connectorType ?? "orthogonal";
1032
1040
  for (const [edgeId, points] of edgePoints) {
1033
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,9 +127,11 @@ 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,
133
- autoBestSideConnection: opts?.autoBestSideConnection ?? DEFAULT_OPTIONS.autoBestSideConnection,
130
+ stubSize: opts?.stubSize ?? opts?.edgeToEdgeSpacing ?? DEFAULT_OPTIONS.edgeToEdgeSpacing,
131
+ shouldSplitEdgesNearHandle: opts?.shouldSplitEdgesNearHandle ?? true,
132
+ // bezier defaults to autoBestSideConnection: true explicit handles
133
+ // make no visual sense on curved paths, so auto-side is the right default.
134
+ autoBestSideConnection: opts?.autoBestSideConnection ?? (opts?.connectorType === "bezier" ? true : DEFAULT_OPTIONS.autoBestSideConnection),
134
135
  debounceMs: opts?.debounceMs ?? DEFAULT_OPTIONS.debounceMs,
135
136
  };
136
137
  }