UpSet Plot
A four-state UpSet plot for set overlaps a Venn cannot draw — a dots-and-lines membership matrix under sorted size bars, per-set totals on the left, and disjoint columns that add up instead of double-counting.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-upset.jsonPrompt
Build a React + TypeScript + Tailwind "ChartUpset" card — an UpSet plot in plain
SVG with zod for the contract and lucide-react for three icons. Recharts has no
primitive for this: the picture is three aligned regions (set bars, label gutter,
intersection matrix) sharing one row grid and one column grid, so the layout, the
axis and the label fitting are small pure functions beside the schema. No d3, no
new dependency.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
description?: string; item?: { one: string; many: string };
sets: { id: string; label: string; size?: number }[];
combinations: { id: string; sets: string[]; size: number;
meta?: string }[] }.
- THE SEMANTICS ARE THE WHOLE COMPONENT: a combination counts the items that are
in EXACTLY those sets and no others. Columns are disjoint, so their sizes add
up to the universe, a set's total is the sum of every column naming it, and no
bar can double-count. Inclusive intersections ("A ∩ B" meaning everyone in
both, C or no C) are a different quantity and will make the totals contradict.
Say this on the card, not only in the docs.
- `sets[].size` is optional on purpose. Omitted, the chart sums the combinations;
stated, the difference between the stated total and the summed parts is drawn
as a hatched tail — which is how a "top N combinations" payload admits it is
partial instead of pretending the parts are the whole.
- superRefine: ready needs at least one set and one combination with size > 0;
set ids unique; combination ids unique; every id in `sets[]` must resolve; no
set listed twice inside one combination; no two combinations with the same
membership (normalised to row order, so ["b","a"] and ["a","b"] collide); and
no stated total smaller than the parts naming it. Guard every access — sibling
refinements all run, so a ragged payload has to produce an issue rather than a
TypeError thrown out of safeParse.
- Props = z.infer of the schema plus maxCombinations (default 24, clamped 3-60),
barHeight (132, 60-320), rowHeight (24, 14-44), defaultSort (uncontrolled),
sortable (true), showValues (true | false | "auto", default "auto"), locale
("en-US"), onRetry, onCombinationSelect, className and the div's native props,
forwardRef to the card.
- Export the maths beside the component so a test can print the same numbers the
picture is made of: buildUpsetModel(), selectTopColumns(), sortColumns(),
upsetGeometry(), columnX(), rowCenter(), barLength(), niceTicks().
Behavior
- buildUpsetModel is where honesty happens. Nothing is repaired quietly: a row
naming a set that does not exist, a row naming no set at all, a repeat of a
membership already listed, and a size that is negative or non-finite are each
DROPPED AND COUNTED BY REASON, and the card prints the tally. Guessing at any
of them would falsify the one number an UpSet plot is trusted for. Surviving
columns keep their payload index, so "given order" and every tie-break are
stable across renders.
- Per set the model carries stated / covered / total / remainder plus a
`contradicts` flag. When a stated total is smaller than the parts, the bar
shows the PARTS — those are the numbers the columns were drawn from — and the
card names the set and suggests the likely cause (inclusive intersections).
- THE CAP KEEPS THE LARGEST COLUMNS, NEVER THE FIRST ONES. n sets have 2^n − 1
possible combinations, so six sets can hand you 63 columns; "the first N" would
give the picture away to whatever order the query returned, and ranking is the
point. Ties break toward the lower degree, then payload order. Whatever the
cap does is stated: how many were dropped, how many items they held, and what
share of the total that is. The left bars still count all of them.
- Selection is independent of display order: pick the largest N first, then sort
the survivors by size / degree / given order, each with a direction. Ties in
the display sort fall through to size, then to payload order, so no two renders
disagree.
- LAYOUT. upsetGeometry() resolves the three regions: the label gutter is 22% of
the measured width (88-176px), the set-bar band 15% (52-120px), and the columns
divide what is left, clamped to 18-52px. When the floor wins, the SVG becomes
WIDER THAN ITS CONTAINER and the card scrolls sideways — squeezing 24 columns
into 320px would trade the one thing this chart has, a legible matrix, for the
appearance of fitting. Dot radius is 0.3 × min(rowHeight, columnWidth), clamped
2.5-8. Each column is a <g transform="translate(x,0)"> so every mark inside it
is positioned once, and sorting animates as one transform.
- Bars: length = (size / max) × barHeight with a 2px floor for any non-zero
count, and NO bar at all for a true zero — a measured zero keeps its column and
prints "0", so "none" and "not here" never look alike. Axis ticks on a 1 / 2 /
5 × 10^n ladder (the 2.5 rung is dropped: these are counts, and an axis labelled
2.5 items invites a reading the data cannot support), integral when every size
is, one tick per ~44px.
- Counts are printed above their bars when they fit (measured against the column
width), always when showValues is true, never when it is false — and whenever
they are not printed the card says so and points at the readout and the table.
- INTERACTION. A column is the unit: one <g role="option"> inside a
<g role="listbox">, carrying the bar, the dots, the connector and a transparent
hit rect so every gap between marks still belongs to the column. Hover, focus
and pin all light the same column, in that order of precedence — a pin that
swallowed hover would make every other column feel dead. Click or Enter/Space
pins and fires onCombinationSelect; Escape releases.
- KEYBOARD IS A FIRST PATH, NOT A FALLBACK: roving tabindex (one tab stop for the
whole matrix, moving with focus), ArrowLeft/ArrowRight step columns, Home/End
jump, Enter/Space pin, Escape release. preventDefault fires only for keys that
were handled, so Tab still leaves the chart. Sorting is three real buttons with
aria-pressed, not a hidden drag or a header gesture.
- FOCUS NEVER FALLS TO <body>. Two things can pull the ground out: the focused
column disappearing when the data changes, and the retry button unmounting
because the retry worked. Both restore focus — to the roving column, else to
the (tabIndex -1) heading — and both act only when focus really was lost, so a
reader who has already clicked elsewhere is never yanked back.
- Four first-class branches of one card: loading (deterministic skeleton bars and
dot matrix, aria-hidden, plus one sr-only role=status line), empty (worded so
it cannot be mistaken for a failed fetch, and it still prints the dropped-row
tally — a chart that is empty BECAUSE everything was dropped must say so),
error (Try again only when onRetry was passed), ready. A ready payload with no
drawable column renders the empty branch.
- CLEANUP: one ResizeObserver, disconnected on unmount and whenever the node
changes. No timers, no rAF, no simulation.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the panel, border,
muted for the bands and the skeleton, muted-foreground for axis, gutter and
non-member dots, ring for the focus outline, destructive for the error icon,
var(--chart-1) for intersection bars and var(--chart-2) for set totals.
- NEVER COLOUR ALONE. Membership is carried by ink AND size (a member dot is
full-strength at radius r, a non-member is 28% at 0.5r) AND by the connector
that joins the members of one column; the "not in any listed combination" tail
is an SVG <pattern> hatch, not a paler fill, so it survives greyscale and
colour blindness; the active column is a tinted band plus a bolder count plus
bolder row labels. Every column is also named in words in the readout and in
the sr-only table.
- Set labels live in a foreignObject with truncate + a title attribute. Unlike a
swarm's gutter this box never overlaps a column, so it KEEPS its pointer events
— which is what makes the tooltip reachable when a long name elides.
- ACCESSIBILITY: do NOT put role="img" on the plot — that is
children-presentational and would silence every focusable column. Use
role="group" labelled by the heading and described by the summary line, which
states the finding in words. Each column's aria-label is a sentence: which sets,
how many items, what share, what degree. A polite live region carries only the
two changes focus cannot announce by itself — the order changing under the
reader, and a pin being taken or released — and the visible readout is
aria-hidden so nothing is said twice. Below the plot, an sr-only WRAPPER DIV
(never sr-only on a table: CSS width is only a lower bound for a table box, so
a narrow viewport picks up real horizontal scroll) holds two tables — one row
per column, one row per set with total / listed / not listed.
- Motion: only the loading pulse (motion-reduce:animate-none) and the column
transform transition (motion-safe:), so with animation off the chart is
complete and correct on the first frame.
Customization levers
- barHeight / rowHeight: the density pair. 88 / 18 makes a dashboard tile, 200 /
32 makes one plot the hero of a slide. Both are clamped, and both only change
the picture's proportions — never which columns exist.
- maxCombinations: raise it when the tail matters and you accept a scrollable
matrix; drop it to 8-12 for a summary card. Whatever it is, the disclosure
follows automatically — there is no configuration where a column disappears
without the card saying so.
- defaultSort: ship the card in the order that tells your story — size for
"biggest overlap", degree for "how deep does adoption go", given for an order
computed upstream. Pair with sortable={false} for a static tile.
- showValues: "auto" prints what fits, false moves every number to the readout,
tooltip and table, true forces the print for a wide card.
- Palette: --chart-1 and --chart-2 are the only two hues; point them at one token
for a monochrome card, or key the intersection bar off a threshold (over/under
a target) when colour should mean something — keep membership dots on
--foreground either way, because they are the one mark that must never look
like a value.
- Semantics to extend: a "degree ≤ n" filter, a second bar band for a per-column
metric (revenue as well as count), or an "empty set" column for items in none
of the sets — the model already carries payload order and per-column shares, so
each is a slice of the same data rather than a new geometry.
- Interaction: onCombinationSelect hands over the whole column model (ids,
labels, size, share, degree) — wire it to a drill-down, a saved segment, or a
linked table. The column hit rect is where a double-click or a context menu
goes without touching the geometry.Concepts
- Disjoint columns — a column counts the items that are in exactly those sets and no others, so every item is counted once and the columns add up to the universe. That is what lets a set's left-hand total be the sum of every column with a dot on its row, and it is the difference between this chart and a pile of pairwise overlaps that silently double-count the people in all three.
- Membership matrix — the label under the bar is not text, it is the dot column: filled dots on the rows that belong, joined by a line so the column reads as one set expression rather than as unrelated marks. It scales to eight or ten sets, where a Venn stops being drawable at four (fifteen regions, no arrangement of circles that produces them).
- Degree — how many sets meet in a column. Sorting by size answers "which overlap is biggest"; sorting by degree answers "how deep does adoption go", grouping the singles, then the pairs, then the one column where everything meets. They are different questions about the same numbers, which is why the order is a first-class control rather than a fixed choice.
- Largest-N, disclosed — n sets have 2ⁿ − 1 possible columns, so a cap is not optional. The cap keeps the largest, never the first, and the card states how many columns it dropped, how many items they held, and what share of the total that is. A silent cap would make the chart lie about the exact thing it exists to rank.
- Parts against the stated total — when the payload states a set's own size, the gap between that and the columns naming it is drawn as a hatched tail: this much of the set is in combinations nobody listed. When the parts exceed the stated total the card says so instead of picking a winner — the usual cause is inclusive intersections arriving where disjoint buckets were promised.
- Column as one tab stop — the whole matrix is a listbox with a roving tab stop, so reaching the twelfth column is eleven arrow presses rather than eleven tab stops, and pinning is Enter rather than a click. Ordering is three real buttons, so nothing the pointer can do is unreachable without one.
Calibration Curve
A four-state reliability diagram — binned predicted probability against observed frequency, read off the diagonal, with Wilson intervals, a shared-axis count strip and every bin reachable from the keyboard.
ECDF
A four-state empirical CDF that bins nothing — one step curve per group on a shared percent axis, percentiles read off the steps themselves, named crossings between curves, a keyboard-driven scan line and a vertex cap that states its own error.