Venn Diagram
An area-proportional Venn for two or three sets that measures what it drew and falls back to a labelled region table when circles cannot tell the truth.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-venn.jsonPrompt
Build a React + TypeScript + Tailwind "ChartVenn" card — an area-proportional
Venn diagram for two or three sets, drawn as hand-computed SVG (no chart
library), with zod for the contract and lucide-react for two state icons.
Contract
- One zod schema is the source of truth and the props are its z.infer plus a
few render knobs. Never hand-write a parallel interface.
{ status: "loading" | "empty" | "error" | "ready";
title: string; caption?: string; unit?: string;
sets: { key: string; label: string; tag?: string (1-4 chars) }[];
regions: { keys: string[]; value: number >= 0 }[] }
- REGIONS ARE DISJOINT. `keys: ["a","b"]` means "in A and B and in no other
set", not "the size of A intersect B". This is the one modelling decision
everything else rests on: every non-negative assignment of disjoint counts
describes a real family of sets, while inclusive sizes can contradict each
other (|A and B| larger than |A|) and the chart would have to guess which
number was wrong. Set sizes and pairwise overlaps are derived by summing.
- Values are non-negative at the schema level: a region is a headcount and
there is no circle with negative area.
- The schema refuses, at parse time, what cannot be drawn or read: fewer than
two or more than three sets, duplicate set keys, a region naming an unknown
set, a region listing the same set twice, and the same combination listed
twice (regions are disjoint, so the caller must sum them first).
- Extra props: tolerance (default 0.03, clamped 0-0.5), sampleResolution
(default 320, clamped 64-640), layout: "auto" | "table", locale (default
"en-US"), onRegionSelect, onRetry, emptyState, className, and the rest of
the div's native props spread on the root. forwardRef to that root.
- Ship the geometry as pure functions beside the schema — lensArea(),
solveCircleDistance(), describeRegion(), buildVennModel() — so the layout,
the audit and the sentences can be unit-tested without React.
Behavior
- LAYOUT, in four steps, each of them exact where exactness is available:
1. Drop every set with no members. A circle of area zero has no radius and
no place to stand; if fewer than two sets survive, refuse and say which
ones were empty. Dropping is lossless — if a set has no members then
every region containing it is zero by definition.
2. Radius from the count: pi * r^2 = |set|, so area IS the quantity.
3. Distance per pair by bisection on the circular lens area
r1^2*acos((d^2+r1^2-r2^2)/(2*d*r1)) + r2^2*acos((d^2+r2^2-r1^2)/(2*d*r2))
- 0.5*sqrt((-d+r1+r2)(d+r1-r2)(d-r1+r2)(d+r1+r2)),
answering both degenerate ends directly rather than through the formula
(which divides by d): d <= |r1-r2| is one circle inside the other, area
pi*min(r)^2; d >= r1+r2 is no overlap at all. The lens falls
monotonically between those ends, so 80 bisection steps land on the one
distance that fits — and because a shared count can never exceed the
smaller set, a TWO-set Venn is always exactly right, whatever the
numbers.
4. Three circles: place A at the origin, B at (d_AB, 0), C at the
intersection of the two remaining distances. If those three distances
break the triangle inequality, clamp the longest and flag it; if d_AB is
zero (identical sets) the base has no direction, so put C on the axis at
the distance the other two agree on.
- THE AUDIT IS THE POINT. Nothing about three circles guarantees the seven
regions come out right; most three-set data has no circular Venn at all.
So measure what was actually drawn: walk a regular grid over the bounding
box once, and for each sample point derive (a) which region it is in, from
set membership, and (b) its distance to that region's boundary, which is
min |distance to centre - radius| over the circles, because every boundary
of every region is an arc of one of them. That single deterministic pass
yields both the drawn area of each region and its pole of inaccessibility —
the deepest interior point, i.e. the only place a label can sit.
- Compare each region's drawn share of the picture against its true share and
refuse the circles on any of four counts, ranked so the message explains the
cause rather than the first symptom in mask order:
- phantom: the region holds nothing yet is drawn at more than 0.4% (the
classic case: every pair overlaps heavily but nobody is in all three —
circles honouring the three pairwise overlaps always leave a middle,
measured at 14.7% of the picture for a region that is empty);
- inflated: drawn at 1.5% or more AND more than 2.5x its true share;
- vanished: worth 2% or more and drawn at under 40% of that;
- drift: |drawn share - true share| past `tolerance`.
On refusal, render the labelled region table instead and say in one sentence
which region would have lied and by how much. Do NOT fall back to three
fixed circles: a Venn that is "near enough" is read as exact by everyone who
looks at it, and the fixed-circle version is a picture of nothing.
- When the circles ARE drawn, still print the largest gap between a drawn
share and a true share in the footnote. Passing an audit silently is how the
next dataset gets trusted for the wrong reason.
- The four states are first-class branches of one bg-card panel: a skeleton of
two overlapping pulsing circles (aria-hidden, with an sr-only status line),
an empty state, an error state with a "Try again" button only when onRetry
exists, and ready. `ready` with no members at all is routed to the zero
state with its own sentence — "sets are listed, but not one has a member" is
a different fact from "we have nothing".
- INTERACTION: hovering or arrowing to a region isolates it. Hit testing is
arithmetic, not DOM: a region is an intersection of discs minus discs, which
no single SVG shape hit-tests, so convert the pointer through
getScreenCTM().inverse() and rebuild the membership bitmask from three
distance comparisons. Pointer beats keyboard beats the pin, so whichever the
reader moved last wins.
- KEYBOARD: the svg is a single tab stop with role="listbox" and one
role="option" group per region, addressed with aria-activedescendant. Arrow
keys in all four directions walk the regions in reading order (singles, then
pairs, then the middle), Home / End jump to the ends, Enter or Space pins a
region and reports it through onRegionSelect, Escape drops the pin. Regions
that hold nothing are part of the walk: "nobody is in all three" is a
finding, not a gap.
- CLEANUP: there is nothing to cancel — no timers, no rAF, no observers, no
window listeners. The layout is scale-free, so it is solved once in view
units and handed to a viewBox; measuring the DOM would buy nothing and cost
a subscription. Keep it that way.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground on the panel, border,
bg-muted for the skeleton and the active table row, text-muted-foreground
for secondary text, text-destructive for the error icon, ring for focus, and
var(--chart-1..5) cycling for the sets. No hex, no rgb(), no oklch().
- Fills are color-mix(in oklab, var(--chart-N) 30%, transparent), so overlaps
darken naturally by stacking alpha and the deepest ink is the region shared
by the most sets. Rings are the same token at full strength.
- COLOUR IS NEVER THE ONLY CHANNEL. Each set also gets a dash pattern from a
cycling table ([], [7,4], [2,3], [11,3,2,3], [1,4]) on its ring — the ring
is the one part of a circle no other circle covers — plus a 1-4 character
tag painted just outside it, a legend row that repeats the tag as a key, and
a name in every sentence. Three translucent fills of similar weight are
exactly what greyscale printing and red-green vision cannot separate.
- LABEL FIT, from the same clearance the audit produced: the clearance is the
radius of the largest circle that fits inside the region at that point, so a
label fits when its own half-diagonal is no larger. Estimate the string's
width at 0.62 * fontSize per character, rounded UP from the real average,
because the cheap error is dropping a label that would just have fitted
(the number survives in the readout and the table) while the expensive one
is painting a count across the arc it should sit inside. Three tiers: count
plus share, count only, nothing. That makes the test adapt to the actual
string, so a six-digit count drops out of a lens a two-digit one sits in.
- Text carries a var(--card) halo via paint-order:stroke, the SVG equivalent
of a knock-out, so a count stays readable over any fill it lands on.
- ISOLATION WITHOUT REGION PATHS: build the region as a mask instead. An
intersection comes from chaining clipPaths — a clipPath whose child circle
is itself clipped by the previous one — since overlapping shapes union for
free but never intersect. Then one mask paints black, paints white through
that intersection, and paints the excluded circles black again (safe:
outside the intersection they are already black). The complement mask is the
same three primitives with the colours swapped, which avoids nesting a mask
inside a mask. Lay a var(--card) scrim at 0.62 through the complement and a
var(--foreground) wash at 0.12 through the region, both UNDER the rings, so
the structure never fades; fade the other regions' counts with them.
- The whole drawing is solved in view units and shipped as
viewBox="0 0 W H" with preserveAspectRatio="xMidYMid meet", h-auto w-full
and maxWidth: W, so it is responsive with no measuring pass and cannot
collapse to zero height in a flex parent. W and H come from the content, not
from a fixed square, so a wide two-circle layout wastes no vertical space.
Reserve 46 units of padding: a 4-character tag at 11px is 27 units wide and
sits 12 units outside the ring, so the tags provably fit.
- ACCESSIBILITY: an sr-only paragraph states the finding in words (totals per
set, the largest shared region, and either the worst drift or the refusal),
the listbox has its own name, each option carries a full sentence as
aria-label and as an SVG <title>, and a visible region table lists every
combination with its count and share. That table is the same DOM in both
modes: companion to the diagram, or the entire answer when the circles are
refused. The hover readout line is aria-hidden — the focused option already
announces the same sentence and a live region would say it twice.
- Every combination is listed, including empty ones, and the table foots with
the union. Regions are disjoint, so the column adds up.
Customization levers
- tolerance: raise toward 0.06-0.08 for exploratory dashboards where a
roughly-right picture beats a table; drop to 0.01 for reporting, where all
but the friendliest three-set data will (correctly) fall back to numbers.
It never changes a number, only whether circles are allowed to carry them.
- layout="table" pins the numbers-only rendering for dense boards, or when
seven regions have to line up with another table. The geometry is skipped
entirely, not hidden.
- sampleResolution trades audit and label precision against work: the pass is
O(resolution^2 * sets). 320 is about 100k points and runs once per data
change; 96 is fine for sparklines-sized cards.
- Palette: re-point the var(--chart-N) cycle, or swap the fill percentage
(30%) to make overlaps read heavier or lighter. Change the dash table to
match a house style, but do not empty it — that is the colour-blind channel.
- Density: font sizes and the 0.62 character ratio decide how much fits inside
a lens; drop the share line for a compact card, or raise VIEW_SPAN for a
hero-sized diagram. The tag lane (12 units out, 46 units of padding) is the
only geometry that has to move with the tag length.
- Interaction: onRegionSelect gives you the region's keys, so a click can
drill into exactly that cohort. Pin styling, the scrim strength and the
wash strength are three independent numbers if isolation needs to be louder
or quieter.Concepts
- Disjoint region counts — the contract asks for "in A and B and nothing else", not "the size of A intersect B". Any non-negative set of disjoint counts describes a real family of sets, so the payload cannot contradict itself; sizes and pairwise overlaps are sums the chart takes, never guesses.
- Exact pairwise solve — a circular lens shrinks monotonically as two circles are pulled apart, so one bisection finds the distance that makes the shared area exactly the shared count. That is why two sets are always drawn perfectly and three sets only sometimes: the third circle has to satisfy three distances at once, and the middle is left to fall where it falls.
- Honesty audit — the drawing is measured, not assumed. Each region's drawn share is compared against its true share, and four separate failures are ranked so the message explains the cause: a region that holds nothing but gets area, one drawn several times too big, one squeezed to nothing, and plain drift past the budget.
- Table fallback — the refusal is a feature. Most three-set data has no circular Venn, and the alternative on offer everywhere else — three fixed circles with numbers dropped in — is read as proportional by every person who sees it. The region table is the same DOM the diagram already ships as its text alternative, so failing over costs nothing and hides nothing.
- Pole of inaccessibility — the deepest interior point of a region, and the only honest place for its label, since a centroid can fall outside a crescent. It comes free from the audit pass: the distance from any point to its region's boundary is the smallest gap to any circle's edge.
- Clip-chain isolation — overlapping SVG shapes union for free but never intersect, so a region is built by chaining clipPaths and then subtracting the excluded circles in a mask. The complement mask reuses the same three primitives with the colours swapped, which is what lets one region be lit while everything else is washed back toward the card.
Word Cloud
A four-state word cloud that sizes terms by the square root of their count and packs them along a deterministic spiral, with every word a focusable button and a ranked list for screen readers.
Polar Area Chart
A Nightingale rose where every period owns an equal angle and the radius is area-corrected, drawing measured zeros, missing readings and below-zero periods each in their own way.