Skip to content

Commit 75a27c1

Browse files
Merge pull request #4 from Mermaid-Chart/feature/structural-elk-fallback
feat(layout): fall back to ELK for graphs hola and gipsec handle badly
2 parents be7156c + 0c65f0c commit 75a27c1

11 files changed

Lines changed: 462 additions & 8 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'mermaid': minor
3+
---
4+
5+
**`hola` and `gipsec` now hand graphs they are ill-suited to over to ELK.**
6+
7+
Both build their drawing around a planar, grid-aligned core. That works on the
8+
shape they were designed for and degrades on graphs that break its premises,
9+
where a general-purpose layout does better. A structural pre-check now runs
10+
before layout and redirects a diagram to `elk` when any one of these holds:
11+
12+
- the graph admits no planar embedding
13+
- it has more than 4 subgraphs
14+
- it has more than 10 nodes and at least one node of degree 5 or more
15+
16+
The check reads the abstract graph only — a planarity DFS and a degree count —
17+
so it never costs a discarded layout. Diagrams that pass are laid out exactly as
18+
before, and every other layout dispatches untouched.
19+
20+
The redirect is reported at `info` level, naming the condition that triggered
21+
it. On the tiny build, which ships no ELK, it resolves to `dagre` through the
22+
normal fallback chain.

‎diagram-lab/src/metrics/computeMetrics.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import { computeConnectivity } from './connectivity.js';
1212
import { computeDegrees } from './degrees.js';
1313
import { buildGraphModel, drawingBounds, simpleAdjacency } from './graphModel.js';
14-
import { isPlanar } from './planarity.js';
14+
import { isPlanar } from '../../../packages/mermaid/src/rendering-util/layout-algorithms/layout-utils/planarity.js';
1515
import { computeStress } from './stress.js';
1616
import { computeSubgraphStats } from './subgraphs.js';
1717
import { LAYOUT_DEPENDENT_METRIC_KEYS } from './types.js';

‎diagram-lab/src/metrics/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export {
99
} from './graphModel.js';
1010
export { computeDegrees, median } from './degrees.js';
1111
export { computeConnectivity } from './connectivity.js';
12-
export { isPlanar } from './planarity.js';
12+
export { isPlanar } from '../../../packages/mermaid/src/rendering-util/layout-algorithms/layout-utils/planarity.js';
1313
export { computeStress, STRESS_NODE_LIMIT } from './stress.js';
1414
export { ancestorGroups, computeSubgraphStats } from './subgraphs.js';
1515
export { LAYOUT_DEPENDENT_METRIC_KEYS, isLayoutDependentMetric } from './types.js';

‎docs/config/setup/mermaid/interfaces/LayoutLoaderDefinition.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,28 @@
1010

1111
# Interface: LayoutLoaderDefinition
1212

13-
Defined in: [packages/mermaid/src/rendering-util/render.ts:26](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/render.ts#L26)
13+
Defined in: [packages/mermaid/src/rendering-util/render.ts:30](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/render.ts#L30)
1414

1515
## Properties
1616

1717
### algorithm?
1818

1919
> `optional` **algorithm**: `string`
2020
21-
Defined in: [packages/mermaid/src/rendering-util/render.ts:29](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/render.ts#L29)
21+
Defined in: [packages/mermaid/src/rendering-util/render.ts:33](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/render.ts#L33)
2222

2323
---
2424

2525
### loader
2626

2727
> **loader**: `LayoutLoader`
2828
29-
Defined in: [packages/mermaid/src/rendering-util/render.ts:28](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/render.ts#L28)
29+
Defined in: [packages/mermaid/src/rendering-util/render.ts:32](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/render.ts#L32)
3030

3131
---
3232

3333
### name
3434

3535
> **name**: `string`
3636
37-
Defined in: [packages/mermaid/src/rendering-util/render.ts:27](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/render.ts#L27)
37+
Defined in: [packages/mermaid/src/rendering-util/render.ts:31](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/render.ts#L31)

‎docs/config/setup/mermaid/interfaces/RenderOptions.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@
1010

1111
# Interface: RenderOptions
1212

13-
Defined in: [packages/mermaid/src/rendering-util/render.ts:12](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/render.ts#L12)
13+
Defined in: [packages/mermaid/src/rendering-util/render.ts:16](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/render.ts#L16)
1414

1515
## Properties
1616

1717
### algorithm?
1818

1919
> `optional` **algorithm**: `string`
2020
21-
Defined in: [packages/mermaid/src/rendering-util/render.ts:13](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/render.ts#L13)
21+
Defined in: [packages/mermaid/src/rendering-util/render.ts:17](https://github.com/mermaid-js/mermaid/blob/master/packages/mermaid/src/rendering-util/render.ts#L17)
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { expect, test } from '@playwright/test';
2+
import { mermaidUrl } from '../helpers/util.js';
3+
4+
/**
5+
* The structural pre-check decides which layout actually runs, so what there is
6+
* to assert is which one did. `mermaidLastLayoutCapture.layoutAlgorithm` records
7+
* that, and is the only place the redirect is observable from outside the
8+
* renderer.
9+
*/
10+
11+
/** K3,3 — the smallest non-planar bipartite graph. 6 nodes, every degree 3. */
12+
const NON_PLANAR = `flowchart LR
13+
a1 --> b1
14+
a1 --> b2
15+
a1 --> b3
16+
a2 --> b1
17+
a2 --> b2
18+
a2 --> b3
19+
a3 --> b1
20+
a3 --> b2
21+
a3 --> b3
22+
`;
23+
24+
/** A path: planar, low-degree, no subgraphs. Nothing for the gate to catch. */
25+
const PLAIN = `flowchart LR
26+
n0 --> n1 --> n2 --> n3
27+
`;
28+
29+
/** Five subgraphs, one over the limit, but otherwise a trivial graph. */
30+
const MANY_SUBGRAPHS = `flowchart LR
31+
subgraph s1
32+
a --> b
33+
end
34+
subgraph s2
35+
c --> d
36+
end
37+
subgraph s3
38+
e --> f
39+
end
40+
subgraph s4
41+
g --> h
42+
end
43+
subgraph s5
44+
i --> j
45+
end
46+
b --> c
47+
`;
48+
49+
const cases = [
50+
{ name: 'non-planar K3,3', code: NON_PLANAR, redirected: true },
51+
{ name: 'five subgraphs', code: MANY_SUBGRAPHS, redirected: true },
52+
{ name: 'a plain path', code: PLAIN, redirected: false },
53+
] as const;
54+
55+
for (const layout of ['hola', 'gipsec'] as const) {
56+
for (const { name, code, redirected } of cases) {
57+
const expected = redirected ? 'elk' : layout;
58+
test(`${layout} on ${name} runs ${expected}`, async ({ page }) => {
59+
await page.addInitScript(() => {
60+
(window as unknown as { mermaidCaptureValidation: boolean }).mermaidCaptureValidation =
61+
true;
62+
});
63+
await page.goto(mermaidUrl(code, { layout, look: 'classic', theme: 'default' }, false));
64+
await page.waitForFunction(
65+
() => (window as unknown as { rendered: boolean }).rendered === true
66+
);
67+
const actual = await page.evaluate(
68+
() =>
69+
(window as unknown as { mermaidLastLayoutCapture?: { layoutAlgorithm?: string } })
70+
.mermaidLastLayoutCapture?.layoutAlgorithm
71+
);
72+
expect(actual).toBe(expected);
73+
});
74+
}
75+
}

diagram-lab/src/metrics/planarity.spec.ts renamed to packages/mermaid/src/rendering-util/layout-algorithms/layout-utils/planarity.spec.ts

File renamed without changes.

diagram-lab/src/metrics/planarity.ts renamed to packages/mermaid/src/rendering-util/layout-algorithms/layout-utils/planarity.ts

13 KB
Binary file not shown.
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { LayoutData } from '../../types.js';
3+
import { structuralFallbackVerdict } from './structuralFallback.js';
4+
5+
/** A graph from an edge list, with every endpoint promoted to a plain node. */
6+
function graph(edges: [string, string][], extraNodes: string[] = []): LayoutData {
7+
const ids = new Set<string>(extraNodes);
8+
for (const [a, b] of edges) {
9+
ids.add(a);
10+
ids.add(b);
11+
}
12+
return {
13+
nodes: [...ids].map((id) => ({ id, isGroup: false })),
14+
edges: edges.map(([start, end], index) => ({ id: `e${index}`, start, end })),
15+
config: {},
16+
} as unknown as LayoutData;
17+
}
18+
19+
function withSubgraphs(count: number, edges: [string, string][] = []): LayoutData {
20+
const data = graph(edges);
21+
for (let index = 0; index < count; index++) {
22+
(data.nodes as unknown[]).push({ id: `sub${index}`, isGroup: true });
23+
}
24+
return data;
25+
}
26+
27+
/** A path, which is planar, low-degree, and has no groups — the happy shape. */
28+
function path(length: number): [string, string][] {
29+
return Array.from({ length }, (_, index) => [`n${index}`, `n${index + 1}`]);
30+
}
31+
32+
describe('structuralFallbackVerdict', () => {
33+
describe('only applies to the grid-like layouts', () => {
34+
// K5 is non-planar, so it would be redirected if the layout were in scope.
35+
const k5: [string, string][] = [
36+
['a', 'b'],
37+
['a', 'c'],
38+
['a', 'd'],
39+
['a', 'e'],
40+
['b', 'c'],
41+
['b', 'd'],
42+
['b', 'e'],
43+
['c', 'd'],
44+
['c', 'e'],
45+
['d', 'e'],
46+
];
47+
48+
it.each(['hola', 'gipsec'])('considers %s', (layout) => {
49+
expect(structuralFallbackVerdict(graph(k5), layout).fallback).toBe(true);
50+
});
51+
52+
it.each(['dagre', 'elk', 'swimlane', 'domus', 'ipsep-cola', ''])(
53+
'leaves %s alone',
54+
(layout) => {
55+
expect(structuralFallbackVerdict(graph(k5), layout)).toEqual({ fallback: false });
56+
}
57+
);
58+
});
59+
60+
describe('non-planar', () => {
61+
it('redirects K5', () => {
62+
const k5: [string, string][] = [];
63+
for (const a of ['a', 'b', 'c', 'd', 'e']) {
64+
for (const b of ['a', 'b', 'c', 'd', 'e']) {
65+
if (a < b) {
66+
k5.push([a, b]);
67+
}
68+
}
69+
}
70+
// Degree 4 throughout and only 5 nodes, so neither other rule can fire.
71+
const verdict = structuralFallbackVerdict(graph(k5), 'hola');
72+
expect(verdict.fallback).toBe(true);
73+
expect(verdict.reason).toBe('non-planar');
74+
});
75+
76+
it('redirects K3,3', () => {
77+
const k33: [string, string][] = [];
78+
for (const a of ['a1', 'a2', 'a3']) {
79+
for (const b of ['b1', 'b2', 'b3']) {
80+
k33.push([a, b]);
81+
}
82+
}
83+
const verdict = structuralFallbackVerdict(graph(k33), 'gipsec');
84+
expect(verdict.fallback).toBe(true);
85+
expect(verdict.reason).toBe('non-planar');
86+
});
87+
88+
it('keeps a planar graph', () => {
89+
expect(structuralFallbackVerdict(graph(path(5)), 'hola')).toEqual({ fallback: false });
90+
});
91+
});
92+
93+
describe('subgraph count', () => {
94+
it('keeps 4', () => {
95+
expect(structuralFallbackVerdict(withSubgraphs(4, path(3)), 'hola')).toEqual({
96+
fallback: false,
97+
});
98+
});
99+
100+
it('redirects 5', () => {
101+
const verdict = structuralFallbackVerdict(withSubgraphs(5, path(3)), 'hola');
102+
expect(verdict.fallback).toBe(true);
103+
expect(verdict.reason).toBe('too-many-subgraphs');
104+
expect(verdict.detail).toContain('5 subgraphs');
105+
});
106+
107+
it('counts only group nodes, not every node', () => {
108+
// 12 plain nodes, no groups: the subgraph rule must not fire on them.
109+
const verdict = structuralFallbackVerdict(graph(path(11)), 'hola');
110+
expect(verdict.reason).not.toBe('too-many-subgraphs');
111+
});
112+
});
113+
114+
describe('large graph with a high-degree node', () => {
115+
/** A star of `spokes` leaves around `hub`, padded to `total` nodes by a path. */
116+
function hubAndPadding(spokes: number, total: number): LayoutData {
117+
const edges: [string, string][] = Array.from({ length: spokes }, (_, index) => [
118+
'hub',
119+
`s${index}`,
120+
]);
121+
const padding = Array.from({ length: total }, (_, index) => `pad${index}`);
122+
return graph(edges, padding);
123+
}
124+
125+
it('redirects >10 nodes with a degree-5 node', () => {
126+
const verdict = structuralFallbackVerdict(hubAndPadding(5, 10), 'hola');
127+
expect(verdict.fallback).toBe(true);
128+
expect(verdict.reason).toBe('large-with-high-degree-node');
129+
expect(verdict.detail).toContain('degree 5');
130+
});
131+
132+
it('keeps a degree-5 node in a small graph', () => {
133+
// 6 nodes: the hub is busy but there is nothing to detour around.
134+
expect(structuralFallbackVerdict(hubAndPadding(5, 0), 'hola')).toEqual({ fallback: false });
135+
});
136+
137+
it('keeps a large graph whose nodes all stay under degree 5', () => {
138+
// A 20-node path: every interior node is degree 2.
139+
expect(structuralFallbackVerdict(graph(path(19)), 'hola')).toEqual({ fallback: false });
140+
});
141+
142+
it('needs more than 10 nodes, not exactly 10', () => {
143+
// hub + 5 spokes + 4 padding = 10 nodes exactly, so the rule must not fire.
144+
expect(structuralFallbackVerdict(hubAndPadding(5, 4), 'hola')).toEqual({ fallback: false });
145+
});
146+
});
147+
148+
describe('adjacency construction', () => {
149+
it('ignores self-loops when counting degree', () => {
150+
const data = graph([
151+
['hub', 's0'],
152+
['hub', 's1'],
153+
['hub', 's2'],
154+
['hub', 's3'],
155+
]);
156+
// Four real spokes plus four self-loops would reach degree 5 only if
157+
// self-loops counted; they must not.
158+
for (let index = 0; index < 4; index++) {
159+
(data.edges as unknown[]).push({ id: `loop${index}`, start: 'hub', end: 'hub' });
160+
}
161+
for (let index = 0; index < 8; index++) {
162+
(data.nodes as unknown[]).push({ id: `pad${index}`, isGroup: false });
163+
}
164+
expect(structuralFallbackVerdict(data, 'hola')).toEqual({ fallback: false });
165+
});
166+
167+
it('counts parallel edges once', () => {
168+
const data = graph([['hub', 's0']]);
169+
for (let index = 0; index < 6; index++) {
170+
(data.edges as unknown[]).push({ id: `dup${index}`, start: 'hub', end: 's0' });
171+
}
172+
for (let index = 0; index < 12; index++) {
173+
(data.nodes as unknown[]).push({ id: `pad${index}`, isGroup: false });
174+
}
175+
// Seven edges between the same pair is still degree 1.
176+
expect(structuralFallbackVerdict(data, 'hola')).toEqual({ fallback: false });
177+
});
178+
179+
it('survives an empty graph', () => {
180+
expect(
181+
structuralFallbackVerdict(
182+
{ nodes: [], edges: [], config: {} } as unknown as LayoutData,
183+
'hola'
184+
)
185+
).toEqual({ fallback: false });
186+
});
187+
188+
it('survives missing nodes and edges', () => {
189+
expect(structuralFallbackVerdict({} as unknown as LayoutData, 'hola')).toEqual({
190+
fallback: false,
191+
});
192+
});
193+
194+
it('skips an edge pointing at an unknown node', () => {
195+
const data = graph(path(3));
196+
(data.edges as unknown[]).push({ id: 'dangling', start: 'n0', end: 'nowhere' });
197+
expect(structuralFallbackVerdict(data, 'hola')).toEqual({ fallback: false });
198+
});
199+
});
200+
});

0 commit comments

Comments
 (0)