Skip to content

Commit 1f5b2ae

Browse files
HansBugclaude
andcommitted
fix(vscode): smoother keeps arrow axis perpendicular; dial spacing back
Two adjustments on top of the earlier edge-polish commit, driven by feedback on the Running composite in ``test_formatter_playground``. - Arrow perpendicularity. The merge-based smoother collapsed a short final stub by deleting its bend point and sliding the preceding one, which FLIPPED the final segment's axis — a 2px vertical stub into a node's top edge became a long horizontal segment running parallel to that edge. The arrow landed parallel instead of perpendicular. Rewrite the smoother to elongate a stub by shifting the two bend points parallel to the stub, keeping the segment's axis intact. An anti-cycle guard skips the shift when it would trade a stub at one end for a new stub at the other end — the pathological case where both ends are already short; in that case the polyline is left as ELK produced it so perpendicularity is never sacrificed. - Edge spacing dialed back. The earlier bump was too aggressive on small diagrams. Settle on values that are still noticeably roomier than the original: elk.spacing.edgeNode 42 -> 48 (was 56) elk.spacing.edgeEdge 30 -> 36 (was 44) elk.layered.spacing.baseValue 48 -> 50 (was 52) elk.layered.spacing.edgeNodeBetweenLayers 46 -> 52 (was 58) elk.layered.spacing.edgeEdgeBetweenLayers (new) 32 (was 40) elk.layered.spacing.nodeNodeBetweenLayers 110 -> 116 (was 120) mergeEdges / mergeHierarchyEdges / unnecessaryBendpoints stay on. The combined effect on the playground's ``[*] -> Active`` edge: ELK now picks a balanced 10-75-10 orthogonal route instead of the original 18-75-2, and the smoother leaves it alone because an anti- cycle shift would cost the other end. The arrow lands vertically on Active's top edge, well inside the flat region between the rounded corners. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 623575c commit 1f5b2ae

2 files changed

Lines changed: 113 additions & 55 deletions

File tree

editors/jsfcstm/src/diagram/elk-graph.ts

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -367,18 +367,20 @@ export function buildFcstmElkGraph(
367367
// Wider breathing room than the first draft — users explicitly
368368
// asked for a less cramped layout.
369369
'elk.spacing.nodeNode': '80',
370-
'elk.layered.spacing.nodeNodeBetweenLayers': '120',
371-
// Push parallel edges further apart so two transitions that
372-
// share a layer do not run glued together. Same story for
373-
// the edge-vs-node gap — ELK otherwise happily parks a line
374-
// one or two pixels off the node outline.
375-
'elk.spacing.edgeNode': '56',
376-
'elk.spacing.edgeEdge': '44',
370+
'elk.layered.spacing.nodeNodeBetweenLayers': '116',
371+
// Push parallel edges apart so two transitions that share
372+
// a layer do not run glued together, and keep edges a
373+
// readable distance from node outlines. The values are a
374+
// compromise between the original tight defaults and the
375+
// earlier wider bump — larger than before, but not so
376+
// aggressive that small diagrams waste whitespace.
377+
'elk.spacing.edgeNode': '48',
378+
'elk.spacing.edgeEdge': '36',
377379
'elk.spacing.edgeLabel': '24',
378380
'elk.spacing.componentComponent': '64',
379-
'elk.layered.spacing.baseValue': '52',
380-
'elk.layered.spacing.edgeNodeBetweenLayers': '58',
381-
'elk.layered.spacing.edgeEdgeBetweenLayers': '40',
381+
'elk.layered.spacing.baseValue': '50',
382+
'elk.layered.spacing.edgeNodeBetweenLayers': '52',
383+
'elk.layered.spacing.edgeEdgeBetweenLayers': '32',
382384
'elk.edgeLabels.placement': 'CENTER',
383385
'elk.layered.nodePlacement.favorStraightEdges': 'true',
384386
// Merging hierarchy edges lets ELK collapse many parallel

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

Lines changed: 101 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,25 @@
11
/**
22
* 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. When ELK
4-
* routes an edge through several bends and the final segment lands
5-
* within a few pixels of the target port, the visual result is a
6-
* perpendicular "L" glued to the arrowhead that reads as rendering
7-
* noise rather than intentional routing.
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.
85
*
9-
* The repair absorbs a short stub into the neighboring bend instead of
10-
* shifting both ends — a plain shift can trade one stub for another
11-
* when a polyline has a short final AND a short initial segment. The
12-
* pass iterates until the polyline stabilizes, so chains of stubs are
13-
* collapsed in one go.
6+
* The smoother preserves two invariants that the earlier merge-based
7+
* version broke:
8+
*
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).
14+
*
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.
1423
*/
1524
import type {PreviewElkEdge, PreviewElkNode} from '../types';
1625

@@ -21,54 +30,100 @@ interface Point {
2130
y: number;
2231
}
2332

24-
function segmentLenLInf(a: Point, b: Point): number {
33+
function manhattan(a: Point, b: Point): number {
2534
return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
2635
}
2736

37+
interface ShiftPlan {
38+
axis: 'x' | 'y';
39+
/** +1 / -1 — direction along the segment axis that elongates the stub. */
40+
sign: number;
41+
/** How many pixels to shift. */
42+
delta: number;
43+
/** Indices of the two points that need to move. */
44+
a: number;
45+
b: number;
46+
}
47+
2848
/**
29-
* Try to collapse the trailing stub of a polyline by removing the
30-
* useless bend point adjacent to the endpoint. Returns ``true`` if a
31-
* change was made.
32-
*
33-
* Geometrically: when ``p_{n-1}`` is only ``short`` away from ``p_n`` on
34-
* axis F, and ``p_{n-2}`` → ``p_{n-1}`` runs perpendicular to that (axis
35-
* P), we can drop ``p_{n-1}`` and slide ``p_{n-2}`` onto ``p_n``'s F
36-
* coordinate. The previous segment keeps its axis; the new terminal
37-
* segment runs along P and inherits the length of the old penultimate
38-
* segment, so no new short stub is introduced as long as the original
39-
* penultimate segment was long enough.
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).
4052
*/
41-
function collapseEndStub(points: Point[], threshold: number): boolean {
42-
if (points.length < 3) return false;
53+
function planTailShift(points: Point[], threshold: number): ShiftPlan | null {
54+
if (points.length < 3) return null;
4355
const last = points[points.length - 1];
4456
const prev = points[points.length - 2];
4557
const pprev = points[points.length - 3];
4658
const dx = last.x - prev.x;
4759
const dy = last.y - prev.y;
4860
const stub = Math.abs(dx) + Math.abs(dy);
49-
if (stub <= 0 || stub >= threshold) return false;
50-
const axis: 'x' | 'y' = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y';
51-
pprev[axis] = last[axis];
52-
points.splice(points.length - 2, 1);
53-
return true;
61+
if (stub <= 0 || stub >= threshold) return null;
62+
const horizontal = Math.abs(dx) >= Math.abs(dy);
63+
if (horizontal) {
64+
// Penultimate segment must be vertical (same x on both ends).
65+
if (Math.abs(pprev.x - prev.x) > 0.5) return null;
66+
const sign = dx > 0 ? -1 : 1;
67+
return {axis: 'x', sign, delta: threshold - stub, a: points.length - 2, b: points.length - 3};
68+
}
69+
if (Math.abs(pprev.y - prev.y) > 0.5) return null;
70+
const sign = dy > 0 ? -1 : 1;
71+
return {axis: 'y', sign, delta: threshold - stub, a: points.length - 2, b: points.length - 3};
5472
}
5573

56-
function collapseStartStub(points: Point[], threshold: number): boolean {
57-
if (points.length < 3) return false;
74+
function planHeadShift(points: Point[], threshold: number): ShiftPlan | null {
75+
if (points.length < 3) return null;
5876
const first = points[0];
5977
const next = points[1];
6078
const nnext = points[2];
6179
const dx = next.x - first.x;
6280
const dy = next.y - first.y;
6381
const stub = Math.abs(dx) + Math.abs(dy);
64-
if (stub <= 0 || stub >= threshold) return false;
65-
const axis: 'x' | 'y' = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y';
66-
nnext[axis] = first[axis];
67-
points.splice(1, 1);
68-
return true;
82+
if (stub <= 0 || stub >= threshold) return null;
83+
const horizontal = Math.abs(dx) >= Math.abs(dy);
84+
if (horizontal) {
85+
if (Math.abs(nnext.x - next.x) > 0.5) return null;
86+
const sign = dx > 0 ? 1 : -1;
87+
return {axis: 'x', sign, delta: threshold - stub, a: 1, b: 2};
88+
}
89+
if (Math.abs(nnext.y - next.y) > 0.5) return null;
90+
const sign = dy > 0 ? 1 : -1;
91+
return {axis: 'y', sign, delta: threshold - stub, a: 1, b: 2};
6992
}
7093

71-
function smoothEdge(edge: PreviewElkEdge, minStubLen: number): void {
94+
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;
124+
}
125+
126+
function smoothEdge(edge: PreviewElkEdge, threshold: number): void {
72127
for (const section of edge.sections || []) {
73128
const points: Point[] = [
74129
{x: section.startPoint.x, y: section.startPoint.y},
@@ -77,14 +132,15 @@ function smoothEdge(edge: PreviewElkEdge, minStubLen: number): void {
77132
];
78133
if (points.length < 3) continue;
79134

80-
// Iterate until the polyline is stable. Both collapses shrink
81-
// the array so the loop is bounded by the original length.
82-
let changed = true;
83-
let safety = points.length + 2;
84-
while (changed && safety-- > 0) {
85-
changed = false;
86-
if (collapseEndStub(points, minStubLen)) changed = true;
87-
if (collapseStartStub(points, minStubLen)) changed = true;
135+
// Try tail first; it is the more visually important end
136+
// because the arrowhead lives there.
137+
const tailPlan = planTailShift(points, threshold);
138+
if (tailPlan && !createsOppositeStub(points, tailPlan, threshold, 'tail')) {
139+
applyShift(points, tailPlan);
140+
}
141+
const headPlan = planHeadShift(points, threshold);
142+
if (headPlan && !createsOppositeStub(points, headPlan, threshold, 'head')) {
143+
applyShift(points, headPlan);
88144
}
89145

90146
section.startPoint = {x: points[0].x, y: points[0].y};

0 commit comments

Comments
 (0)