Skip to content
Open
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/venn-implied-pairwise-subsets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mermaid': patch
---

fix(venn): render labeled higher-arity unions when the underlying pairwise unions are not declared. The venn.js layout needs pairwise intersection sizes to make circles overlap, so `union A,B,C[label]` on its own previously rendered as three disjoint circles with no intersection region for the label. `vennRenderer` now synthesizes the missing pairwise subsets from any higher-arity union before passing the data to venn.js. User-declared subsets keep their sizes, labels, and styles. Resolves #7656.
25 changes: 25 additions & 0 deletions cypress/integration/rendering/venn/venn.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,4 +237,29 @@ describe('Venn Diagram', () => {
{ look: 'handDrawn', handDrawnSeed: 1, fontFamily: 'courier' }
);
});

it('17: should render a three-set venn with only a labeled 3-way union (issue #7656)', () => {
imgSnapshotTest(
`venn-beta
title Innovation
set Desirable
set Feasible
set Viable
union Desirable, Feasible, Viable["Innovation"]
`
);
});

it('18: should render a four-set venn with only a 4-way union (issue #7656)', () => {
imgSnapshotTest(
`venn-beta
title Four sets
set A
set B
set C
set D
union A, B, C, D["AllFour"]
`
);
});
});
22 changes: 22 additions & 0 deletions docs/syntax/venn.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,28 @@ venn-beta
union A,B["AB"]
```

### Higher-arity unions

`union` accepts three or more set names. The diagram renders the implied
pairwise overlaps automatically, so the label on the higher-arity union has a
visible region to sit in:

```mermaid-example
venn-beta
set Desirable
set Feasible
set Viable
union Desirable,Feasible,Viable["Innovation"]
```

```mermaid
venn-beta
set Desirable
set Feasible
set Viable
union Desirable,Feasible,Viable["Innovation"]
```

### Sizes

Use `:N` suffix to set the size of a set or union:
Expand Down
139 changes: 138 additions & 1 deletion packages/mermaid/src/diagrams/venn/vennRenderer.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { draw } from './vennRenderer.js';
import { draw, expandImpliedSubsets } from './vennRenderer.js';
import type { Diagram } from '../../Diagram.js';
import type { VennData } from './vennTypes.js';
import * as configModule from '../../config.js';

const createDiagram = (overrides: Partial<Record<string, unknown>> = {}) => {
Expand Down Expand Up @@ -163,4 +164,140 @@ describe('vennRenderer', () => {
const debugCircle = document.querySelector('.venn-text-debug-circle');
expect(debugCircle).not.toBeNull();
});

it('renders an overlapping layout for a bare 3-way union (issue #7656)', async () => {
document.body.innerHTML = '<svg id="venn"></svg>';
const diagram = createDiagram({
getSubsetData: () => [
{ sets: ['A'], size: 10, label: undefined },
{ sets: ['B'], size: 10, label: undefined },
{ sets: ['C'], size: 10, label: undefined },
{ sets: ['A', 'B', 'C'], size: 1, label: 'Innovation' },
],
});

await draw('', 'venn', '1.0', diagram);

// The label being present is necessary but not sufficient: venn.js emits a
// `.venn-intersection text` element for any declared labeled union, even
// when the circles do not actually overlap.
const intersectionLabels = [...document.querySelectorAll('.venn-intersection text')].map(
(el) => el.textContent
);
expect(intersectionLabels).toContain('Innovation');

// What the fix actually guarantees is that the layout produces overlapping
// circles, which is visible in two complementary ways in the DOM:
// 1. venn.js renders the three implied pairwise intersections, in addition
// to the user-declared 3-way intersection.
// 2. The 3-way intersection's SVG path is a real region, not the
// degenerate `"M 0 0"` placeholder venn.js emits when an area has no
// visible geometry on screen.
const intersections = [...document.querySelectorAll('.venn-intersection')];
const setsByPath = new Map<string, string | null>();
for (const node of intersections) {
const data = (node as unknown as { __data__?: { sets?: string[] } }).__data__;
const sets = data?.sets ?? [];
if (sets.length >= 2) {
const key = [...sets].sort().join('|');
setsByPath.set(key, node.querySelector('path')?.getAttribute('d') ?? null);
}
}
expect([...setsByPath.keys()].sort()).toEqual(['A|B', 'A|B|C', 'A|C', 'B|C']);
const threeWayPath = setsByPath.get('A|B|C');
expect(threeWayPath).toBeTruthy();
expect(threeWayPath).not.toBe('M 0 0');
expect((threeWayPath ?? '').length).toBeGreaterThan(20);
});
});

describe('expandImpliedSubsets', () => {
it('returns input unchanged when only singleton and pairwise subsets are present', () => {
const input: VennData[] = [
{ sets: ['A'], size: 10, label: undefined },
{ sets: ['B'], size: 10, label: undefined },
{ sets: ['A', 'B'], size: 2.5, label: 'AB' },
];
expect(expandImpliedSubsets(input)).toBe(input);
});

it('returns input unchanged when 3-way union has all pairwise unions declared', () => {
const input: VennData[] = [
{ sets: ['A'], size: 10, label: undefined },
{ sets: ['B'], size: 10, label: undefined },
{ sets: ['C'], size: 10, label: undefined },
{ sets: ['A', 'B'], size: 2.5, label: undefined },
{ sets: ['A', 'C'], size: 2.5, label: undefined },
{ sets: ['B', 'C'], size: 2.5, label: undefined },
{ sets: ['A', 'B', 'C'], size: 1, label: 'ABC' },
];
const result = expandImpliedSubsets(input);
expect(result).toHaveLength(input.length);
});

it('synthesizes missing pairwise subsets for a bare 3-way union', () => {
const input: VennData[] = [
{ sets: ['A'], size: 10, label: undefined },
{ sets: ['B'], size: 10, label: undefined },
{ sets: ['C'], size: 10, label: undefined },
{ sets: ['A', 'B', 'C'], size: 1, label: 'Innovation' },
];
const result = expandImpliedSubsets(input);
const pairKeys = result
.filter((entry) => entry.sets.length === 2)
.map((entry) => entry.sets.join('|'))
.sort();
expect(pairKeys).toEqual(['A|B', 'A|C', 'B|C']);
});

it('preserves user-declared pairwise subsets and only fills in missing ones', () => {
const input: VennData[] = [
{ sets: ['A'], size: 10, label: undefined },
{ sets: ['B'], size: 10, label: undefined },
{ sets: ['C'], size: 10, label: undefined },
{ sets: ['A', 'B'], size: 5, label: 'AB' },
{ sets: ['A', 'B', 'C'], size: 1, label: 'ABC' },
];
const result = expandImpliedSubsets(input);

const ab = result.find((entry) => entry.sets.join('|') === 'A|B');
expect(ab).toEqual({ sets: ['A', 'B'], size: 5, label: 'AB' });

const synthesized = result
.filter((entry) => entry.label === undefined && entry.sets.length === 2)
.map((entry) => entry.sets.join('|'))
.sort();
expect(synthesized).toEqual(['A|C', 'B|C']);
});

it('synthesizes C(N,2) pairs for a bare 4-way union', () => {
const input: VennData[] = [
{ sets: ['A'], size: 10, label: undefined },
{ sets: ['B'], size: 10, label: undefined },
{ sets: ['C'], size: 10, label: undefined },
{ sets: ['D'], size: 10, label: undefined },
{ sets: ['A', 'B', 'C', 'D'], size: 1, label: 'AllFour' },
];
const result = expandImpliedSubsets(input);
const pairKeys = result
.filter((entry) => entry.sets.length === 2)
.map((entry) => entry.sets.join('|'))
.sort();
expect(pairKeys).toEqual(['A|B', 'A|C', 'A|D', 'B|C', 'B|D', 'C|D']);
});

it('synthesized pairs have a default size and undefined label', () => {
const input: VennData[] = [
{ sets: ['A'], size: 10, label: undefined },
{ sets: ['B'], size: 10, label: undefined },
{ sets: ['C'], size: 10, label: undefined },
{ sets: ['A', 'B', 'C'], size: 1, label: undefined },
];
const result = expandImpliedSubsets(input);
const synthesized = result.filter((entry) => entry.sets.length === 2);
for (const entry of synthesized) {
expect(entry.label).toBeUndefined();
expect(entry.size).toBeGreaterThan(0);
}
});
});
38 changes: 37 additions & 1 deletion packages/mermaid/src/diagrams/venn/vennRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,42 @@ function buildStyleByKey(styleData: VennStyleData[]): Map<string, Record<string,
return map;
}

// venn.js relies on pairwise intersection sizes to lay out overlapping circles.
// When the user declares only a higher-arity union (e.g. `union A,B,C[label]`)
// without the underlying pairwise unions, the layout has no overlap constraints
// and renders the circles disjointly. We synthesize the missing pairwise
// subsets here so the layout produces the expected overlapping diagram.
// User-declared subsets are preserved verbatim.
//
// Synthesized pairs use the same default size that `vennDB.addSubsetData`
// would have produced for an undeclared 2-way union, so a bare 3-way union
// renders identically to one declared with all three pairwise unions left
// at their defaults. Keep this expression in sync with vennDB.
const defaultSubsetSize = (arity: number) => 10 / Math.pow(arity, 2);

export function expandImpliedSubsets(subsets: VennData[]): VennData[] {
const existingKeys = new Set(subsets.map((s) => s.sets.join('|')));
const implied: VennData[] = [];
const defaultPairSize = defaultSubsetSize(2);
for (const subset of subsets) {
if (subset.sets.length < 3) {
continue;
}
for (let i = 0; i < subset.sets.length; i++) {
for (let j = i + 1; j < subset.sets.length; j++) {
const pair = [subset.sets[i], subset.sets[j]].sort();
const key = pair.join('|');
if (existingKeys.has(key)) {
continue;
}
existingKeys.add(key);
implied.push({ sets: pair, size: defaultPairSize, label: undefined });
}
}
}
return implied.length > 0 ? [...subsets, ...implied] : subsets;
}

export const draw: DrawDefinition = (
_text: string,
id: string,
Expand All @@ -49,7 +85,7 @@ export const draw: DrawDefinition = (
themeVariables.venn8,
].filter(Boolean);
const title = db.getDiagramTitle?.();
const sets = db.getSubsetData();
const sets = expandImpliedSubsets(db.getSubsetData());
const textNodes = db.getTextData();
const styleByKey = buildStyleByKey(db.getStyleData());

Expand Down
14 changes: 14 additions & 0 deletions packages/mermaid/src/docs/syntax/venn.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,20 @@ venn-beta
union A,B["AB"]
```

### Higher-arity unions

`union` accepts three or more set names. The diagram renders the implied
pairwise overlaps automatically, so the label on the higher-arity union has a
visible region to sit in:

```mermaid-example
venn-beta
set Desirable
set Feasible
set Viable
union Desirable,Feasible,Viable["Innovation"]
```

### Sizes

Use `:N` suffix to set the size of a set or union:
Expand Down
Loading