Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, ArrowDown, ArrowUp, LayoutGrid } from "lucide-react"

import { cn } from "@/lib/utils"
import type {
  ChartUpsetCombination,
  ChartUpsetData,
  ChartUpsetSet,
  ChartUpsetSort,
} from "./chart-upset.contract"

export interface ChartUpsetProps

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/chart-upset.json

Prompt

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.

On This Page