Beeswarm
A four-state beeswarm that keeps every observation — greedy collision-free placement that never moves a dot along the value axis, grouped rows, hover and keyboard readouts per point, and an out-loud sampling notice instead of a silent cap.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-beeswarm.jsonPrompt
Build a React + TypeScript + Tailwind "ChartBeeswarm" card in plain SVG with
zod. Recharts has no beeswarm primitive and the placement is not a cartesian
series, so the layout, the axis and the label fitting are done by hand in small
pure functions that live beside the schema. There is no force simulation and no
new dependency.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
unit?: string; sample?: { one: string; many: string };
groups: { id: string; label: string;
points: { id: string; label: string; value: number;
meta?: string }[] }[] }.
Points carry an id AND a label, and that is the whole reason this component
exists: the mark on screen is still a row you can name. A density curve or a
hexagon count cannot be un-aggregated back into "which build was that".
- `points` may be empty, and empty is not absent: a category that was measured
and produced nothing keeps its row and says so, because a silently missing
row reads as "nobody asked".
- superRefine: a ready chart needs at least one point somewhere; group ids
unique; point ids unique inside a group (a duplicate would collide as a React
key and as the id aria-activedescendant points at). 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 dotRadius (default 3.5, clamped 1.5-10),
rowHeight (default 88, clamped 48-320), maxPoints (default 400, clamped
24-1000), showMedian (default true), onRetry, onPointSelect, className and
the div's native props, forwardRef to the card.
- Export the maths beside the schema so it is testable and so this prompt can
describe it: quantile(), swarmOffsets(), placeSwarm(), fitSwarm(),
rankSample(), buildBeeswarmModel(), niceStep(), buildScale().
Behavior
- PLACEMENT is the component. Sort by value, walk in ascending x, and for each
dot collect candidate offsets: 0, plus — for every already-placed neighbour
within one diameter on x — the two offsets that put the new dot exactly one
diameter away from it, y_j +/- sqrt(d^2 - dx^2). Try candidates nearest the
baseline first and take the first that collides with nothing, so the swarm
hugs the axis and stays symmetric. Ties break downward, so a run of equal
values stacks the same way in every render.
Termination is guaranteed, not hoped for: the highest candidate is always
free, because it sits at least one diameter above every neighbour that
proposed it.
- X IS NEVER TOUCHED. This is the reason not to reach for d3-force: a force
layout relaxes x as well, so a dot ends up a few pixels from the value it
stands for and the one exact thing a beeswarm has becomes an approximation.
Displacement is perpendicular only, and the legend says so out loud.
- ONE DOT SIZE FOR THE WHOLE CHART. Auto-fit walks a six-rung ladder from the
asked radius down to 1.1px, by binary search because a smaller dot never
needs more room and a layout is the expensive operation here. The chart then
uses the smallest radius any row needed: a mark that changed size from row to
row would read as an encoding of something, and it encodes nothing. Rows that
fit at no size get no vote — shrinking buys them nothing, so they keep the
larger dot and the row grows instead.
- NOTHING IS EVER CLIPPED OR QUIETLY CAPPED, which is the whole promise:
* over maxPoints a row draws a rank-systematic sample — every k-th value of
the sorted sample with both extremes always kept, because "the first N"
would keep the floor and throw the entire right tail away, and outliers are
the point. The sampling is stated in four places: the summary line, the
row's gutter count ("400/900"), the row's accessible label, and the data
table. Medians and extremes always use the whole sample, never a sample of
a sample.
* when a swarm needs more room than rowHeight, the ROW grows and a note names
which rows grew. Never a fixed height with overflow:hidden.
* the cap is a time budget, not taste. Placement is O(n*k) in the dots
sharing one dot-width slice of the axis, so spread readings are nearly free
and readings piled on one value are not: measured, a whole row including
auto-fit is 0.4ms for 400 spread over 714px, 1.9ms for 900 spread, 75ms for
400 all on the same value, 860ms for 1,000 all on the same value.
- The axis is the observed extent plus a 6% margin, deliberately not rounded
outwards (snapping [45,580] up to [0,800] spends 40% of the plot on nothing);
that margin is also what keeps the two extreme dots whole instead of half-cut
by the frame. Ticks on the 1 / 2 / 2.5 / 5 x 10^n ladder, one per ~90px so a
375px card never grows a crowded axis, explicit "en-US" locale, compact
notation past 100,000, and a dashed rule at zero whenever the domain crosses
it. The first and last tick labels anchor to their ends so they cannot spill
out of the frame.
- INTERACTION. Each row owns one transparent hit rect that finds the nearest
dot itself — a 3.5px circle is not a pointer target — converting through that
rect's own client box, which stays correct when the SVG scales down below its
minimum width. Click pins, and the pin survives the pointer leaving.
Keyboard: each row is a role="listbox" with tabIndex 0 and
aria-activedescendant, so there is one tab stop per row instead of one per
dot; Tab lands on the dot FURTHEST FROM THAT ROW'S MEDIAN, because on this
chart that is the finding. Left/Right step by value, Up/Down move to the
neighbouring row and land on the nearest value there, Home/End jump,
Enter/Space pin, Escape releases. preventDefault fires only for keys that
were handled, so Tab still leaves the chart.
- Live input wins in the order hover, keyboard cursor, pin — a pin that
swallowed hover would make every other dot feel dead.
- Four first-class branches of one card: loading (deterministic skeleton
swarms, aria-hidden, plus one sr-only role=status line), empty (a valid
contract with no readings, worded so it cannot be mistaken for a failed
fetch), error (a Try again button only when onRetry was passed), ready. A
ready chart with zero drawable points renders the empty branch.
- CLEANUP: one ResizeObserver, disconnected on unmount and whenever the node
changes. No timers, no rAF, no simulation to stop.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the panel, border,
muted for the skeleton, muted-foreground for axis and gutter text, ring for
the focus outline, var(--chart-1..5) cycling for the dots.
- COLOUR NEVER CARRIES A VALUE. It only separates neighbouring rows, every row
is labelled in the gutter, and the one mark that means a number — the median
tick — is --foreground. Past five groups the ramp repeats, which is harmless
precisely because the label was always the identity. Never use a chart token
as a text colour.
- The median tick is as tall as the swarm it belongs to, not as tall as the
row: a full-height rule beside a single dot reads as an error bar, which is a
different chart. Each dot takes a hairline --card stroke so two touching
neighbours still read as two.
- Gutter labels live in a foreignObject with pointer-events:none (an HTML box
hit-tests its whole rectangle even with no background, so it would otherwise
swallow hovers meant for the row under it). They are line-clamp-2 plus
wrap-anywhere, and the wrap class is load-bearing and measured: without it a
46-character label with no break opportunity runs 207px past a 62px gutter,
and line-clamp only ellipsises vertically, so nothing else catches that.
- ACCESSIBILITY: do NOT put role="img" on the plot — that is
children-presentational and would silence every focusable row. Use
role="group" labelled by the card heading and described by the summary line,
which states the finding in words: how many groups, how many readings,
whether every one is drawn, which reading is the highest and where. Dots are
aria-hidden marks reached through their row's listbox. Below the plot an
sr-only WRAPPER DIV (never sr-only on the table itself: CSS width is only a
lower bound for a table box, so width:1px does not hold one back and a narrow
viewport picks up real horizontal scroll) holds a table of n, dots drawn,
median and the NAMED lowest and highest reading per group — naming the
extremes is the text equivalent of what the chart is for. The visible readout
line is aria-hidden, because the focused row already announces the active dot
and a live region would say it twice.
- Motion: the only animation is the loading skeleton's pulse, carrying
motion-reduce:animate-none. Nothing else moves, so nothing else has to stop.
Customization levers
- dotRadius / rowHeight: the two knobs that set density. 2-2.5 with rowHeight
64 packs a dashboard row; 5-6 with rowHeight 140 makes one swarm the hero of
a slide and makes every dot an easy pointer target. Both are preferences the
auto-fit may lower — pass a small radius directly if it has to be fixed
across several cards.
- maxPoints: raise it when every reading really must be drawn and you accept
the density and the layout cost; drop it to 100-150 for a sparkline-sized
card. Whatever it is, the disclosure follows it automatically — there is no
configuration in which a dot disappears without the card saying so.
- showMedian: turn it off for a pure distribution, or swap the median for
another summary (a mean tick, or a Q1/Q3 pair) — it is one <line> per row
driven by the group model, and the model already holds the sorted values.
- Palette: re-point GROUP_INK to a single token for a monochrome chart, or key
the ink off a status ("passed" / "failed") rather than the row index when
colour should mean something; keep the median tick on --foreground either
way.
- Axis: a fixed domain makes two cards comparable — add it as a prop, and then
decide out loud what happens to readings outside it (count and name them,
never clamp, or a dot would claim a value nothing was ever measured at).
TICK_PITCH trades tick density against crowding at 375px.
- Interaction: onPointSelect already carries the whole point object — wire it
to a drill-down, a trace link or a linked table. The row's hit rect is where
a double-click or a context menu goes without touching the geometry.Concepts
- Collision-free placement — the layout walks the sample in axis order and puts each dot at the offset closest to the baseline that no already-placed dot occupies, using the exact geometry
y_j ± √(d² − Δx²). It is greedy and finishes in one pass: there is nothing to relax, nothing to tick and nothing to cancel on unmount. The highest candidate is always collision-free, so the loop can never fail to place a dot. - Exact on the axis — displacement is perpendicular only. That is the reason this is not a force layout: a simulation nudges x as well, and a dot a few pixels off its own value quietly turns the one precise thing a beeswarm has into an estimate. Measured against an axis reconstructed from the rendered tick labels, every dot lands within 0.000px of its value.
- Every observation kept — a violin replaces the sample with a curve and a hexbin replaces it with counts; both answer "what shape is this distribution" and neither can answer "which one was that". Keeping the marks is what makes an outlier something you can hover, name and drill into, and what stops a group of twelve from being drawn as a smooth shape it never had.
- Disclosed sampling — past the cap a row draws every k-th value of the sorted sample with both extremes kept, and the gutter count changes from
900to400/900. The rule is that the number of marks on screen always equals the number the card claims: either all of them, or a stated fraction of them. A silent cap would make the chart lie about precisely the thing it exists to show. - Grow, never clip — a fixed row height plus
overflow:hiddenis the other way to lose observations, so a swarm that outgrows its row makes the row taller and the card names which rows grew. Forty readings on one value become a forty-dot column you can count, not a smudge that stops at the row edge. - One dot size per chart — the auto-fit shrinks the dot until every row that can fit does, then uses that one size everywhere, because a mark that changed size between rows would read as an encoding. Rows that fit at no size are excluded from the vote: they grow either way, so letting them shrink everyone else would be a loss for nothing.
- Row as listbox — one tab stop per row rather than one per dot, with
aria-activedescendantwalking the announcement through the readings while focus stays on the row. Tab lands on the dot furthest from that row's median, because on a chart built for outliers that is the finding, and nobody should have to arrow through four hundred dots to reach it.
Small Multiples
A four-state trellis grid — one repeated mark per category, every panel on one shared y scale with a free-scale toggle that states its cost, axis labels only on the edge panels, and a measured column count that wraps rather than shrinking cells past legibility.
Lollipop Chart
A four-state lollipop chart — one hairline and one dot per category on a shared scale, ranked, with an optional reference baseline and a second dimension carried by dot size or shade.