Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
49a2a4e
feat(ddlt): port validateLayout from the domus branch
knsv Aug 26, 2026
a04e241
fix(ddlt): run the real measure step so shapes attach their intersect
knsv Aug 26, 2026
a786be9
test(elk): add a DDLT-to-browser parity check
knsv Aug 26, 2026
8cfff5d
fix(rendering): drop the half-pixel bias in intersectLine
knsv Aug 26, 2026
9d4c927
fix(elk): attach non-rect endpoints along the edge's departure axis
knsv Aug 26, 2026
e9e3dea
fix(rendering): tighten types in the intersectLine spec
knsv Aug 26, 2026
39f239d
feat(elk): default node placement to NETWORK_SIMPLEX
knsv Aug 26, 2026
993146b
feat(validate): distinguish a deliberate edge bundle from an ambiguou…
knsv Aug 26, 2026
3369bc6
test(elk): pin the case where merging fabricates an edge
knsv Aug 26, 2026
d743993
fix(dev-explorer): keep capturedAt when a re-capture measures the sam…
knsv Aug 26, 2026
ad4d60b
fix(elk): bundle edges by role so merging cannot invent a connection
knsv Aug 26, 2026
23a19c4
Revert "fix(elk): bundle edges by role so merging cannot invent a con…
knsv Aug 26, 2026
81ec1bb
feat(validate): flag an edge passing through another edge's attachmen…
knsv Aug 26, 2026
af703fd
test(elk): wrap many-subgraphs-and-edges labels at 125
knsv Aug 26, 2026
f7faeac
feat(elk): name the node-placement combinations as elk.preset
knsv Aug 27, 2026
906cbac
feat(mermaid): export applyLineJumpsToSvg for external layout packages
knsv Aug 27, 2026
9b78fa1
fix(elk): straighten the tiny step where an edge meets a node
knsv Aug 27, 2026
6fea7b9
feat(elk): draw line hops where edges cross
knsv Aug 27, 2026
f3f36a4
fix(elk): only straighten the step that sits next to the node
knsv Aug 27, 2026
98b65b6
fix(elk): see sub-pixel steps when straightening
knsv Aug 27, 2026
28842e7
chore: add the ELK layering option names to the dictionary
knsv Aug 27, 2026
669d482
fix(elk): straighten without moving the port
knsv Aug 27, 2026
798b98e
fix(line-hops): skip a hop that cannot be drawn next to a bend
knsv Aug 27, 2026
c29d293
fix(line-hops): ignore a crossing found inside a rounded corner
knsv Aug 27, 2026
80fab04
fix(elk): buy subgraph edge spacing separately from the base value
knsv Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/bundled-edge-validation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'mermaid': minor
---

feat: layout validation tells a deliberate edge bundle apart from an ambiguous one.

Two edges sharing a handle on a node, or running the same lane for a stretch, used to be a hard violation regardless of how they got there. That is right for the case the checks were written for — an edge arriving exactly where another leaves, so the handle no longer says which way anything goes — but wrong for a fan: one trunk that splits, which a reader can follow.

Sharing is now classified. When both edges play the same role at the node they meet on, and travel the shared geometry the same way, it is reported as `edge-bundled-attachment-point` or `edge-bundled-subpath` — soft issues that cost score rather than invalidating the layout, because a bundle still hides how many edges are inside it. Mixed roles and opposing travel stay hard as `edge-shared-attachment-point` and `edge-shared-subpath`.

This makes bundled layouts measurable instead of automatically invalid. Over the ELK edge-case corpus, a layout laid out with `elk.mergeEdges: true` drops from 540 hard issues to 52, and the 44 that remain are genuine ambiguity rather than bundling. Layouts that do not bundle are unaffected.
11 changes: 11 additions & 0 deletions .changeset/edge-passes-node-attachment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'mermaid': patch
---

feat: layout validation catches an edge that passes through another edge's attachment point.

An edge that has nothing to do with a node, but runs through the exact spot where some other edge attaches to it, is read as leaving that node — a reader cannot tell a line that touches a node from one that starts there. The diagram then shows a connection its source never declared.

It shows up when edges are bundled: a trunk from an upstream node brushes past a node on its way, and its continuation beyond that point looks like the node's own outgoing edge. The bundle checks cannot see it, because they only ask whether the bundled edges share a source and travel the same way — not what happens after they split.

Reported as `edge-passes-node-attachment`, hard, within the same tolerance two attachment points must already differ by. It is narrow on purpose: passing _near_ a node is `edge-border-hugging`'s business, and this only fires on coinciding with a real attachment point. No fixture in the existing corpora trips it.
26 changes: 26 additions & 0 deletions .changeset/elk-layout-presets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@mermaid-js/layout-elk': minor
---

feat: `elk.preset` picks a named combination of the options that decide where nodes end up.

Three options settle node positions, and they sit in different phases of the layout: which layer a node lands in, where it goes within that layer, and which edges get reversed to make the graph acyclic. Choosing them well means knowing all three interact; `preset` names the combinations worth using.

- `default` — network simplex layering, linear segments placement, greedy model order cycle breaking. Keeps chains of nodes aligned.
- `legacy` — reproduces what earlier versions actually rendered: Brandes-Koepf placement with ELK's own greedy cycle breaking.
- `depthFirst` — as `default`, but breaks cycles depth first, which gives shorter back edges on graphs that have many.

```yaml
---
config:
layout: elk
elk:
preset: legacy
---
```

Setting `layeringStrategy`, `nodePlacementStrategy` or `cycleBreakingStrategy` explicitly overrides the preset for that one option and leaves the rest in place, so a preset is a starting point rather than a lock.

**The default placement strategy changes from `NETWORK_SIMPLEX` to `LINEAR_SEGMENTS`, so existing ELK diagrams will lay out differently.** `preset: legacy` restores the previous behaviour.

Note that `legacy` uses `GREEDY` cycle breaking rather than the `GREEDY_MODEL_ORDER` the schema previously advertised. That default was declared in the schema but never listed in the shipped defaults, so it reached ELK as undefined and ELK's own default applied — `legacy` reproduces what was rendered, not what was documented.
20 changes: 20 additions & 0 deletions .changeset/elk-line-hops.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@mermaid-js/layout-elk': minor
---

feat: draw line hops where ELK edges cross, controlled by `elk.lineHops`.

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.

```yaml
---
config:
layout: elk
elk:
lineHops: gap
---
```

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.

**Existing ELK diagrams with crossing edges will render differently.**
13 changes: 13 additions & 0 deletions .changeset/elk-non-rect-attachment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@mermaid-js/layout-elk': patch
---

fix: edges leave diamonds, stadiums and other non-rectangular shapes without kinking.

ELK routes to ports on a node's bounding box and always leaves one perpendicular to the side it sits on. For a rectangle that port is the attachment point; for anything else the outline is inside the box, so the attachment has to move inwards — and the direction it moves in decides whether the edge stays orthogonal.

It used to move along the ray from the node's centre, which lands on the outline at a different offset along the side than the port ELK chose, so the opening segment came out diagonal. The attachment now walks the outline along the edge's own departure axis, staying collinear with ELK's stub: the edge leaves the outline, crosses the box, and carries on in one straight line. Rectangular nodes are unaffected.

Also in this release:

- The default `elk.nodePlacementStrategy` is now `NETWORK_SIMPLEX` rather than `BRANDES_KOEPF`. **This changes the layout of existing ELK diagrams**, though most are unaffected: over the ELK edge-case corpus, 9 of 13 diagrams are byte-identical and the rest improve. Set `elk: { nodePlacementStrategy: 'BRANDES_KOEPF' }` to keep the previous placement.
11 changes: 11 additions & 0 deletions .changeset/elk-straighten-terminal-jogs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@mermaid-js/layout-elk': patch
---

fix: an edge no longer leaves a node with a tiny kink.

ELK spreads an edge's port evenly along a node's side, then routes the edge down a channel whose row rarely lines up with that port exactly. The leftover is a staircase of a few pixels right at the border: leave the port, run a short distance, step perpendicular onto the channel, carry on. With rounded corners the two micro-bends sit on top of each other and read as a glitch — one edge in the sample corpus stepped 3.25px and rendered as two quadratic curves with a zero-length segment between them.

The step is now removed by moving the channel onto the port's row, so the edge draws as one straight line and **both ports stay exactly where the layout put them** — sliding an attachment along a node's border leaves a node whose other edges are still evenly spread looking lopsided. Only a step next to a node is touched, and only when it is small and the edge continues the same way afterwards, so a real turn is never collapsed. An edge is left alone entirely when moving its run would drag the far port, or would buy a crossing.

Set `elk.straightenEdges: false` to keep the previous behaviour.
17 changes: 17 additions & 0 deletions .changeset/elk-subgraph-spacing-split.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@mermaid-js/layout-elk': patch
---

fix: stop ELK subgraphs padding one side more than the other.

A subgraph could end up with far more space on one side than the other for no reason a reader could see — 74px on the right of one group against 24px everywhere else. The extra space was a routing lane held open for an edge that runs against the flow of the layout and has to be routed back around, and its width came from `spacing.baseValue`.

That base value was doing two jobs at once. Every unset ELK spacing derives from it, so it had to stay large enough that an edge got a straight run before the node it enters — below about 40 the approach came out shorter than the arrowhead and the turn read as happening underneath it. But an edge routed down the inside of a frame claims a lane the same width, so paying for the approach out of the base value also pushed groups clear of their own borders.

The two are now bought separately. The base value drops to 24, and the approach run, node separation and edge separation are set explicitly. Subgraph padding is even again, and edges keep the run they had.

`elk.layered.spacing.edgeNodeBetweenLayers` is the key that buys the approach. An earlier attempt used `elk.layered.spacing.edgeEdgeBetweenLayers`, which is edge-to-edge and a different quantity, and a note in the source concluded from it that ELK ignored edge-node spacing "in every key form". It does not; that note was wrong and is corrected.

Subgraph nodes are also placed with `NETWORK_SIMPLEX` and `PORT_POSITION` flexibility, which keeps a group's nodes aligned with one another instead of drifting, and lets a node shift so an edge can leave straight rather than bending off the port.

**Existing diagrams with subgraphs will render differently** — groups get tighter and more even.
9 changes: 9 additions & 0 deletions .changeset/export-line-jumps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'mermaid': minor
---

feat: export `applyLineJumpsToSvg` so layout packages outside this one can draw line hops.

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.

`EdgeGeom` and `LineJumpConfig` are exported with it, since they are the argument types.
11 changes: 11 additions & 0 deletions .changeset/intersect-line-half-pixel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'mermaid': patch
---

fix: edges attach to non-rectangular shapes on the outline instead of half a pixel off it.

`intersectLine` comes from Graphics Gems, where the coordinates were integers and `denom / 2` was added to the numerator so the integer division rounded instead of truncating. JavaScript division does neither, so the term was never a rounding correction: `(num + denom / 2) / denom` is `num / denom + 0.5`. Every intersection came back displaced half a unit on both axes.

`intersectPolygon` is how every non-rectangular shape finds its edge attachment — diamond, stadium, hexagon, trapezoid, subroutine — so a vertical ray leaving a node's bottom border returned a point half a pixel to the right of it and half a pixel below it. Enough to give an otherwise orthogonal edge a tiny diagonal opening segment, and, when the ray pointed the other way, to put the attachment just inside the node it was meant to touch.

`question.ts` had been subtracting the 0.5 back off for diamonds; that compensation is removed along with the cause. **Rendered output moves by half a pixel wherever a polygon shape terminates an edge.**
13 changes: 13 additions & 0 deletions .changeset/line-hop-corner-clearance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'mermaid': patch
---

fix: don't draw a line hop that has no room next to a bend.

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.

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.

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.

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.
3 changes: 3 additions & 0 deletions .cspell/code-terms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ DOUBLECIRCLEEND
DOUBLECIRCLESTART
DQUOTE
DSTART
DUMMYNODE
EBNF
edgesep
Eiglsperger
Expand Down Expand Up @@ -101,6 +102,8 @@ minlen
Mstartx
MULT
Naur
NIKOLOV
NODECOUNT
NODIR
nonterminal
NSTR
Expand Down
22 changes: 20 additions & 2 deletions .esbuild/dev-explorer/diagram-viewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,17 @@ type CapturedNodeSize = {
height: number;
};

type CapturedGroupLabelSize = {
id: string;
labelBBox: { width: number; height: number };
};

type CapturedSizesEntry = {
svgId: string;
sizes: {
nodes: CapturedNodeSize[];
groups?: CapturedGroupLabelSize[];
edges?: CapturedNodeSize[];
metadata?: Record<string, unknown>;
};
};
Expand Down Expand Up @@ -321,9 +328,16 @@ function normalizeLayout(v: unknown): MermaidLayout | null {
return null;
}

/**
* Layouts whose measure step runs `createGraphWithElements`, which is where the
* DDLT size capture hook lives. `dagre` and `hola` do not, so capturing under
* them would silently produce an empty fixture.
*/
const SIZE_CAPTURE_LAYOUTS: MermaidLayout[] = ['swimlane', 'domus', 'elk'];

function sizeCaptureUnavailableReason(layout: MermaidLayout) {
if (layout !== 'swimlane') {
return 'No size data is available for this layout. Select swimlanes to capture DDLT sizes.';
if (!SIZE_CAPTURE_LAYOUTS.includes(layout)) {
return `No size data is available for the "${layout}" layout. Select one of ${SIZE_CAPTURE_LAYOUTS.join(', ')} to capture DDLT sizes.`;
}
return '';
}
Expand Down Expand Up @@ -813,7 +827,11 @@ export class DevDiagramViewer extends LitElement {
body: JSON.stringify({
path: this.filePath,
nodes,
groups: captured?.sizes.groups ?? [],
edges: captured?.sizes.edges ?? [],
capturedFrom: `dev-explorer ${this.filePath} theme=${this.theme} look=${this.look} layout=${this.layout}`,
theme: this.theme,
look: this.look,
}),
});
if (!res.ok) {
Expand Down
Loading
Loading