Charts

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import type {
  ChartTernaryData,
  ChartTernaryPoint,
  ChartTernaryRegion,
  ChartTernarySeries,
  TernaryParts,
} from "./chart-ternary.contract"

export interface ChartTernaryProps

Installation

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

Prompt

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.

On This Page