reactflow-edge-routing 0.1.2 → 0.1.4

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.4",
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 */
@@ -199,6 +199,59 @@ function getRouterFlags(connectorType: ConnectorType | undefined): number {
199
199
  }
200
200
  }
201
201
 
202
+ // ---- Label Positioning Helpers ----
203
+
204
+ /** Walk path points and return the point at fraction t (0–1) of total arc length. */
205
+ function pointAtFraction(points: { x: number; y: number }[], t: number): { x: number; y: number } {
206
+ if (points.length === 1) return points[0];
207
+ let total = 0;
208
+ for (let i = 1; i < points.length; i++) {
209
+ const dx = points[i].x - points[i - 1].x;
210
+ const dy = points[i].y - points[i - 1].y;
211
+ total += Math.sqrt(dx * dx + dy * dy);
212
+ }
213
+ const target = total * Math.max(0, Math.min(1, t));
214
+ let walked = 0;
215
+ for (let i = 1; i < points.length; i++) {
216
+ const dx = points[i].x - points[i - 1].x;
217
+ const dy = points[i].y - points[i - 1].y;
218
+ const segLen = Math.sqrt(dx * dx + dy * dy);
219
+ if (walked + segLen >= target) {
220
+ const frac = segLen > 0 ? (target - walked) / segLen : 0;
221
+ return { x: points[i - 1].x + dx * frac, y: points[i - 1].y + dy * frac };
222
+ }
223
+ walked += segLen;
224
+ }
225
+ return points[points.length - 1];
226
+ }
227
+
228
+ /**
229
+ * For each edge that has routed points, compute the fraction t (0–1) along
230
+ * the path where its label should sit. Edges sharing the same source handle
231
+ * are staggered so their labels don't overlap.
232
+ */
233
+ function buildLabelFractions(
234
+ edges: FlowEdge[],
235
+ edgePoints: Map<string, { x: number; y: number }[]>
236
+ ): Map<string, number> {
237
+ const groups = new Map<string, string[]>();
238
+ for (const edge of edges) {
239
+ if (!edgePoints.has(edge.id)) continue;
240
+ const key = `${edge.source}|${edge.sourceHandle ?? ""}`;
241
+ if (!groups.has(key)) groups.set(key, []);
242
+ groups.get(key)!.push(edge.id);
243
+ }
244
+ const fractions = new Map<string, number>();
245
+ for (const group of groups.values()) {
246
+ const n = group.length;
247
+ const step = Math.min(0.12, 0.4 / Math.max(1, n - 1));
248
+ for (let i = 0; i < n; i++) {
249
+ fractions.set(group[i], 0.5 + (i - (n - 1) / 2) * step);
250
+ }
251
+ }
252
+ return fractions;
253
+ }
254
+
202
255
  // ---- Geometry ----
203
256
 
204
257
  export class Geometry {
@@ -613,10 +666,8 @@ function configureRouter(router: AvoidRouter, options: AvoidRouterOptions): void
613
666
  }
614
667
 
615
668
  // --- 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);
669
+ router.setRoutingOption(nudgeOrthogonalSegmentsConnectedToShapesOpt, options.nudgeOrthogonalSegmentsConnectedToShapes ?? true);
670
+ router.setRoutingOption(nudgeSharedPathsWithCommonEndPointOpt, options.nudgeSharedPathsWithCommonEndPoint ?? true);
620
671
  router.setRoutingOption(performUnifyingNudgingPreprocessingStepOpt, options.performUnifyingNudgingPreprocessingStep ?? true);
621
672
  router.setRoutingOption(nudgeOrthogonalTouchingColinearSegmentsOpt, options.nudgeOrthogonalTouchingColinearSegments ?? false);
622
673
  router.setRoutingOption(improveHyperedgeRoutesMovingJunctionsOpt, options.improveHyperedgeRoutesMovingJunctions ?? true);
@@ -686,17 +737,6 @@ function createObstacles(
686
737
  }
687
738
 
688
739
 
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
740
 
701
741
 
702
742
  /** Offset a point away from the node border by stubLength in the direction of the side */
@@ -709,87 +749,109 @@ function offsetFromSide(pt: { x: number; y: number }, side: HandlePosition, stub
709
749
  }
710
750
  }
711
751
 
712
- /** Info needed to add stubs after routing when splitNearHandle is off */
752
+ /** Shift a point laterally along a node border (perpendicular to the exit direction) */
753
+ function applyLateralOffset(pt: { x: number; y: number }, side: HandlePosition, offset: number): { x: number; y: number } {
754
+ switch (side) {
755
+ case "left":
756
+ case "right": return { x: pt.x, y: pt.y + offset };
757
+ case "top":
758
+ case "bottom": return { x: pt.x + offset, y: pt.y };
759
+ }
760
+ }
761
+
762
+
763
+ /** Info needed to add stubs after routing */
713
764
  type StubInfo = {
714
765
  edgeId: string;
715
766
  srcHandlePt: { x: number; y: number };
716
767
  tgtHandlePt: { x: number; y: number };
768
+ srcStubPt: { x: number; y: number };
769
+ tgtStubPt: { x: number; y: number };
770
+ merged: boolean; // true = shared stub mode (splitNearHandle=false)
717
771
  };
718
772
 
719
773
  function createConnections(
720
774
  router: AvoidRouter,
721
775
  edges: FlowEdge[],
722
776
  nodeById: Map<string, FlowNode>,
723
- shapeRefMap: Map<string, AvoidShapeRef>,
724
- pinRegistry: PinRegistry,
725
777
  options: AvoidRouterOptions
726
778
  ): { connRefs: { edgeId: string; connRef: AvoidConnRef }[]; stubs: StubInfo[] } {
727
779
  const connRefs: { edgeId: string; connRef: AvoidConnRef }[] = [];
728
780
  const stubs: StubInfo[] = [];
729
781
  const autoBestSide = options.autoBestSideConnection ?? true;
730
- const splitNearHandle = options.shouldSplitEdgesNearHandle ?? true;
731
782
  const stubLength = options.stubSize ?? 20;
783
+ const handleSpacing = options.handleNudgingDistance ?? 0;
732
784
  const connType = getConnType(options.connectorType);
733
785
  const hateCrossings = options.hateCrossings ?? false;
734
786
 
787
+ // Pre-pass: group edges by node+side and compute lateral fan-out offsets so each edge
788
+ // gets its own stub spread by handleSpacing pixels along the node border.
789
+ const stubLateralOffsets = new Map<string, { srcOffset: number; tgtOffset: number }>();
790
+ const splitNearHandle = options.shouldSplitEdgesNearHandle ?? true;
791
+ if (splitNearHandle && handleSpacing > 0) {
792
+ const srcGroups = new Map<string, string[]>();
793
+ const tgtGroups = new Map<string, string[]>();
794
+ for (const edge of edges) {
795
+ const src = nodeById.get(edge.source);
796
+ const tgt = nodeById.get(edge.target);
797
+ if (!src || !tgt) continue;
798
+ const sb = Geometry.getNodeBoundsAbsolute(src, nodeById);
799
+ const tb = Geometry.getNodeBoundsAbsolute(tgt, nodeById);
800
+ const srcSide = autoBestSide ? Geometry.getBestSides(sb, tb).sourcePos : Geometry.getHandlePosition(src, "source");
801
+ const tgtSide = autoBestSide ? Geometry.getBestSides(sb, tb).targetPos : Geometry.getHandlePosition(tgt, "target");
802
+ const sk = `${edge.source}:${srcSide}`;
803
+ const tk = `${edge.target}:${tgtSide}`;
804
+ if (!srcGroups.has(sk)) srcGroups.set(sk, []);
805
+ srcGroups.get(sk)!.push(edge.id);
806
+ if (!tgtGroups.has(tk)) tgtGroups.set(tk, []);
807
+ tgtGroups.get(tk)!.push(edge.id);
808
+ }
809
+ for (const [, ids] of srcGroups) {
810
+ const n = ids.length;
811
+ ids.forEach((id, i) => {
812
+ const prev = stubLateralOffsets.get(id) ?? { srcOffset: 0, tgtOffset: 0 };
813
+ prev.srcOffset = (i - (n - 1) / 2) * handleSpacing;
814
+ stubLateralOffsets.set(id, prev);
815
+ });
816
+ }
817
+ for (const [, ids] of tgtGroups) {
818
+ const n = ids.length;
819
+ ids.forEach((id, i) => {
820
+ const prev = stubLateralOffsets.get(id) ?? { srcOffset: 0, tgtOffset: 0 };
821
+ prev.tgtOffset = (i - (n - 1) / 2) * handleSpacing;
822
+ stubLateralOffsets.set(id, prev);
823
+ });
824
+ }
825
+ }
826
+
735
827
  for (const edge of edges) {
736
828
  const src = nodeById.get(edge.source);
737
829
  const tgt = nodeById.get(edge.target);
738
830
  if (!src || !tgt) continue;
739
831
 
740
- const srcShapeRef = shapeRefMap.get(edge.source);
741
- const tgtShapeRef = shapeRefMap.get(edge.target);
742
-
743
832
  const srcBounds = Geometry.getNodeBoundsAbsolute(src, nodeById);
744
833
  const tgtBounds = Geometry.getNodeBoundsAbsolute(tgt, nodeById);
745
834
 
746
835
  let srcEnd: AvoidConnEnd;
747
836
  let tgtEnd: AvoidConnEnd;
748
837
 
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
838
+ {
839
+ // Each edge fans out laterally from its side center by handleSpacing,
840
+ // then exits outward via a stub of stubLength before libavoid routes the middle.
778
841
  const srcSide = autoBestSide ? Geometry.getBestSides(srcBounds, tgtBounds).sourcePos : Geometry.getHandlePosition(src, "source");
779
842
  const tgtSide = autoBestSide ? Geometry.getBestSides(srcBounds, tgtBounds).targetPos : Geometry.getHandlePosition(tgt, "target");
780
843
 
781
- // Use center of the side (ignore individual handle positions)
782
- const srcHandlePt = Geometry.getHandlePoint(srcBounds, srcSide);
783
- const tgtHandlePt = Geometry.getHandlePoint(tgtBounds, tgtSide);
844
+ const lateral = stubLateralOffsets.get(edge.id) ?? { srcOffset: 0, tgtOffset: 0 };
845
+ const srcHandlePt = applyLateralOffset(Geometry.getHandlePoint(srcBounds, srcSide), srcSide, lateral.srcOffset);
846
+ const tgtHandlePt = applyLateralOffset(Geometry.getHandlePoint(tgtBounds, tgtSide), tgtSide, lateral.tgtOffset);
784
847
 
785
- // Route from stub endpoints (offset from center of side)
786
848
  const srcStubPt = offsetFromSide(srcHandlePt, srcSide, stubLength);
787
849
  const tgtStubPt = offsetFromSide(tgtHandlePt, tgtSide, stubLength);
788
850
 
789
851
  srcEnd = AvoidConnEnd.fromPoint(new AvoidPoint(srcStubPt.x, srcStubPt.y));
790
852
  tgtEnd = AvoidConnEnd.fromPoint(new AvoidPoint(tgtStubPt.x, tgtStubPt.y));
791
853
 
792
- stubs.push({ edgeId: edge.id, srcHandlePt, tgtHandlePt });
854
+ stubs.push({ edgeId: edge.id, srcHandlePt, tgtHandlePt, srcStubPt, tgtStubPt, merged: !splitNearHandle });
793
855
  }
794
856
 
795
857
  const connRef = new AvoidConnRef(router as any, srcEnd, tgtEnd);
@@ -832,8 +894,8 @@ export class RoutingEngine {
832
894
  const router = createRouter(routerFlags);
833
895
  configureRouter(router, opts);
834
896
 
835
- const { shapeRefMap, shapeRefList } = createObstacles(router, nodes, nodeById, pinRegistry, opts);
836
- const { connRefs, stubs } = createConnections(router, edges, nodeById, shapeRefMap, pinRegistry, opts);
897
+ const { shapeRefList } = createObstacles(router, nodes, nodeById, pinRegistry, opts);
898
+ const { connRefs, stubs } = createConnections(router, edges, nodeById, opts);
837
899
 
838
900
  const result: Record<string, AvoidRoute> = {};
839
901
  try { router.processTransaction(); } catch (e) { console.error("[edge-routing] processTransaction failed:", e); RoutingEngine.cleanup(router, connRefs, shapeRefList); return result; }
@@ -848,25 +910,18 @@ export class RoutingEngine {
848
910
  if (stub) {
849
911
  points.unshift(stub.srcHandlePt);
850
912
  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);
913
+ // In merged mode (splitNearHandle=false), force the adjacent stub waypoints
914
+ // back to the exact stub endpoints so libavoid's nudging doesn't spread the
915
+ // entry/exit segments — all edges share one visible trunk line to the stub point.
916
+ if (stub.merged && points.length >= 3) {
917
+ points[1] = { ...stub.srcStubPt };
918
+ points[points.length - 2] = { ...stub.tgtStubPt };
919
+ }
866
920
  }
867
921
  }
868
922
 
869
923
  const connType = opts.connectorType ?? "orthogonal";
924
+ const labelFractions = buildLabelFractions(edges, edgePoints);
870
925
  for (const [edgeId, points] of edgePoints) {
871
926
  const edgeRounding = opts.edgeRounding ?? 0;
872
927
  const path = connType === "bezier"
@@ -874,8 +929,8 @@ export class RoutingEngine {
874
929
  : edgeRounding > 0
875
930
  ? PathBuilder.polylineToPath(points.length, (i) => points[i], { cornerRadius: edgeRounding })
876
931
  : PathBuilder.pointsToSvgPath(points);
877
- const mid = Math.floor(points.length / 2);
878
- const midP = points[mid];
932
+ const t = labelFractions.get(edgeId) ?? 0.5;
933
+ const midP = pointAtFraction(points, t);
879
934
  const labelP = gridSize > 0 ? Geometry.snapToGrid(midP.x, midP.y, gridSize) : midP;
880
935
  const first = points[0];
881
936
  const last = points[points.length - 1];
@@ -1000,7 +1055,7 @@ export class PersistentRouter {
1000
1055
  const result = createObstacles(this.router, this.prevNodes, this.nodeById, this.pinRegistry, opts);
1001
1056
  this.shapeRefMap = result.shapeRefMap;
1002
1057
  this.shapeRefList = result.shapeRefList;
1003
- const conn = createConnections(this.router, this.prevEdges, this.nodeById, this.shapeRefMap, this.pinRegistry, opts);
1058
+ const conn = createConnections(this.router, this.prevEdges, this.nodeById, opts);
1004
1059
  this.connRefList = conn.connRefs;
1005
1060
  this.stubList = conn.stubs;
1006
1061
 
@@ -1012,8 +1067,6 @@ export class PersistentRouter {
1012
1067
 
1013
1068
  private readRoutes(): Record<string, AvoidRoute> {
1014
1069
  const opts = this.prevOptions;
1015
- const idealNudging = opts.idealNudgingDistance ?? 10;
1016
- const handleNudging = opts.handleNudgingDistance ?? idealNudging;
1017
1070
  const gridSize = opts.diagramGridSize ?? 0;
1018
1071
  const result: Record<string, AvoidRoute> = {};
1019
1072
 
@@ -1027,16 +1080,18 @@ export class PersistentRouter {
1027
1080
  if (stub) {
1028
1081
  points.unshift(stub.srcHandlePt);
1029
1082
  points.push(stub.tgtHandlePt);
1083
+ // In merged mode (splitNearHandle=false), force the adjacent stub waypoints
1084
+ // back to the exact stub endpoints so libavoid's nudging doesn't spread the
1085
+ // entry/exit segments — all edges share one visible trunk line to the stub point.
1086
+ if (stub.merged && points.length >= 3) {
1087
+ points[1] = { ...stub.srcStubPt };
1088
+ points[points.length - 2] = { ...stub.tgtStubPt };
1089
+ }
1030
1090
  }
1031
1091
  }
1032
1092
 
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
1093
  const connType = opts.connectorType ?? "orthogonal";
1094
+ const labelFractions = buildLabelFractions(this.prevEdges, edgePoints);
1040
1095
  for (const [edgeId, points] of edgePoints) {
1041
1096
  const edgeRounding = opts.edgeRounding ?? 0;
1042
1097
  const path = connType === "bezier"
@@ -1044,10 +1099,8 @@ export class PersistentRouter {
1044
1099
  : edgeRounding > 0
1045
1100
  ? PathBuilder.polylineToPath(points.length, (i) => points[i], { cornerRadius: edgeRounding })
1046
1101
  : PathBuilder.pointsToSvgPath(points);
1047
-
1048
-
1049
- const mid = Math.floor(points.length / 2);
1050
- const midP = points[mid];
1102
+ const t = labelFractions.get(edgeId) ?? 0.5;
1103
+ const midP = pointAtFraction(points, t);
1051
1104
  const labelP = gridSize > 0 ? Geometry.snapToGrid(midP.x, midP.y, gridSize) : midP;
1052
1105
  const first = points[0];
1053
1106
  const last = points[points.length - 1];
@@ -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),