Skip to content

Commit c139ca7

Browse files
committed
fix(vscode): smoother treats both transition ends with the same stub threshold
The previous smoother preferred tail-end shifts over head-end ones and would happily take an already-balanced polyline (e.g., the 10-75-10 route ELK chose for ``[*] -> Active`` in the playground) and unbalance it to 2-75-18, reintroducing a visible stub on the source side. The user explicitly asked for the same threshold on both ends, so a short stub at the start of a transition is just as unwanted as one at the arrow. Replace the directional tail / head shifts for the common four-point V-H-V / H-V-H routing with a proper balance pass: - When the total displacement along the outer axis is large enough to fit two threshold-length segments, only the short end is bumped up while the healthy end stays as-is. - When the total displacement is shorter than ``2 * threshold``, the two outer segments are split evenly so neither dominates. For the ``[*] -> Active`` case this yields 10/10 instead of the earlier 2/18 — both ends are equally short (geometry does not allow the full threshold on both sides), but no end is tiny and every arrow still lands perpendicular to its destination. - The balance function now owns every V-H-V case and returns true even when no change is needed, so the tail / head shift fallback no longer fires for these polylines and cannot re-introduce an asymmetry. Longer routings (5+ points) still use the tail-first fallback. Visual verification against the playground confirms: [*] -> Active — 10-75-10 (balanced V-H-V) Active -> [*] exit pseudo — 18+ on both ends Running child transitions — unchanged, already balanced everything else — unchanged
1 parent f99aede commit c139ca7

1 file changed

Lines changed: 114 additions & 73 deletions

File tree

editors/vscode/src/preview-webview/render/edge-smoother.ts

Lines changed: 114 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,37 @@
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
*/
2436
import 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

37119
interface 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-
*/
53127
function 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

94167
function 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

126172
function 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

Comments
 (0)