Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartBiplotComponent,
  ChartBiplotData,
  ChartBiplotGroup,
  ChartBiplotLoading,
  ChartBiplotScore,
} from "./chart-biplot.contract"

/** What a click or Enter hands back — the two layers are different things. */

Installation

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

Prompt

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.

On This Page