PCA Biplot
A four-state PCA biplot that draws observation scores and variable loading arrows on one equal-aspect plane, puts explained variance in the axis titles, and states the arrow scale factor instead of hiding it.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-biplot.jsonPrompt
Build a React + TypeScript + Tailwind "ChartBiplot" card in plain SVG with zod.
Recharts has no biplot: a biplot needs an equal-aspect domain (which no
cartesian chart library will constrain for you), a second mark family drawn
from the origin on its own scale, and collision-aware direct labels. So the
layout, the axis and the label fitting are done by hand in small pure functions
that live beside the schema, and there is no new dependency.
This component PLOTS a PCA, it does not COMPUTE one. Scores and loadings come
in already fitted, from scikit-learn (`transform()` + `components_.T`), R
(`prcomp()$x` + `$rotation`) or anywhere else. Refusing to fit means the card
never has to guess at centring, scaling or sign conventions it was not told
about.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
axes: { x: { label: string; explained: number },
y: { label: string; explained: number } };
scores: { id, label, x, y, group?, meta? }[];
loadings: { id, label, x, y, meta? }[];
groups?: { id, label }[];
observation?: { one: string; many: string } }
- `explained` is a FRACTION of total variance, 0-1, and it is required: without
it a biplot is two unnamed axes and the reader cannot tell whether the plane
is most of the data or a rounding error. Every PCA implementation hands it
back, so asking costs the caller nothing.
- Scores and loadings may each be empty and neither is an error: loadings-only
is a loading plot, scores-only is a score plot, and the card names which one
it is drawing instead of implying the other half failed to arrive.
- superRefine: a ready chart needs at least one mark; ids unique within scores,
within loadings and within groups (a duplicate would collide as a React key
and as the id aria-activedescendant points at); a score's `group` must be
declared; y.explained must not exceed x.explained (components come out of a
PCA sorted by variance — if the second is larger the axes were swapped
upstream and every reading of the picture is wrong); the two shares must not
sum above 1 + 1e-9 (the epsilon because upstream rounding is arithmetic, not
a mistake). 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 marks ("both" | "scores" | "loadings",
default "both"), arrowFill (default 0.82, clamped 0.4-0.98), pointRadius
(default 4, clamped 2-9), labelledScores (default 6, clamped 0-40),
showUnitCircle (default false), onRetry, onSelect, 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: arrowScale(), biplotExtent(), equalAspectDomain(), niceStep(),
biplotTicks(), placeLabels(), buildBiplotModel(), markPath(),
arrowHeadPath(), compassOf(), elide().
Behavior
- EQUAL ASPECT IS A CORRECTNESS RULE, NOT TASTE. PC1 and PC2 are measured in
the same units, so the distance between two points only means anything when
one unit is the same number of pixels on both axes. Stretching a biplot to
fill its box is the single most common way to break one: the cloud grows a
diagonal structure the data never had. So widen the DOMAIN, never squeeze the
pixels — expand each axis about its own centre until units-per-pixel agree,
which also keeps the origin (the mean of the data, and the tail of every
arrow) from drifting. Hand BOTH axes the same tick step afterwards: the grid
then comes out of square cells, so a reader can see the aspect is honest
instead of taking the caption's word for it.
- ARROW SCALING IS UNIFORM AND STATED. Loadings and scores live in different
units, so arrows are drawn at k = arrowFill * maxScoreRadius /
maxLoadingRadius — one factor, applied to both coordinates of every arrow.
Radius-based rather than per-axis (R's biplot() matches ranges axis by axis)
because a per-axis stretch changes the ANGLE between two arrows, and that
angle is the one thing arrows mean: same direction = correlated, right angle
= unrelated, opposite = trading off. The card prints k, and the ticks stay in
score units, so nobody reads an arrow's length as a loading. k falls back to
1 when there are no scores to fit against, and to 0 when no arrow has any
length at all.
- FOUR FIRST-CLASS BRANCHES of one card: loading (a deterministic skeleton
cloud and four skeleton arrows, aria-hidden, plus one sr-only role=status
line), empty (a valid contract with nothing to project — worded so it cannot
be mistaken for a failed fetch, and stating that this chart plots a PCA
rather than computing one), error (a Try again button only when onRetry was
passed), ready. A ready chart with nothing drawable renders the empty branch.
- NOTHING IS DROPPED QUIETLY. Rows whose coordinates are not both finite are
filtered and COUNTED in a visible note. A variable that loads on neither
component keeps its option, is drawn as a small ring at the centre, and the
card says this plane has nothing to say about it — a reading, not a gap. A
score pointing at an undeclared group gets its own "Unassigned" legend entry
rather than being folded into the first one.
- THE CARD ARGUES WITH ITS OWN DATA when the numbers deserve it: under 50%
total explained variance it warns that two observations looking close may be
far apart; under 1% on the second component it says the plane is very nearly
a line and vertical distance is noise; shares that sum above 1 are left OFF
the axis titles with a note, rather than printed as though they were true
(the geometry is unaffected, so the plot still draws).
- DIRECT LABELS, PLACED GREEDILY. Every arrow is labelled, and the
`labelledScores` observations furthest from the centre are too — furthest
first because the centre is the mean, so distance from it is how unusual an
observation is. Each label wants the spot just past its own mark along the
ray from the origin, which is the one direction guaranteed to point away from
the crowd; if taken, it is bumped further out along that ray up to five
times, then placed anyway and flagged crowded. A dropped label is a mark that
silently lost its name, which is worse than a tight one. Boxes are clamped
inside the plot, so a label can crowd another but never leaves the frame.
Long labels elide with a full-text <title>, and text width is ESTIMATED from
character count, never measured from the DOM, so SSR and the client agree.
- INTERACTION. One transparent hit rect owns the plot and finds the nearest
mark itself (a 4px circle and a 1px arrow are not pointer targets),
converting through that rect's own client box, which stays correct when the
SVG scales down below its minimum width. Click pins; the pin survives the
pointer leaving.
Keyboard: TWO listboxes, each one tab stop with aria-activedescendant rather
than a tab stop per mark. Tab into the cloud lands on the observation
furthest from the centre; Tab into the arrows lands on the longest arrow —
on this chart those are the findings. The cloud walks in order of decreasing
distance from the centre, the arrows walk counter-clockwise by angle, because
an arrow's neighbour is the next one round the ring. Left/Right and Up/Down
step, Home/End jump to the list ends, 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 mark feel dead.
- FOCUS IS NEVER STRANDED. Which list "has focus" is DERIVED each render, not
repaired in an effect: an element removed from the tree never fires blur, so
the raw state goes stale and a correction would land a render too late. If
the list holding the caret leaves (the payload flips to error, or `marks`
changes) and focus has fallen to <body>, the card — tabIndex={-1},
programmatically focusable only — takes it.
- CLEANUP: one ResizeObserver, disconnected on unmount and whenever the node
changes. No timers, no rAF, no simulation to stop. Nothing is derived from
Math.random or Date.now, so two renders of the same payload are identical.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the panel, border
for gridlines and the unit circle, muted for the skeleton, muted-foreground
for axis text, ticks, the dashed origin cross and score labels, ring for the
focus outline, foreground for the arrows and their labels, var(--chart-1..5)
cycling for the groups. Never a chart token as a text colour.
- COLOUR IS NEVER THE ONLY ENCODING: each group also gets its own mark shape
(circle, square, triangle, diamond, cross), which survives greyscale
printing, every kind of colour blindness and the sixth group — where the
colour ramp repeats but the shape does not for another four. The legend
carries shape, colour, name and count, and the extreme observations are
labelled on the plot, so the legend is never the only way to identify a mark.
- ARROWS ARE --foreground, NOT A CHART TOKEN. They are a different kind of
thing from the observations; painting them in a series colour would imply
they belong to a series. Labels take a --card halo via paint-order so they
stay legible over the densest part of the cloud.
- Axis and tick text is muted-foreground at 11px with tabular-nums; the two end
ticks anchor to their ends so they cannot spill out of the frame. The origin
is a dashed muted-foreground cross, not just another gridline: it is the mean
of the data.
- ACCESSIBILITY: do NOT put role="img" on the plot — that is
children-presentational and would silence both focusable lists. Use
role="group" labelled by the card heading and described by the summary line,
which states the finding in words: how many observations, how many variables,
what share of variance the plane carries, and which observation is furthest
from the centre. 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 two tables — every variable with its two loadings, its length and its
direction IN WORDS from an eight-sector compass, because a screen reader
cannot see where an arrow points and a pair of numbers is not a direction;
and the named extreme observations, with a caption saying the rest are
reachable by arrow key. The visible readout is aria-hidden, because the
focused list already announces the active mark; a polite live region carries
ONLY the pin, which is the one change focus does not announce by itself.
- Motion: the only animation is the loading skeleton's pulse, carrying
motion-reduce:animate-none. Nothing else moves, so nothing else has to stop,
and the chart is complete with animation off.
Customization levers
- marks: "scores" for a plain PC score plot, "loadings" for a loading plot
(where the arrows own the axes, the ticks read in loading units and nothing
is rescaled), "both" for the biplot proper. Three pictures out of one
contract, and each one says which it is.
- arrowFill: the only free parameter in a biplot, and purely cosmetic. Drop to
0.5-0.6 when arrow labels collide with a dense cluster, raise toward 0.95
when the arrows are the story. It can never change what the arrows claim.
- pointRadius / labelledScores: the density pair. 3 with 12 labels reads as an
analysis figure; 6 with 0 labels makes a clean segmentation slide where the
legend and the shapes carry everything.
- showUnitCircle: turn on only when the loadings really are correlations —
then the circle is a ceiling with meaning, and how close an arrow gets to it
is how well the plane represents that variable. On covariance loadings it
would be a circle at an arbitrary radius.
- Palette: re-point GROUP_INK to one token for a monochrome figure, or key the
ink off a status ("passed" / "failed") rather than the group index when
colour should mean something; keep the arrows on --foreground either way, and
keep the shape list in step with whatever the colour does.
- Group centroids: the model already holds each group's marks, so a centroid
cross or a convex hull per group is a few lines on top of the same layout —
add it when the question is "do these groups separate" rather than "which
observation is that".
- Sign: a principal component's sign is arbitrary (PCA fixes direction, not
orientation), so flipping one axis is a legitimate presentational choice. Do
it by negating BOTH the scores and the loadings of that component before they
reach the component, never inside the renderer — half a flip is a picture
that lies.
- Axis: a fixed domain makes two cards comparable — add it as a prop, run it
through equalAspectDomain() so equal aspect survives, and decide out loud
what happens to marks outside it (count and name them, never clamp, or a
point would claim a position nothing was ever measured at).
- Interaction: onSelect carries the whole score or loading object — wire it to
a drill-down, a row link or a linked table. The hit rect is where a
double-click or a context menu goes without touching the geometry.Concepts
- Scores and loadings on one plane — a biplot is two pictures superimposed: where the observations landed after the projection, and which original variables pushed them there. Read together they answer a question neither answers alone — "this cluster is out on the right, and it is out there because of seats and API calls, not because of exports."
- Equal aspect as correctness — both axes are in the same units, so a distance only means something when a unit is the same width in pixels either way. The chart widens the domain instead of stretching the picture, and hands both axes the same tick step, which turns the grid into square cells: the aspect is visible rather than promised.
- Uniform arrow scaling — arrows and points live in different units, so the arrows are rescaled by one shared factor and that factor is printed on the card. It must be one factor: stretching each axis separately would fit the box more tidily and silently rewrite every angle between arrows, which is precisely what the arrows are for.
- Angle is the message — two arrows pointing the same way mean variables that move together, a right angle means unrelated, opposite ways means a trade-off, and length is how well this plane represents that variable at all. The screen-reader table therefore carries an eight-sector direction in words, because a pair of loadings is not a direction.
- The plane admits what it left behind — the axis titles carry each component's share of variance and the card warns when the two together carry less than half of it, or when the second carries almost none. A projection that quietly presents 31% of a dataset as though it were the dataset is the failure mode this chart exists to prevent.
- Furthest from the centre first — the origin is the mean of the projection, so distance from it is how unusual an observation is. Direct labels, the keyboard entry point and the screen-reader table all take the outermost observations first, so the finding is reachable without arrowing through ninety-six marks to get to it.
Correlogram
A correlation matrix drawn half as coefficient-shaped glyphs and half as numbers, with non-significant pairs hatched instead of blanked and the axes reorderable by hierarchical clustering.
Percentile Bands
A four-state percentile fan: nested p50/p90/p99 bands over time with the median line on top, an SLO rule whose breach stretches are interpolated, counted and keyboard-reachable, and an optional log axis for a tail that dwarfs the median.