Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { AlertCircle, CircleDashed } from "lucide-react"

import { cn } from "@/lib/utils"
import {
  buildVennModel,
  type ChartVennData,
  type VennCircle,
  type VennRegionSummary,
} from "./chart-venn.contract"

export interface ChartVennProps

Installation

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

Prompt

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.

On This Page