11/**
2- * Post-process an ELK-laid-out preview graph so edges do not end (or
3- * start) with a tiny orthogonal stub right next to a node. A short
4- * terminal segment reads as visual noise glued to the arrowhead.
2+ * Post-process an ELK-laid-out preview graph so neither end of an edge
3+ * ends with a too-short orthogonal stub. The same length threshold
4+ * applies to both ends — an edge is just as visually broken when its
5+ * tail segment (the one the arrowhead sits on) is 2px as when its
6+ * head segment (the one leaving the source) is 2px.
57 *
6- * The smoother preserves two invariants that the earlier merge-based
7- * version broke:
8+ * The smoother operates in three modes depending on the polyline shape:
89 *
9- * 1. Orthogonality — every segment remains axis-aligned.
10- * 2. Axis at the endpoint — the final segment keeps the same axis it
11- * had before smoothing, so the arrow lands perpendicular to the
12- * destination node's border (horizontal segment → hits a vertical
13- * side, vertical segment → hits a horizontal side).
10+ * 1. **V-H-V / H-V-H (len 4)** — the overwhelmingly common orthogonal
11+ * routing shape with one middle bend. The two outer segments share
12+ * the same axis; their length sum equals the source-to-target
13+ * displacement along that axis. When either outer segment is
14+ * shorter than the threshold, the middle bend is slid along the
15+ * shared axis to redistribute length:
16+ * * If the total displacement allows both ends to meet the
17+ * threshold while keeping the healthier end intact, only the
18+ * short end is elongated.
19+ * * Otherwise the two ends are balanced symmetrically so
20+ * neither is dominant. Both ends then match, which is the
21+ * closest approximation of the "same standard on both sides"
22+ * rule when geometry makes the threshold unreachable.
1423 *
15- * The fix elongates a short terminal segment by shifting the two bend
16- * points adjacent to it in the direction parallel to the segment,
17- * keeping the penultimate segment perpendicular. If that shift would
18- * turn a formerly-healthy segment on the OTHER side into a new stub,
19- * the fix is skipped — that was the ping-pong bug where end-stub
20- * elongation created a start stub and vice versa. In such a case the
21- * tiny stub is left as-is; preserving perpendicularity matters more
22- * than hiding a two-pixel segment.
24+ * 2. **Longer polylines** — a tail or head shift is applied as a
25+ * single-pass fix. The ping-pong between tail and head fixes is
26+ * avoided by picking the tail if it was short and only falling
27+ * back to the head otherwise.
28+ *
29+ * 3. **Short polylines (< 3 points)** — left alone; there is no bend
30+ * to adjust.
31+ *
32+ * Orthogonality is preserved in every mode: no segment ever flips
33+ * axis, so arrows continue to land perpendicular to the destination
34+ * node's border after smoothing.
2335 */
2436import type { PreviewElkEdge , PreviewElkNode } from '../types' ;
2537
@@ -30,26 +42,88 @@ interface Point {
3042 y : number ;
3143}
3244
33- function manhattan ( a : Point , b : Point ) : number {
34- return Math . abs ( a . x - b . x ) + Math . abs ( a . y - b . y ) ;
45+ type Axis = 'x' | 'y' ;
46+
47+ function axisOf ( a : Point , b : Point ) : Axis | null {
48+ const dx = Math . abs ( a . x - b . x ) ;
49+ const dy = Math . abs ( a . y - b . y ) ;
50+ if ( dx < 0.5 && dy < 0.5 ) return null ;
51+ return dx > dy ? 'x' : 'y' ;
52+ }
53+
54+ /**
55+ * Rebalance a 4-point V-H-V / H-V-H polyline so the two outer
56+ * segments both meet the stub threshold when possible, and split the
57+ * total displacement evenly when the threshold cannot be reached.
58+ *
59+ * Returns ``true`` when the polyline is a V-H-V shape this function
60+ * knows how to handle — whether or not a geometric change was made.
61+ * Returning ``true`` signals to the caller "I own this case; do not
62+ * run the tail / head fallback shifts, which would undo the balance."
63+ * Returns ``false`` only when the polyline is not a V-H-V / H-V-H
64+ * (e.g. longer routing, degenerate geometry).
65+ */
66+ function balanceVHV ( points : Point [ ] , threshold : number ) : boolean {
67+ if ( points . length !== 4 ) return false ;
68+ const [ p0 , p1 , p2 , p3 ] = points ;
69+ const outerAxis = axisOf ( p0 , p1 ) ;
70+ const innerAxis = axisOf ( p1 , p2 ) ;
71+ const tailAxis = axisOf ( p2 , p3 ) ;
72+ if ( ! outerAxis || ! innerAxis || ! tailAxis ) return false ;
73+ if ( outerAxis !== tailAxis ) return false ;
74+ if ( outerAxis === innerAxis ) return false ;
75+ const axis : Axis = outerAxis ;
76+
77+ // The two outer segments lie on ``axis``; their lengths sum to the
78+ // total displacement along that axis from source to target.
79+ const startCoord = p0 [ axis ] ;
80+ const endCoord = p3 [ axis ] ;
81+ const total = Math . abs ( endCoord - startCoord ) ;
82+ const direction = endCoord > startCoord ? 1 : - 1 ;
83+ const headLen = Math . abs ( p1 [ axis ] - p0 [ axis ] ) ;
84+ const tailLen = Math . abs ( p3 [ axis ] - p2 [ axis ] ) ;
85+
86+ // Both ends already healthy — V-H-V owns the decision and leaves
87+ // the polyline untouched. Returning true here prevents the
88+ // fallback shift from running and destabilising the balance.
89+ if ( headLen >= threshold && tailLen >= threshold ) return true ;
90+
91+ let targetHead : number ;
92+ if ( total >= 2 * threshold ) {
93+ // Plenty of room — keep whichever end is already healthy and
94+ // only bump the short one up to the threshold. When both are
95+ // short in this branch (shouldn't happen but guard anyway),
96+ // split evenly.
97+ if ( headLen < threshold && tailLen < threshold ) {
98+ targetHead = total / 2 ;
99+ } else if ( tailLen < threshold ) {
100+ const delta = threshold - tailLen ;
101+ targetHead = headLen - delta ;
102+ } else {
103+ // headLen < threshold
104+ targetHead = threshold ;
105+ }
106+ } else {
107+ // Can't satisfy the threshold on both ends — balance evenly.
108+ targetHead = total / 2 ;
109+ }
110+
111+ const newBendCoord = startCoord + direction * targetHead ;
112+ if ( Math . abs ( newBendCoord - p1 [ axis ] ) >= 0.5 ) {
113+ p1 [ axis ] = newBendCoord ;
114+ p2 [ axis ] = newBendCoord ;
115+ }
116+ return true ;
35117}
36118
37119interface ShiftPlan {
38- axis : 'x' | 'y' ;
39- /** +1 / -1 — direction along the segment axis that elongates the stub. */
120+ axis : Axis ;
40121 sign : number ;
41- /** How many pixels to shift. */
42122 delta : number ;
43- /** Indices of the two points that need to move. */
44123 a : number ;
45124 b : number ;
46125}
47126
48- /**
49- * Build a shift plan for the tail of a polyline. Returns ``null`` when
50- * the tail is not a valid short stub (either already long enough, or
51- * the penultimate geometry is not perpendicular to the final segment).
52- */
53127function planTailShift ( points : Point [ ] , threshold : number ) : ShiftPlan | null {
54128 if ( points . length < 3 ) return null ;
55129 const last = points [ points . length - 1 ] ;
@@ -61,7 +135,6 @@ function planTailShift(points: Point[], threshold: number): ShiftPlan | null {
61135 if ( stub <= 0 || stub >= threshold ) return null ;
62136 const horizontal = Math . abs ( dx ) >= Math . abs ( dy ) ;
63137 if ( horizontal ) {
64- // Penultimate segment must be vertical (same x on both ends).
65138 if ( Math . abs ( pprev . x - prev . x ) > 0.5 ) return null ;
66139 const sign = dx > 0 ? - 1 : 1 ;
67140 return { axis : 'x' , sign, delta : threshold - stub , a : points . length - 2 , b : points . length - 3 } ;
@@ -92,35 +165,8 @@ function planHeadShift(points: Point[], threshold: number): ShiftPlan | null {
92165}
93166
94167function applyShift ( points : Point [ ] , plan : ShiftPlan ) : void {
95- const { axis, sign, delta, a, b} = plan ;
96- points [ a ] [ axis ] += sign * delta ;
97- points [ b ] [ axis ] += sign * delta ;
98- }
99-
100- /**
101- * Would applying this plan on the polyline create a fresh short stub at
102- * the OPPOSITE end? A plan qualifies as safe only when it leaves both
103- * ends of the polyline above the threshold.
104- */
105- function createsOppositeStub (
106- points : Point [ ] ,
107- plan : ShiftPlan ,
108- threshold : number ,
109- end : 'tail' | 'head'
110- ) : boolean {
111- // Clone just enough to check. Applying the shift mutates; test on a
112- // shallow clone of the two points that change.
113- const cloned = points . map ( p => ( { x : p . x , y : p . y } ) ) ;
114- applyShift ( cloned , plan ) ;
115- if ( end === 'tail' ) {
116- // Check the head segment of the clone.
117- if ( cloned . length < 2 ) return false ;
118- const headLen = manhattan ( cloned [ 0 ] , cloned [ 1 ] ) ;
119- return headLen > 0 && headLen < threshold ;
120- }
121- if ( cloned . length < 2 ) return false ;
122- const tailLen = manhattan ( cloned [ cloned . length - 2 ] , cloned [ cloned . length - 1 ] ) ;
123- return tailLen > 0 && tailLen < threshold ;
168+ points [ plan . a ] [ plan . axis ] += plan . sign * plan . delta ;
169+ points [ plan . b ] [ plan . axis ] += plan . sign * plan . delta ;
124170}
125171
126172function smoothEdge ( edge : PreviewElkEdge , threshold : number ) : void {
@@ -132,20 +178,15 @@ function smoothEdge(edge: PreviewElkEdge, threshold: number): void {
132178 ] ;
133179 if ( points . length < 3 ) continue ;
134180
135- // The tail end carries the arrowhead and is therefore the most
136- // visually important place to elongate a short stub. If the
137- // tail is short, fix it unconditionally — even if the fix
138- // creates a tiny start stub, that one is rarely visible
139- // (circle pseudo-ports hide it, and rect ports put it inside
140- // the node outline). Only fall back to the head fix when the
141- // tail was already fine, to avoid the ping-pong that would
142- // otherwise shuttle the stub back and forth.
143- const tailPlan = planTailShift ( points , threshold ) ;
144- if ( tailPlan ) {
145- applyShift ( points , tailPlan ) ;
146- } else {
147- const headPlan = planHeadShift ( points , threshold ) ;
148- if ( headPlan ) applyShift ( points , headPlan ) ;
181+ const balanced = balanceVHV ( points , threshold ) ;
182+ if ( ! balanced ) {
183+ const tailPlan = planTailShift ( points , threshold ) ;
184+ if ( tailPlan ) {
185+ applyShift ( points , tailPlan ) ;
186+ } else {
187+ const headPlan = planHeadShift ( points , threshold ) ;
188+ if ( headPlan ) applyShift ( points , headPlan ) ;
189+ }
149190 }
150191
151192 section . startPoint = { x : points [ 0 ] . x , y : points [ 0 ] . y } ;
0 commit comments