Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/elk-small-node-edge-centering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@mermaid-js/layout-elk': patch
---

fix: center edges attached to small nodes such as start/end state circles. ELK's ports-surrounding margin exceeded the side length of nodes narrower than 24px, parking the edge anchor off-center; such anchors are now discarded so the edge aims at the node center.
607 changes: 607 additions & 0 deletions docs/community/layout-makers-guide.md

Large diffs are not rendered by default.

316 changes: 254 additions & 62 deletions docs/community/new-diagram.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
config:
layout: elk
---
stateDiagram-v2
[*] --> [*]
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
stateDiagram
[*] --> State1
State1 --> [*]
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
stateDiagram
State1: The state with a note
note right of State1
Important information! You can write
notes.
end note
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
stateDiagram
[*] --> State1
State1 --> State2 : Transition 1
State1 --> State3 : Transition 2
State1 --> State4 : Transition 3
State1 --> State5 : Transition 4
State2 --> State3 : Transition 5
State1 --> [*]
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
stateDiagram-v2

[*] --> State1
State1 --> [*]
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
stateDiagram-v2
A: Google
click A "https://google.com" "Visit Google"
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
stateDiagram-v2
[*] --> State1
State1 --> State2
State2 --> [*]
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
stateDiagram-v2
[*] --> State1
State1 --> State2
State1 --> State3
State1 --> State4
State1 --> State5
State2 --> State3
State2 --> State4
State2 --> State5
State3 --> State4
State3 --> State5
State4 --> State5
State5 --> [*]
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
stateDiagram-v2
[*] --> State1
State1 --> State2
State1 --> State3
State1 --> State4
State2 --> State3
State2 --> State4
State3 --> State4
state State1: Description 1
state State2: Description 2
state State3: Description 3
state State4: Description 4
State4 --> [*]
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
stateDiagram-v2
state fork_state <<fork>>
[*] --> fork_state
fork_state --> State2
fork_state --> State3

state join_state <<join>>
State2 --> join_state
State3 --> join_state
join_state --> State4
State4 --> [*]
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
stateDiagram-v2
[*] --> Active
state Active {
[*] --> Running
Running --> Paused
Paused --> Running
Running --> [*]
}
Active --> Inactive
Inactive --> [*]
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
stateDiagram-v2
[*] --> Active
state Active {
[*] --> NumLockOff
NumLockOff --> NumLockOn
NumLockOn --> NumLockOff
--
[*] --> CapsLockOff
CapsLockOff --> CapsLockOn
CapsLockOn --> CapsLockOff
}
74 changes: 74 additions & 0 deletions packages/mermaid-layout-elk/src/__tests__/render.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,80 @@ describe('runElkLayoutCore', () => {
});
});

describe('small-node edge anchoring', () => {
// A start/end state circle is 14px across — smaller than twice the 12px
// ports-surrounding margin — so ELK's anchor for it lands off-centre and is
// discarded in favour of aiming at the node centre. The whole route must
// then run down the shared centre line.
it('centres a single edge between two start/end state circles', async () => {
const data = {
direction: 'TB',
config: { elk: {} },
nodes: [
{
id: 'root_start',
isGroup: false,
width: 14,
height: 14,
label: 'root_start',
shape: 'stateStart',
},
{
id: 'root_end',
isGroup: false,
width: 14,
height: 14,
label: 'root_end',
shape: 'stateEnd',
},
],
edges: [{ id: 'edge0', start: 'root_start', end: 'root_end', type: 'arrow_barb' }],
} as any;

await runElkLayoutCore(data, elkRenderContext);

const start = data.nodes.find((node: any) => node.id === 'root_start');
const edge = data.edges[0];
expect(edge.points.length).toBeGreaterThanOrEqual(2);
for (const point of edge.points) {
expect(point.x).toBeCloseTo(start.x, 3);
}
});

// The drop is side-specific: a fork/join bar is thin but long, and the side
// its anchors spread along (the width, for a top/bottom attachment) is well
// above the margin threshold. Its anchors carry real information — two
// incoming edges must keep two distinct attachment points instead of being
// funnelled to the bar's centre.
it('keeps spread anchors on a wide, thin fork/join bar', async () => {
const data = {
direction: 'TB',
config: { elk: {} },
nodes: [
{ id: 'a', isGroup: false, width: 40, height: 20, label: 'a', shape: 'rect' },
{ id: 'b', isGroup: false, width: 40, height: 20, label: 'b', shape: 'rect' },
{ id: 'bar', isGroup: false, width: 120, height: 10, label: 'bar', shape: 'forkJoin' },
],
edges: [
{ id: 'e1', start: 'a', end: 'bar', type: 'arrow_point' },
{ id: 'e2', start: 'b', end: 'bar', type: 'arrow_point' },
],
} as any;

await runElkLayoutCore(data, elkRenderContext);

const bar = data.nodes.find((node: any) => node.id === 'bar');
const arrivalXs = data.edges.map((edge: any) => edge.points.at(-1).x);
expect(Math.abs(arrivalXs[0] - arrivalXs[1])).toBeGreaterThan(1);
// ELK spreads the two anchors ~28px either side of the bar's centre. An
// edge whose anchor was wrongly dropped aims at the centre instead and
// arrives within ~5px of it, so require real clearance from the centre.
for (const x of arrivalXs) {
expect(Math.abs(x - bar.x)).toBeGreaterThan(10);
}
});
});

describe('ensureEndMarkerSegmentLength', () => {
const log = { debug: () => undefined };
const circleBounds = {
Expand Down
41 changes: 38 additions & 3 deletions packages/mermaid-layout-elk/src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,10 +145,13 @@ const DEFAULT_NODE_PLACEMENT_ALIGNMENT = 'NONE';

/**
* Margin reserved at the ends of each side of a node, so that a port cannot be
* placed on a corner. Spelled as an ELK margin because `spacing.portsSurrounding`
* takes one.
* placed on a corner. `anchorOnDegenerateSide` treats a side shorter than twice
* this as having no usable anchor span, so the option string below is built
* from it — the two must not be able to disagree.
*/
const PORTS_SURROUNDING = '[top=12,left=12,bottom=12,right=12]';
const PORTS_SURROUNDING_MARGIN = 12;
/** The margin spelled as an ELK margin, because `spacing.portsSurrounding` takes one. */
const PORTS_SURROUNDING = `[top=${PORTS_SURROUNDING_MARGIN},left=${PORTS_SURROUNDING_MARGIN},bottom=${PORTS_SURROUNDING_MARGIN},right=${PORTS_SURROUNDING_MARGIN}]`;
/** Padding between a subgraph frame and its children. ELK's own default is 12. */
const SUBGRAPH_PADDING = 24;
/**
Expand Down Expand Up @@ -1711,10 +1714,16 @@ function applyElkEdgeLayout(
endNode.y = endNode.offset!.posY + endNode.height! / 2;

if (startNode.shape !== 'rect33') {
if (points.length > 1 && anchorOnDegenerateSide(startNode, points[0])) {
points.shift();
}
points.unshift({ x: startNode.x, y: startNode.y });
}

if (endNode.shape !== 'rect33') {
if (points.length > 1 && anchorOnDegenerateSide(endNode, points[points.length - 1])) {
points.pop();
}
points.push({ x: endNode.x, y: endNode.y });
}

Expand All @@ -1739,6 +1748,32 @@ function applyElkEdgeLayout(
}
}

/**
* ELK reserves `PORTS_SURROUNDING_MARGIN` at both ends of a node side before
* distributing edge anchors along it (see `PORTS_SURROUNDING`). On a side
* shorter than twice that margin the usable span is negative, and ELK's
* clamping parks the anchor off-centre — a 14px start/end state circle got
* its only edge attached 3px off the dot's centre, and no node-level option
* overrides it (the spacing is only read per hierarchy level). Such an anchor
* carries no information, so the caller drops it and lets the edge aim at the
* node centre instead; the border clip then lands it dead centre, the same
* way the dagre pipeline attaches edges.
*/
function anchorOnDegenerateSide(node: NodeWithVertex, anchor: P): boolean {
const width = node.width ?? 0;
const height = node.height ?? 0;
const top = node.offset!.posY;
const bottom = top + height;
// ELK puts the anchor exactly on the border; the slack only absorbs float
// error from the offset arithmetic above. Same tolerance `onBorder` uses.
const tol = 0.5;
// An anchor on the top or bottom border spreads along the width; one on the
// left or right border spreads along the height.
const alongWidth = Math.abs(anchor.y - top) <= tol || Math.abs(anchor.y - bottom) <= tol;
const side = alongWidth ? width : height;
return side < 2 * PORTS_SURROUNDING_MARGIN;
}

function createEdgePointsFromSection(section: any, offset: { x: number; y: number }): P[] {
const src = section.startPoint;
const dest = section.endPoint;
Expand Down
1 change: 1 addition & 0 deletions packages/mermaid/src/docs/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ function sidebarCommunity() {
{ text: 'Getting Started', link: '/community/intro' },
{ text: 'Contributing to Mermaid', link: '/community/contributing' },
{ text: 'Adding Diagrams', link: '/community/new-diagram' },
{ text: 'Adding Layouts', link: '/community/layout-makers-guide' },
{ text: 'Questions and Suggestions', link: '/community/questions-and-suggestions' },
{ text: 'Security', link: '/community/security' },
],
Expand Down
Loading
Loading