Skip to content

Commit 1246a55

Browse files
committed
feat(elk): draw line hops where edges cross
Where two edges cross, the one that gives way is drawn with a small arc (or a gap) so it is clear which line passes over which. On by default; `elk.lineHops: false` for plain crossings, `'gap'` for gaps. Detection and both styles already existed for swimlanes. What is new is the `afterPaint` hook that lets ELK use them, and `applyLineJumpsToSvg` being exported so a layout package outside `mermaid` can reach it. Two defects in the hop geometry are fixed here, both found on real diagrams and both of which rendered as something that looked broken rather than merely untidy: - A hop with no room next to a bend was fitted into whatever was left, as little as 2.9px against a requested 6, opening exactly on the corner's tangent point. At that radius the arc does not clear the stroke it is hopping, so the lines still touch. Hops now keep a straight run clear of the bend, and one that would still shrink below 60% of the requested radius is dropped — an ordinary crossing is a much better failure than a broken-looking hop. - A crossing found inside the stretch where either edge is rounding a bend is now ignored. Crossings are computed on polylines, but a rounded edge is not drawn as its polyline: it leaves the line up to 7.07px before each bend and rejoins it that far after. A crossing found in there is somewhere the stroke never goes, so the arc arched over blank paper while the two lines carried on touching beside it.
1 parent d4bea0d commit 1246a55

7 files changed

Lines changed: 354 additions & 6 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@mermaid-js/layout-elk': minor
3+
---
4+
5+
feat: draw line hops where ELK edges cross, controlled by `elk.lineHops`.
6+
7+
Where two edges cross, the one that gives way is drawn with a small arc (or a visible gap) so it is clear which line passes over which. On by default; set `elk.lineHops: false` to draw plain crossings, or `'gap'` to use gaps instead of arcs.
8+
9+
```yaml
10+
---
11+
config:
12+
layout: elk
13+
elk:
14+
lineHops: gap
15+
---
16+
```
17+
18+
The crossing detection and both styles already existed and were used by swimlanes — this registers the `afterPaint` hook that lets ELK use them. An edge that takes a hop loses its corner rounding on that segment, which is the trade for a readable crossing; curved edges are skipped rather than rewritten, to avoid corrupting their geometry.
19+
20+
**Existing ELK diagrams with crossing edges will render differently.**
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'mermaid': minor
3+
---
4+
5+
feat: export `applyLineJumpsToSvg` so layout packages outside this one can draw line hops.
6+
7+
Line jumps are applied after paint, once every edge has been emitted and the crossings are known. A layout that ships inside this package can reach into `rendering-util` to do that; one that ships separately, like `@mermaid-js/layout-elk`, cannot. Exporting it alongside the other common-renderer pieces lets an external layout register an `afterPaint` hook that draws hops the same way the built-in ones do.
8+
9+
`EdgeGeom` and `LineJumpConfig` are exported with it, since they are the argument types.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'mermaid': patch
3+
---
4+
5+
fix: don't draw a line hop that has no room next to a bend.
6+
7+
A crossing close to a corner used to get a hop squeezed into whatever space was left — as little as 2.9px against a requested 6px, opening exactly on the corner's tangent point. At that size the arc no longer clears the line it is meant to hop, so the two strokes still touch and the corner's curve runs straight into the arc's. It reads as a rendering fault rather than as a crossing.
8+
9+
Hops now keep a straight run clear of the bend, and one that would still have to shrink below 60% of the requested radius is dropped instead of drawn. An undrawn hop is an ordinary crossing, which is a much better failure than a broken-looking one.
10+
11+
This shows up wherever a layout stacks edges in narrow lanes: ELK routes subgraph-internal edges 10px apart, and 10px does not hold a 7.07px corner cut plus a 6px hop.
12+
13+
A crossing is also ignored now when it lands inside the stretch where either edge is rounding a bend. Crossings are found on polylines, but a rounded edge is not drawn as its polyline — it leaves the line up to 7.07px before each bend and rejoins it that far after. A crossing found inside that stretch is somewhere the stroke never goes, so the hop was arching over blank paper while the two lines carried on touching beside it.
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import {
2+
applyLineJumpsToSvg,
3+
type CommonLayoutPaintContext,
4+
type EdgeGeom,
5+
type LayoutData,
6+
} from 'mermaid';
7+
8+
/** Radius of the arc drawn where one edge hops another. */
9+
const JUMP_RADIUS = 6;
10+
11+
/**
12+
* Draw a hop where two edges cross.
13+
*
14+
* Runs as `afterPaint`, because a hop is a property of the rendered path rather
15+
* than of the layout: the crossings are only known once every edge has been
16+
* emitted, and the fix is to rewrite the `d` of the edge that gives way.
17+
*
18+
* ELK's curve is compatible either way — `applyElkEdgeLayout` sets `rounded`
19+
* for a routed edge and `linear` for its straight-line fallback, and
20+
* `curveSupportsLineHops` accepts both. An edge that takes a hop loses its
21+
* corner rounding in exchange, which is the trade the line-jump module
22+
* documents.
23+
*/
24+
export function applyElkLineJumps(
25+
data4Layout: LayoutData,
26+
{ measure }: CommonLayoutPaintContext<unknown, { groups: { edgePaths: unknown } }>
27+
): void {
28+
const lineHops = (data4Layout.config as { elk?: { lineHops?: boolean | string } })?.elk?.lineHops;
29+
if (lineHops === false) {
30+
return;
31+
}
32+
33+
const edgeGeometries: EdgeGeom[] = data4Layout.edges
34+
.filter((edge) => Array.isArray(edge.points) && edge.points.length >= 2)
35+
.map((edge) => ({
36+
id: edge.id,
37+
points: edge.points!,
38+
curve: edge.curve,
39+
arrowTypeStart: edge.arrowTypeStart,
40+
arrowTypeEnd: edge.arrowTypeEnd,
41+
})) as EdgeGeom[];
42+
43+
applyLineJumpsToSvg(
44+
(measure as { groups: { edgePaths: never } }).groups.edgePaths,
45+
edgeGeometries,
46+
{
47+
enabled: true,
48+
jumpRadius: JUMP_RADIUS,
49+
jumpStyle: lineHops === 'gap' ? 'gap' : 'arc',
50+
}
51+
);
52+
}

‎packages/mermaid/src/mermaid.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ export type {
5454
CommonLayoutRenderContext,
5555
CommonLayoutRendererDefinition,
5656
} from './rendering-util/layout-algorithms/common/index.js';
57+
// Exported for layout packages that live outside this one: an `afterPaint`
58+
// hook is the only place line hops can be applied, and an external layout
59+
// cannot reach into `rendering-util` the way the built-in ones do.
60+
export { applyLineJumpsToSvg } from './rendering-util/rendering-elements/lineJump.js';
61+
export type { EdgeGeom, LineJumpConfig } from './rendering-util/rendering-elements/lineJump.js';
5762

5863
export interface RunOptions {
5964
/**

‎packages/mermaid/src/rendering-util/rendering-elements/lineJump.spec.ts‎

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -686,4 +686,169 @@ describe('lineJump', () => {
686686
expect(e2.getAttribute('d')).toBe('M5,0 L5,10');
687687
});
688688
});
689+
690+
describe('hops with too little room next to a bend', () => {
691+
const ROOMY: LineJumpConfig = { enabled: true, jumpRadius: 6, jumpStyle: 'arc' };
692+
693+
/**
694+
* Geometry lifted from `elk-edge-cases/many-subgraphs-and-edges`, where
695+
* `design-system -> mermaid-chart-app` leaves its node, turns north, turns
696+
* east again at (430.6, 180.1), and is crossed 10px later at (440.6, 180.1)
697+
* by `infrastructure -> auth-service`.
698+
*
699+
* 10px is `SUBGRAPH_EDGE_LANE_SPACING` — ELK stacks subgraph-internal edges
700+
* in lanes that far apart — and it does not hold a 7.07px corner cut plus a
701+
* 6px hop. The hop used to be fitted into what was left anyway, at 2.9px,
702+
* starting exactly where the corner's quadratic ended.
703+
*/
704+
const CRAMPED: EdgeGeom[] = [
705+
{
706+
id: 'designSystemToApp',
707+
points: [
708+
{ x: 395.6, y: 275.3 },
709+
{ x: 430.6, y: 275.3 },
710+
{ x: 430.6, y: 180.1 },
711+
{ x: 502.1, y: 180.1 },
712+
],
713+
curve: 'rounded',
714+
},
715+
{
716+
id: 'infrastructureToAuth',
717+
points: [
718+
{ x: 440.6, y: 120 },
719+
{ x: 440.6, y: 320 },
720+
],
721+
curve: 'rounded',
722+
},
723+
];
724+
725+
it('leaves the crossing alone rather than drawing an undersized arc', () => {
726+
const d = processEdgesWithJumps(CRAMPED, ROOMY).get('designSystemToApp')!;
727+
728+
// The crossing IS found — this is about what gets drawn for it, not about
729+
// detection.
730+
expect(findEdgeIntersections(CRAMPED)).toHaveLength(1);
731+
732+
// No arc anywhere on the path, and no zero-length `L` parked on the
733+
// corner's tangent point ahead of one.
734+
expect(d).not.toMatch(/A/);
735+
expect(d).not.toMatch(/L437\.696,180\.086 L437\.696,180\.086/);
736+
});
737+
738+
it('still hops once the bend is far enough away', () => {
739+
// Same edge, same crossing, but the turn moved back so the lane is 20px
740+
// instead of 10px — now there is room for the full radius.
741+
const roomy: EdgeGeom[] = [{ ...CRAMPED[0], points: [...CRAMPED[0].points] }, CRAMPED[1]];
742+
roomy[0].points[1] = { x: 420.6, y: 275.3 };
743+
roomy[0].points[2] = { x: 420.6, y: 180.1 };
744+
745+
const d = processEdgesWithJumps(roomy, ROOMY).get('designSystemToApp')!;
746+
747+
expect(d).toContain('A6,6 0 0 1');
748+
});
749+
750+
/**
751+
* `org -> platform` and `design -> app` from `knsv2.html`, verbatim.
752+
*
753+
* `org -> platform` runs east, turns south at (249, 1031.102) and carries on
754+
* down. `design -> app` runs west along y=1033.625 and crosses that vertical
755+
* — 2.5px below the turn, which is INSIDE the 7.07px the corner's quadratic
756+
* takes to rejoin the line.
757+
*
758+
* So at the y where the hop was drawn, `org -> platform` is not on x=249 at
759+
* all; it is still curving through its corner. The arc arched over blank
760+
* paper while the two strokes carried on touching beside it.
761+
*/
762+
const ACROSS_A_CORNER: EdgeGeom[] = [
763+
{
764+
id: 'orgToPlatform',
765+
points: [
766+
{ x: 169, y: 1031.102 },
767+
{ x: 249, y: 1031.102 },
768+
{ x: 249, y: 1345.091 },
769+
{ x: 552.5, y: 1345.091 },
770+
],
771+
curve: 'rounded',
772+
},
773+
{
774+
id: 'designToApp',
775+
points: [
776+
{ x: 229, y: 1382.007 },
777+
{ x: 229, y: 1033.625 },
778+
{ x: 269, y: 1033.625 },
779+
{ x: 351.5, y: 1033.625 },
780+
],
781+
curve: 'rounded',
782+
},
783+
];
784+
785+
it("ignores a crossing that lands inside the OTHER edge's corner", () => {
786+
// Nothing is wrong with the hopping edge here: its own bend is 20px back,
787+
// so the previous rule is happy to give it a full 6px arc. The problem is
788+
// entirely on the edge being hopped.
789+
expect(findEdgeIntersections(ACROSS_A_CORNER)).toEqual([]);
790+
791+
const d = processEdgesWithJumps(ACROSS_A_CORNER, ROOMY).get('designToApp')!;
792+
expect(d).not.toMatch(/A/);
793+
});
794+
795+
it('hops normally once that corner is out of the way', () => {
796+
// Same two edges, but `org -> platform` turns south 30px higher, so by
797+
// y=1033.625 it has long since settled onto x=249 and there is a real
798+
// vertical line to hop.
799+
const clear: EdgeGeom[] = [
800+
{
801+
...ACROSS_A_CORNER[0],
802+
points: [
803+
{ x: 169, y: 1001.102 },
804+
{ x: 249, y: 1001.102 },
805+
{ x: 249, y: 1345.091 },
806+
{ x: 552.5, y: 1345.091 },
807+
],
808+
},
809+
ACROSS_A_CORNER[1],
810+
];
811+
812+
expect(findEdgeIntersections(clear)).toHaveLength(1);
813+
expect(processEdgesWithJumps(clear, ROOMY).get('designToApp')).toContain('A6,6');
814+
});
815+
816+
it('keeps a straight run between the corner and a hop it does draw', () => {
817+
// A hop with just enough room still must not open ON the corner's tangent
818+
// point — the quadratic and the arc would meet with nothing between them,
819+
// which is the same squiggle as the undersized case, only bigger.
820+
//
821+
// The bend is at x=100, so its rounding ends at x=107.07. The crossing at
822+
// x=113 leaves 5.93px, which the old clamp spent entirely on radius and
823+
// opened the arc at exactly 107.07.
824+
const edges: EdgeGeom[] = [
825+
{
826+
id: 'bend',
827+
points: [
828+
{ x: 100, y: 0 },
829+
{ x: 100, y: 100 },
830+
{ x: 300, y: 100 },
831+
],
832+
curve: 'rounded',
833+
},
834+
{
835+
id: 'crosser',
836+
points: [
837+
{ x: 113, y: 0 },
838+
{ x: 113, y: 200 },
839+
],
840+
},
841+
];
842+
843+
const d = processEdgesWithJumps(edges, ROOMY).get('bend')!;
844+
const arc = /L([\d.]+),100 A([\d.]+),/.exec(d);
845+
expect(arc).not.toBeNull();
846+
847+
const [openAt, radius] = [arc![1], arc![2]].map(Number.parseFloat);
848+
// Radius gives way, not the clearance: 2px of straight line survives
849+
// between the end of the corner and the start of the arc.
850+
expect(openAt - 107.071).toBeCloseTo(2, 2);
851+
expect(radius).toBeCloseTo(3.929, 2);
852+
});
853+
});
689854
});

0 commit comments

Comments
 (0)