Ternary Plot
A four-state ternary plot in plain SVG — three-part compositions normalised to 100% on an equilateral triangle, with three gridline families, edge-rotated axis ticks, shaded classification zones and a keyboard-reachable mark per row.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/chart-ternary.jsonPrompt
Build a React + TypeScript + Tailwind "ChartTernary" card in plain SVG with zod.
Recharts has no ternary primitive and the projection is barycentric rather than
cartesian, so the frame, the grid, the tick rotation and the zone classification
are done by hand in small pure functions that live beside the schema. No new
dependency, no d3.
Contract
- One zod schema is the source of truth:
{ status: "loading" | "empty" | "error" | "ready"; title: string;
axes: { a: { label }, b: { label }, c: { label } }; // apex, bottom-left, bottom-right
points: { id; label; a: number; b: number; c: number;
series?: string; meta?: string }[];
series?: { id; label }[];
regions?: { id; label; vertices: { a; b; c }[] }[] } // >= 3 vertices, ring order
- a/b/c are raw amounts on ANY scale: percentages adding to 100, fractions adding
to 1, or three raw counts of grams, sessions or votes. Each row is normalised by
its OWN total, because that is the one thing a ternary plot may assume - the
position encodes the shares and never the size of the sample behind them. A 48 g
jar and a 53 g jar with the same mix land on the same spot.
- Regions are written in composition space, not pixels, so they survive every
resize and relabelling. A point belongs to the FIRST region whose polygon
contains it: list the most specific zone first when two overlap.
- superRefine: a ready chart needs at least one point; point / series / region ids
unique; a point naming an undeclared series is an issue; a region vertex that
cannot be normalised is an issue (the zone would silently come out the wrong
shape). 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 gridSteps (default 10, clamped 2-20),
pointRadius (default 4.5, clamped 2-9), jitter (default 0, clamped 0-8),
decimals (default 0, clamped 0-3), locale (default "en-US"), onRetry,
onPointSelect, className and the div's native props. forwardRef to the card.
- Export the maths so it is testable and so this prompt can describe it:
buildTernaryFrame(), fromUnit(), normalizeParts(), partsToXY(), roundShares(),
buildTernaryGrid(), buildAxisAnchors(), pointInPolygon(), regionCentroid(),
buildRegionShapes(), hashUnit(), jitterOffset(), markCoincident(),
buildTernaryModel(), markPath(), segmentsPath().
Behavior
- THE PROJECTION IS THE COMPONENT, and it is three lines: normalise (a,b,c) to
shares that add to 1, then take the parts-weighted average of the three
vertices. The triangle must be EQUILATERAL - the axes have to be
interchangeable, and on a squashed triangle ten percentage points covers more
ground on one axis than on another.
- Geometry is computed once in a UNIT FRAME (side 1) and mapped into the drawn
frame with a scale plus a translate. Resizing therefore never re-runs
classification, and the zone a sample falls in cannot change with the card width.
- THREE GRIDLINE FAMILIES, one per axis. Holding one share constant produces a
line parallel to the OPPOSITE edge, and that parallelism is the reading rule:
follow the line through a mark back to the edge its family is ticked on. So each
axis is ticked on exactly one edge - a on the left, b on the bottom, c on the
right - instead of all three being ticked everywhere. Ticks are drawn at the
gridline values only; the vertices are deliberately not ticked, because three
labels would pile up in each corner and a corner already means 100%.
- AXIS LABELS ARE ROTATED to run parallel to their own edge (-60 / 0 / +60), which
is the only layout that fits ten labels along a 60-degree edge without
overlapping. Each caption carries a direction arrow, because in this (clockwise)
layout the bottom axis counts up against the direction its own caption reads,
and a reader who guesses wrong reads the whole chart backwards. Each corner
prints "100%".
- ZONES are classified by ray casting in the unit frame, and labelled DIRECTLY at
the polygon's vertex mean - a zone that has to be looked up in a key is a zone
nobody reads. Long zone names live in a foreignObject and truncate with the full
string in a title, instead of running across the triangle.
- DEGENERATE ROWS ARE DROPPED AND COUNTED, never coerced: a negative part, a
non-finite part (an unparsed CSV cell) and three zeros each have their own
reason, all three are tallied in a visible note under the plot, and every one is
named in the screen-reader table. Coercing a NaN to 0 would put an invented
sample on the axis.
- PRINTED SHARES ADD TO EXACTLY 100 by largest remainder. Rounding each share on
its own publishes 62% + 24% + 15% = 101%, and a reader who can add is entitled
to conclude the chart is broken.
- COINCIDENT MARKS: rounded compositions collide constantly, and a stack of four
is indistinguishable from one reading. jitter defaults to 0 (exact). Above 0,
ONLY marks that actually share a position move, by an offset derived from a
hash of the point id - never Math.random, so the server, the first client frame
and every re-render agree - and the card says out loud that those marks are no
longer exactly on their own composition.
- INTERACTION. One transparent hit rect over the triangle finds the nearest mark
itself (a 4.5px mark is not a pointer target), converting through that rect's own
client box so it stays correct when the SVG scales below its minimum width.
Click pins, and the same rect's coordinates - not a remembered hover - decide
what was clicked, so a touch tap pins without a preceding pointermove.
Keyboard is a full equivalent, never an afterthought: the marks are a listbox
with a roving tabindex (one tab stop for the whole cloud), Tab lands on the most
one-sided composition because that is the finding, Left/Right walk the cloud in
model order, Up/Down cross to the nearest mix in the neighbouring series,
Home/End jump, Enter/Space pin, Escape releases. preventDefault fires only for
keys that were handled, so Tab still leaves the chart and Up/Down still scroll
when there is only one series.
- Live input wins in the order hover, focus, pin - a pin that swallowed hover
would make every other mark feel dead.
- Four first-class branches of one card: loading (a fixed skeleton triangle,
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 drop note), error (a Try
again button only when onRetry was passed), ready. A ready payload with nothing
placeable renders the empty branch WITH the note, so "42 rows arrived and none
of them were usable" never looks like "no rows arrived".
- CLEANUP: one ResizeObserver, disconnected on unmount and whenever the node
changes. No timers, no rAF, nothing to cancel. If the focused mark disappears
because the data changed underneath it, focus is handed to the plot group rather
than dropped on <body>.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the panel, border for
the gridlines, muted-foreground for the frame and every axis label, muted for the
skeleton, ring for the focus ring, foreground for the pin and the pointer halo,
var(--chart-1..5) for the series.
- COLOUR IS NEVER THE ONLY CHANNEL. Each series gets a mark SHAPE as well as a
colour (circle, square, triangle, diamond, cross), which survives greyscale,
every kind of colour blindness and the sixth series. Zones are shaded with
color-mix(in oklab, var(--foreground) 3.5-9.5%, transparent), cycling three steps
so neighbours never match, and are identified by their boundary and their direct
label rather than by the shade.
- Axis and tick text is muted-foreground at 10-12px; gridlines are hairline border
tokens and the frame is one step stronger, so the triangle reads as the boundary
and the grid as the scaffolding. Each grid family is ONE path of joined segments,
so a 10-step grid costs three nodes instead of 27.
- ACCESSIBILITY: do NOT put role="img" on the plot - that is
children-presentational and would silence every focusable mark. Use role="group"
labelled by the heading and described by the summary line, which states the
finding in words (how many compositions, the average mix, how many zones, how
many rows could not be placed). Marks are listbox options carrying the full
sentence as aria-label and as an SVG title. The visible readout is aria-hidden,
because a focused mark already announces itself; a polite live region carries
only what focus cannot - pinning and releasing. 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 a narrow viewport picks up real horizontal scroll) holds one
row per composition with its three shares and its zone, then the dropped rows
with their reasons.
- Motion: the only animation is the loading skeleton's pulse, with
motion-reduce:animate-none. Nothing else moves, so nothing else has to stop, and
the chart is complete with animation off.
Customization levers
- gridSteps is the reading resolution, not decoration: 4-5 for "which corner does
this lean towards" on a dashboard tile, 10 for a working chart, 20 for a
reference diagram people quote numbers off.
- Corner assignment (which component is a, b, c) is a real decision - rotating the
same data changes which corner the eye is drawn to. Take the ordering your field
already uses (soil science puts clay at the apex) and keep it fixed across cards.
- Zones: pass none for a plain scatter on a triangle, one for a target envelope, or
a full tiling for classification. They are polygons in composition space, so a
policy threshold ("at least 20% referral") is three vertices, not a redraw.
- Series: drop `series` entirely for a single unlabelled cloud (the legend hides
itself), or key the series off a status rather than a group when colour should
mean pass / fail - keep the shapes paired with it either way.
- Density: pointRadius 2.5-3 for a few hundred marks, 6-7 when a handful of samples
are the hero of a slide and each one has to be an easy pointer target.
- jitter: leave at 0 for anything measured; raise it to 3-5 only when rounded
inputs genuinely stack, and keep the disclosure line with it.
- decimals: 1 for lab data where 0.5 percentage points matters. Largest-remainder
rounding follows the setting, so the printed shares still add to 100.
- onPointSelect already carries the whole point object - wire it to a drill-down, a
sample sheet or a linked table. The hit rect is where a double-click or a context
menu goes without touching the geometry.Concepts
- Composition, not magnitude — every row is divided by its own total, so the position carries the mix and nothing else. That is the deal a ternary plot makes: 3 grams and 3 tonnes of the same blend land on the same point, and if the size of the sample is part of your finding this is the wrong chart.
- Barycentric projection — a point is the share-weighted average of the three corners, which is why the triangle has to be equilateral. Squash it and ten percentage points buys more distance on one axis than on another, so the same mix reads differently depending on which corner it leans towards.
- Three parallel families — holding one share constant traces a line parallel to the opposite edge. That parallelism is the entire reading rule, and it is why each axis is ticked on exactly one edge with its labels rotated to match: follow the line through a mark back to the edge its family belongs to. The direction arrow on each caption exists because one of the three axes always counts up against the way its own caption reads.
- Zones as classification — the named regions are polygons in composition space, hit-tested by ray casting, so a sample is reported as belonging to a zone rather than merely sitting near one. They are labelled where they are, because a zone that has to be looked up in a legend is a zone nobody reads.
- Dropped and counted, never coerced — a negative part, an unparsed cell and three zeros are three different failures, and none of them may be turned into a position. Each is tallied in a visible note and named in the screen-reader table, so a payload that half arrived cannot pass for a payload that fully arrived.
- Shares that add to 100 — printed percentages are rounded by largest remainder rather than one at a time, because three independently rounded shares routinely publish 101% and a reader who can add will conclude the chart is broken.
- Seeded separation — identical rounded mixes stack into one mark. Jitter is off by default, and when it is on only the marks that actually collide move, by a hash of their own id: deterministic across SSR and every re-render, disclosed on the card, and always a trade of exactness for countability.
Spiral Timeline
Time wound onto an Archimedean coil — one ring per period, so the same phase of every turn lands on one radial line — with bars or dots on the coil, a floating zero baseline, measured zeros told apart from windows the series never covered, and a single-ring series refused rather than drawn.
Market Depth
A four-state order-book depth chart: mirrored cumulative bid and ask staircases meeting at the spread, an exact step outline, a hover and keyboard readout of price, size and cumulative total at any level, and a refusal when the book crosses itself.