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 +1 -1
- package/src/routing-core.ts +144 -91
- package/src/use-edge-routing.ts +6 -7
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reactflow-edge-routing",
|
|
3
|
-
"version": "0.1.
|
|
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",
|
package/src/routing-core.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
617
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
750
|
-
//
|
|
751
|
-
//
|
|
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
|
-
|
|
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 {
|
|
836
|
-
const { connRefs, stubs } = createConnections(router, edges, nodeById,
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
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
|
|
878
|
-
const midP = points
|
|
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,
|
|
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];
|
package/src/use-edge-routing.ts
CHANGED
|
@@ -64,10 +64,10 @@ export interface UseEdgeRoutingOptions {
|
|
|
64
64
|
// --- Rendering / layout ---
|
|
65
65
|
edgeRounding?: number;
|
|
66
66
|
diagramGridSize?: number;
|
|
67
|
-
/**
|
|
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.
|
|
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
|
-
|
|
132
|
-
|
|
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),
|