Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
9 changes: 9 additions & 0 deletions .changeset/fix-venn-3-circle-union.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'mermaid': patch
---

fix: fix 3-circle venn diagram union rendering

Venn diagrams with three sets and a triple union (without explicit pairwise unions) now render correctly. The layout engine receives enough pairwise overlap information to place the shared intersection properly.

This resolves the issue where a single 3-set union would fail to display the central intersection area correctly.
28 changes: 27 additions & 1 deletion cypress/integration/rendering/venn/venn.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,33 @@ describe('Venn Diagram', () => {
);
});

it('16: should render a handDrawn venn with custom styles and text nodes', () => {
it('16: should render a venn diagram with a 3-set union without explicit pairwise subsets', () => {
imgSnapshotTest(
`venn-beta
title Innovation
set Desirable
set Feasible
set Viable
union Desirable,Feasible,Viable["Innovation"]
`
);
});

it('17: should render a venn diagram with partial pairwise subsets', () => {
imgSnapshotTest(
`venn-beta
title Partial Pairwise
set A
set B
set C
union A,B,C["ABC"]
union A,B["AB"]
union B,C["BC"]
`
);
});

it('18: should render a handDrawn venn with custom styles and text nodes', () => {
imgSnapshotTest(
`venn-beta
set A
Expand Down
89 changes: 87 additions & 2 deletions packages/mermaid/src/diagrams/venn/vennRenderer.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest';
import { draw } from './vennRenderer.js';
import { draw, ensurePairwiseSubsets } from './vennRenderer.js';
import type { Diagram } from '../../Diagram.js';
import * as configModule from '../../config.js';

Expand Down Expand Up @@ -130,7 +130,7 @@ describe('vennRenderer', () => {
textColor: '#cccccc',
primaryTextColor: '#cccccc',
},
} as ReturnType<typeof configModule.getConfig>);
});

const diagram = createDiagram();
await draw('', 'venn', '1.0', diagram);
Expand Down Expand Up @@ -163,4 +163,89 @@ describe('vennRenderer', () => {
const debugCircle = document.querySelector('.venn-text-debug-circle');
expect(debugCircle).not.toBeNull();
});

describe('ensurePairwiseSubsets', () => {
it('returns the same reference for empty array', () => {
const result = ensurePairwiseSubsets([]);
expect(result).toBe(result);
});

it('returns the same reference when no 3+-set unions exist', () => {
const subsets = [
{ sets: ['A'], size: 10, label: 'A' },
{ sets: ['B'], size: 10, label: 'B' },
{ sets: ['A', 'B'], size: 2.5, label: 'AB' },
];
const result = ensurePairwiseSubsets(subsets);
expect(result).toBe(subsets);
});

it('adds pairwise subsets for a 3-set union', () => {
const subsets = [
{ sets: ['A'], size: 10, label: 'A' },
{ sets: ['B'], size: 10, label: 'B' },
{ sets: ['C'], size: 10, label: 'C' },
{ sets: ['A', 'B', 'C'], size: 5, label: 'ABC' },
];
const result = ensurePairwiseSubsets(subsets);
expect(result).not.toBe(subsets);
expect(result).toHaveLength(7);
// Check that the three pairwise unions were added
const pairs = result.filter((s) => s.sets.length === 2);
expect(pairs).toHaveLength(3);
const pairKeys = pairs.map((p) => p.sets.join('|')).sort();
expect(pairKeys).toEqual(['A|B', 'A|C', 'B|C']);
// Verify sizes are 1/4 of smaller set size (10/4 = 2.5)
const pairSizes = pairs.map((p) => p.size).sort();
expect(pairSizes).toEqual([2.5, 2.5, 2.5]);
});

it('handles partial pairwise coverage: adds only missing pairs', () => {
const subsets = [
{ sets: ['A'], size: 10, label: 'A' },
{ sets: ['B'], size: 10, label: 'B' },
{ sets: ['C'], size: 10, label: 'C' },
{ sets: ['A', 'B', 'C'], size: 5, label: 'ABC' },
{ sets: ['A', 'B'], size: 2.5, label: 'AB' },
{ sets: ['B', 'C'], size: 2.5, label: 'BC' },
];
const result = ensurePairwiseSubsets(subsets);
expect(result).not.toBe(subsets);
expect(result).toHaveLength(7);
// Should have added exactly one missing pair: A|C
const acPair = result.find(
(s) => s.sets.length === 2 && s.sets.includes('A') && s.sets.includes('C')
);
expect(acPair).toBeDefined();
expect(acPair?.size).toBe(2.5);
});

it('handles sets out of alphabetical order', () => {
const subsets = [
{ sets: ['A'], size: 10, label: 'A' },
{ sets: ['B'], size: 10, label: 'B' },
{ sets: ['C'], size: 10, label: 'C' },
{ sets: ['B', 'A', 'C'], size: 5, label: 'ABC' }, // out of order
];
const result = ensurePairwiseSubsets(subsets);
expect(result).not.toBe(subsets);
expect(result).toHaveLength(7);
// Should add pairs A|B, A|C, B|C (sorted internally)
const pairKeys = result
.filter((s) => s.sets.length === 2)
.map((p) => p.sets.join('|'))
.sort();
expect(pairKeys).toEqual(['A|B', 'A|C', 'B|C']);
});

it('falls back to default size when individual set sizes are unknown', () => {
const subsets = [{ sets: ['A', 'B', 'C'], size: 5, label: 'ABC' }];
const result = ensurePairwiseSubsets(subsets);
expect(result).not.toBe(subsets);
expect(result).toHaveLength(4);
const pairs = result.filter((s) => s.sets.length === 2);
const pairSizes = pairs.map((p) => p.size);
expect(pairSizes).toEqual([2.5, 2.5, 2.5]);
});
});
});
72 changes: 70 additions & 2 deletions packages/mermaid/src/diagrams/venn/vennRenderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ export const draw: DrawDefinition = (
const textNodes = db.getTextData();
const styleByKey = buildStyleByKey(db.getStyleData());

// For layout purposes, ensure all pairwise subsets exist for any N-set union (N >= 3).
// The venn.js layout algorithm requires explicit pairwise intersection entries
// to correctly position overlapping circles for 3+ set unions. We add these synthetic
// entries only for rendering — the stored data model (sets) is not mutated so all
// tests that check getSubsetData() continue to pass unchanged.
const renderSets = ensurePairwiseSubsets(sets);

// Configurable viewBox size with scale factor for proportional rendering
const svgWidth = config?.width ?? 800;
const svgHeight = config?.height ?? 450;
Expand Down Expand Up @@ -85,14 +92,14 @@ export const draw: DrawDefinition = (
.VennDiagram()
.width(svgWidth)
.height(svgHeight - titleHeight);
dummyD3root.datum(sets).call(vennDiagram as never);
dummyD3root.datum(renderSets).call(vennDiagram as never);

const roughSvg = isHandDrawn
? rough.svg(dummyD3root.select('svg').node() as SVGSVGElement)
: undefined;

// Compute layout areas so we can position additional text nodes
const layoutAreas = venn.layout(sets, {
const layoutAreas = venn.layout(renderSets, {
width: svgWidth,
height: svgHeight - titleHeight,
padding: config?.padding ?? 15,
Expand Down Expand Up @@ -364,4 +371,65 @@ function renderTextNodes(
}
}

/**
* Ensures that for every N-set union (N \>= 3) in the subset list, all pairwise
* (2-set) intersections are present in the array passed to the layout engine.
*
* The venn.js layout algorithm needs explicit pairwise subset entries to
* correctly overlap circles when three or more sets share a region. Without them,
* `union A,B,C["Innovation"]` renders without the shared centre intersection.
*
* This function returns a *new* array — the original `subsets` from the DB is never
* mutated, so all parser/DB tests that assert on `getSubsetData()` continue to pass.
*
* The default size for pairwise intersections is chosen as 1/4 of the smaller
individual set size. This ratio ensures the overlap is visually distinct but
smaller than either contributing set, maintaining a balanced representation.
When set sizes are unknown, a fallback of 2.5 (the parser's default for 2-set unions)
is used.
*/
export function ensurePairwiseSubsets(subsets: VennData[]): VennData[] {
// Build a set of all existing subset keys (sorted, joined) for fast lookup.
const existingKeys = new Set(subsets.map((s) => [...s.sets].sort().join('|')));

// Pre-compute a map of individual set sizes for O(1) lookup
const individualSetSizes = new Map(
subsets
.filter((s) => s.sets.length === 1 && s.size !== undefined)
.map((s) => [s.sets[0], s.size])
);

const synthetic: VennData[] = [];

for (const subset of subsets) {
if (subset.sets.length < 3) {
continue;
}
// For an N-set union, enumerate all pairs and add any that are missing.
const members = [...subset.sets].sort();
for (let i = 0; i < members.length - 1; i++) {
for (let j = i + 1; j < members.length; j++) {
const pair = [members[i], members[j]];
const key = pair.join('|');
if (!existingKeys.has(key)) {
existingKeys.add(key); // avoid duplicates if multiple N-set unions share pairs
// Use a size that visually represents a meaningful pairwise overlap.
// We default to 1/4 of the smaller of the two individual set sizes,
// falling back to 2.5 (the default for a 2-set union in the parser).
// This ratio was chosen so the pairwise overlap is visible but smaller
// than either contributing set, providing a balanced visual representation
// when the actual size relationship between sets is not specified.
const sizeA = individualSetSizes.get(pair[0]);
const sizeB = individualSetSizes.get(pair[1]);
const pairSize =
sizeA !== undefined && sizeB !== undefined ? Math.min(sizeA, sizeB) / 4 : 2.5;
synthetic.push({ sets: pair, size: pairSize, label: '' });
}
}
}
}

return synthetic.length > 0 ? [...subsets, ...synthetic] : subsets;
}

export const renderer: DiagramRenderer = { draw };
Loading