reactflow-edge-routing 0.1.7 → 0.1.9
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/index.ts +1 -1
- package/src/routing-core.ts +46 -28
- package/src/use-routed-edge-path.ts +29 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "reactflow-edge-routing",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
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/index.ts
CHANGED
|
@@ -34,7 +34,7 @@ export { useEdgeRouting } from "./use-edge-routing";
|
|
|
34
34
|
export type { UseEdgeRoutingOptions, UseEdgeRoutingResult } from "./use-edge-routing";
|
|
35
35
|
|
|
36
36
|
export { useRoutedEdgePath } from "./use-routed-edge-path";
|
|
37
|
-
export type { UseRoutedEdgePathParams } from "./use-routed-edge-path";
|
|
37
|
+
export type { UseRoutedEdgePathParams, RoutePinPoints } from "./use-routed-edge-path";
|
|
38
38
|
|
|
39
39
|
// Collision resolution
|
|
40
40
|
export { resolveCollisions } from "./resolve-collisions";
|
package/src/routing-core.ts
CHANGED
|
@@ -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";
|
|
@@ -522,53 +524,62 @@ export class PathBuilder {
|
|
|
522
524
|
}
|
|
523
525
|
|
|
524
526
|
/**
|
|
525
|
-
* Convert routed waypoints to a smooth
|
|
527
|
+
* Convert routed waypoints to a smooth cubic Bezier spline using
|
|
528
|
+
* Catmull-Rom → Bezier conversion with adaptive tension.
|
|
526
529
|
*
|
|
527
|
-
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
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.
|
|
531
535
|
*/
|
|
532
536
|
static routedBezierPath(
|
|
533
537
|
points: { x: number; y: number }[],
|
|
534
|
-
options: { gridSize?: number } = {}
|
|
538
|
+
options: { gridSize?: number; baseTension?: number } = {}
|
|
535
539
|
): string {
|
|
536
540
|
if (points.length < 2) return "";
|
|
537
541
|
const gridSize = options.gridSize ?? 0;
|
|
542
|
+
const baseTension = options.baseTension ?? 0.2;
|
|
538
543
|
const snap = (p: { x: number; y: number }) =>
|
|
539
544
|
gridSize > 0 ? Geometry.snapToGrid(p.x, p.y, gridSize) : p;
|
|
540
545
|
|
|
541
546
|
const raw = points.map(snap);
|
|
542
547
|
|
|
543
|
-
// Deduplicate
|
|
548
|
+
// Deduplicate near-identical consecutive points
|
|
544
549
|
const deduped: { x: number; y: number }[] = [raw[0]];
|
|
545
550
|
for (let i = 1; i < raw.length; i++) {
|
|
546
551
|
const prev = deduped[deduped.length - 1];
|
|
547
|
-
if (Math.abs(raw[i].x - prev.x) >
|
|
552
|
+
if (Math.abs(raw[i].x - prev.x) > 1 || Math.abs(raw[i].y - prev.y) > 1) {
|
|
548
553
|
deduped.push(raw[i]);
|
|
549
554
|
}
|
|
550
555
|
}
|
|
551
556
|
|
|
552
557
|
// Remove collinear midpoints — keep only corners + endpoints
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
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]);
|
|
561
572
|
}
|
|
562
|
-
pts.push(deduped[deduped.length - 1]);
|
|
563
573
|
|
|
564
|
-
if (pts.length
|
|
574
|
+
if (pts.length === 0) return "";
|
|
575
|
+
if (pts.length === 1) return `M ${pts[0].x} ${pts[0].y}`;
|
|
565
576
|
|
|
566
|
-
// 2 points: simple bezier with offset control points
|
|
577
|
+
// 2 points: simple bezier with offset control points scaled to distance
|
|
567
578
|
if (pts.length === 2) {
|
|
568
579
|
const [s, t] = pts;
|
|
569
580
|
const dx = t.x - s.x;
|
|
570
581
|
const dy = t.y - s.y;
|
|
571
|
-
const offset = Math.max(
|
|
582
|
+
const offset = Math.max(30, Math.max(Math.abs(dx), Math.abs(dy)) * 0.4);
|
|
572
583
|
if (Math.abs(dx) >= Math.abs(dy)) {
|
|
573
584
|
const sign = dx >= 0 ? 1 : -1;
|
|
574
585
|
return `M ${s.x} ${s.y} C ${s.x + offset * sign} ${s.y}, ${t.x - offset * sign} ${t.y}, ${t.x} ${t.y}`;
|
|
@@ -577,10 +588,6 @@ export class PathBuilder {
|
|
|
577
588
|
return `M ${s.x} ${s.y} C ${s.x} ${s.y + offset * sign}, ${t.x} ${t.y - offset * sign}, ${t.x} ${t.y}`;
|
|
578
589
|
}
|
|
579
590
|
|
|
580
|
-
// 3+ points: smooth cubic bezier spline through all corner points.
|
|
581
|
-
// For each segment i→i+1, compute tangent-based control points using
|
|
582
|
-
// neighbors (Catmull-Rom style, tension 0.3).
|
|
583
|
-
const tension = 0.3;
|
|
584
591
|
const dist = (a: { x: number; y: number }, b: { x: number; y: number }) =>
|
|
585
592
|
Math.hypot(b.x - a.x, b.y - a.y);
|
|
586
593
|
|
|
@@ -594,10 +601,21 @@ export class PathBuilder {
|
|
|
594
601
|
|
|
595
602
|
const segLen = dist(p1, p2);
|
|
596
603
|
|
|
597
|
-
//
|
|
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
|
|
598
617
|
let cp1x = p1.x + (p2.x - p0.x) * tension;
|
|
599
618
|
let cp1y = p1.y + (p2.y - p0.y) * tension;
|
|
600
|
-
// Tangent at p2: direction from p1 to p3
|
|
601
619
|
let cp2x = p2.x - (p3.x - p1.x) * tension;
|
|
602
620
|
let cp2y = p2.y - (p3.y - p1.y) * tension;
|
|
603
621
|
|
|
@@ -1023,7 +1041,7 @@ export class RoutingEngine {
|
|
|
1023
1041
|
const labelP = gridSize > 0 ? Geometry.snapToGrid(midP.x, midP.y, gridSize) : midP;
|
|
1024
1042
|
const first = points[0];
|
|
1025
1043
|
const last = points[points.length - 1];
|
|
1026
|
-
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 };
|
|
1027
1045
|
}
|
|
1028
1046
|
|
|
1029
1047
|
RoutingEngine.cleanup(router, connRefs, shapeRefList);
|
|
@@ -1229,7 +1247,7 @@ export class PersistentRouter {
|
|
|
1229
1247
|
const labelP = gridSize > 0 ? Geometry.snapToGrid(midP.x, midP.y, gridSize) : midP;
|
|
1230
1248
|
const first = points[0];
|
|
1231
1249
|
const last = points[points.length - 1];
|
|
1232
|
-
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 };
|
|
1233
1251
|
}
|
|
1234
1252
|
return result;
|
|
1235
1253
|
}
|
|
@@ -56,16 +56,29 @@ function getFallback(
|
|
|
56
56
|
return getStraightPath({ sourceX, sourceY, targetX, targetY });
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
export type RoutePinPoints = {
|
|
60
|
+
sourceX: number; sourceY: number;
|
|
61
|
+
targetX: number; targetY: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
59
64
|
/**
|
|
60
|
-
* Returns [path, labelX, labelY, wasRouted] for a routed edge.
|
|
65
|
+
* Returns [path, labelX, labelY, wasRouted, controlPoints, pinPoints] for a routed edge.
|
|
61
66
|
*
|
|
62
67
|
* - While a connected node is being dragged → dashed fallback
|
|
63
68
|
* - If a routed path exists for this edge → solid routed path
|
|
64
69
|
* - No route yet (worker hasn't responded) → dashed fallback
|
|
70
|
+
*
|
|
71
|
+
* `controlPoints` are the intermediate waypoints of the routed path (excluding
|
|
72
|
+
* the source and target anchor points). Use them to build an editable edge on
|
|
73
|
+
* top of auto-routing — e.g. render draggable handles at each waypoint.
|
|
74
|
+
* When no route is available (fallback), `controlPoints` is an empty array.
|
|
75
|
+
*
|
|
76
|
+
* `pinPoints` are the exact source/target positions the router snapped to.
|
|
77
|
+
* Falls back to the passed-in sourceX/Y, targetX/Y when no route is available.
|
|
65
78
|
*/
|
|
66
79
|
export function useRoutedEdgePath(
|
|
67
80
|
params: UseRoutedEdgePathParams
|
|
68
|
-
): [path: string, labelX: number, labelY: number, wasRouted: boolean] {
|
|
81
|
+
): [path: string, labelX: number, labelY: number, wasRouted: boolean, controlPoints: { x: number; y: number }[], pinPoints: RoutePinPoints] {
|
|
69
82
|
const {
|
|
70
83
|
id, sourceX, sourceY, targetX, targetY,
|
|
71
84
|
sourcePosition, targetPosition,
|
|
@@ -76,6 +89,8 @@ export function useRoutedEdgePath(
|
|
|
76
89
|
const draggingNodeIds = useEdgeRoutingStore((s) => s.draggingNodeIds);
|
|
77
90
|
|
|
78
91
|
return useMemo(() => {
|
|
92
|
+
const fallbackPins: RoutePinPoints = { sourceX, sourceY, targetX, targetY };
|
|
93
|
+
|
|
79
94
|
// If a connected node is being dragged, show fallback
|
|
80
95
|
const isDragging =
|
|
81
96
|
draggingNodeIds.size > 0 &&
|
|
@@ -84,16 +99,25 @@ export function useRoutedEdgePath(
|
|
|
84
99
|
|
|
85
100
|
if (isDragging) {
|
|
86
101
|
const [path, lx, ly] = getFallback(sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, connectorType, params.borderRadius, offset);
|
|
87
|
-
return [path, lx, ly, false];
|
|
102
|
+
return [path, lx, ly, false, [], fallbackPins];
|
|
88
103
|
}
|
|
89
104
|
|
|
90
105
|
// If we have a routed path, use it (no stale check — trust the router)
|
|
91
106
|
if (route?.path) {
|
|
92
|
-
|
|
107
|
+
// Strip the first (source anchor) and last (target anchor) points — the
|
|
108
|
+
// middle points are the editable control points for the connector.
|
|
109
|
+
const controlPoints = route.points && route.points.length > 2
|
|
110
|
+
? route.points.slice(1, -1)
|
|
111
|
+
: [];
|
|
112
|
+
const pins: RoutePinPoints = {
|
|
113
|
+
sourceX: route.sourceX, sourceY: route.sourceY,
|
|
114
|
+
targetX: route.targetX, targetY: route.targetY,
|
|
115
|
+
};
|
|
116
|
+
return [route.path, route.labelX, route.labelY, true, controlPoints, pins];
|
|
93
117
|
}
|
|
94
118
|
|
|
95
119
|
// No route yet — show fallback
|
|
96
120
|
const [path, lx, ly] = getFallback(sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, connectorType, params.borderRadius, offset);
|
|
97
|
-
return [path, lx, ly, false];
|
|
121
|
+
return [path, lx, ly, false, [], fallbackPins];
|
|
98
122
|
}, [route, sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, offset, connectorType, params.borderRadius, source, target, draggingNodeIds]);
|
|
99
123
|
}
|