Basic Ternary Plot — MUI X Charts

A ternary plot displays three-component compositional data on an equilateral triangle where each vertex represents 100% of one component. Points inside the triangle show compositions that sum to a constant total (usually 100%), with position indicating relative proportions. This visualization is essential for data where three variables are interdependent and constrained to sum to a fixed value.

Basic Ternary Plot rendered with MUI X Charts

JavaScript source (MUI X Charts)

// anyplot.ai
// ternary-basic: Basic Ternary Plot
// Library: muix 7.29.1 | JavaScript 22.23.1
// Quality: 88/100 | Created: 2026-08-04
//# anyplot-orientation: square
// anyplot.ai
// ternary-basic: Basic Ternary Plot
// Library: MUI X Charts | React | Node 22
// License: @mui/x-charts — MIT (community). Pro/Premium are out of scope.
// Quality: pending | Created: 2026-08-04
import { ChartContainer } from "@mui/x-charts/ChartContainer";
import { ScatterPlot } from "@mui/x-charts/ScatterChart";
import { ChartsTooltip } from "@mui/x-charts/ChartsTooltip";
import { useXScale, useYScale } from "@mui/x-charts/hooks";

const t = window.ANYPLOT_TOKENS;
const SIZE = window.ANYPLOT_SIZE;
const TITLE = "Bronze Alloy Composition · ternary-basic · javascript · muix · anyplot.ai";
const FONT = '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif';

// --- Ternary <-> Cartesian projection ---------------------------------------
// Equilateral triangle: Copper (a) at the top apex, Tin (b) at bottom-left,
// Zinc (c) at bottom-right. Only two degrees of freedom exist (a+b+c=1), so
// every composition maps to a unique point inside (or on) the triangle.
const SQRT3_2 = Math.sqrt(3) / 2;
function toXY(a, b, c) {
  const total = a + b + c;
  const aN = a / total;
  const cN = c / total;
  return { x: cN + aN / 2, y: SQRT3_2 * aN };
}
const VERTEX_TOP = toXY(1, 0, 0);
const VERTEX_LEFT = toXY(0, 1, 0);
const VERTEX_RIGHT = toXY(0, 0, 1);

// --- Data: bronze/gunmetal alloy samples (Cu-Sn-Zn, deterministic) ---------
// Foundry bronze is copper-dominant with smaller tin and zinc fractions, so
// the sample cloud naturally clusters toward the Copper apex while still
// spreading across the simplex — the "story" comes from that real material
// tendency, not from an added annotation.
let seed = 20260804 >>> 0;
function rand() {
  seed = (1664525 * seed + 1013904223) >>> 0;
  return seed / 4294967296;
}
function gammaShape(k) {
  let s = 0;
  for (let i = 0; i < k; i++) s += -Math.log(rand() || 1e-9);
  return s;
}

const N = 65;
const cu = new Array(N);
const sn = new Array(N);
const zn = new Array(N);
const points = new Array(N);
for (let i = 0; i < N; i++) {
  const gCu = gammaShape(5);
  const gSn = gammaShape(2);
  const gZn = gammaShape(2);
  const total = gCu + gSn + gZn;
  const a = gCu / total;
  const b = gSn / total;
  const c = gZn / total;
  cu[i] = a * 100;
  sn[i] = b * 100;
  zn[i] = c * 100;
  const { x, y } = toXY(a, b, c);
  points[i] = { x, y, id: `alloy-${i}` };
}

// --- Grid: lines of constant composition, parallel to each edge ------------
const GRID_LEVELS = [0.2, 0.4, 0.6, 0.8];
const GRID_SEGMENTS = [];
for (const f of GRID_LEVELS) {
  GRID_SEGMENTS.push([toXY(f, 1 - f, 0), toXY(f, 0, 1 - f)]); // constant Copper
  GRID_SEGMENTS.push([toXY(1 - f, f, 0), toXY(0, f, 1 - f)]); // constant Tin
  GRID_SEGMENTS.push([toXY(1 - f, 0, f), toXY(0, 1 - f, f)]); // constant Zinc
}

// --- Edge tick marks (0-100 in steps of 20) ---------------------------------
const TICK_LEVELS = [0, 20, 40, 60, 80, 100];
const LEFT_TICKS = TICK_LEVELS.map((pct) => ({ pct, pos: toXY(pct / 100, 1 - pct / 100, 0) }));
const BOTTOM_TICKS = TICK_LEVELS.map((pct) => ({ pct, pos: toXY(0, pct / 100, 1 - pct / 100) }));
const RIGHT_TICKS = TICK_LEVELS.map((pct) => ({ pct, pos: toXY(1 - pct / 100, 0, pct / 100) }));

// Half-extent margins around the [0,1] x [0, sqrt3/2] triangle. Equal x/y
// spans (1.12) keep the triangle equilateral while filling most of the
// square canvas — just enough room above for the title + apex label and
// below/beside for the tick and vertex labels.
const X_MIN = -0.06;
const X_MAX = 1.06;
const Y_MIN = -0.09;
const Y_MAX = 1.03;

function TriangleGrid() {
  const xs = useXScale();
  const ys = useYScale();
  return (
    <g>
      {GRID_SEGMENTS.map((seg, i) => (
        <line
          key={i}
          x1={xs(seg[0].x)}
          y1={ys(seg[0].y)}
          x2={xs(seg[1].x)}
          y2={ys(seg[1].y)}
          stroke={t.grid}
          strokeWidth={1.5}
          strokeDasharray="6 5"
        />
      ))}
      <path
        d={`M${xs(VERTEX_TOP.x)} ${ys(VERTEX_TOP.y)} L${xs(VERTEX_LEFT.x)} ${ys(VERTEX_LEFT.y)} L${xs(VERTEX_RIGHT.x)} ${ys(VERTEX_RIGHT.y)} Z`}
        fill="none"
        stroke={t.inkSoft}
        strokeWidth={2.5}
        strokeLinejoin="round"
      />
    </g>
  );
}

function EdgeTicks() {
  const xs = useXScale();
  const ys = useYScale();
  const label = (tick, dx, dy, anchor) => (
    <text
      key={`${dx}-${dy}-${tick.pct}`}
      x={xs(tick.pos.x) + dx}
      y={ys(tick.pos.y) + dy}
      textAnchor={anchor}
      dominantBaseline="middle"
      fontFamily={FONT}
      fontSize={14}
      fill={t.inkSoft}
    >
      {tick.pct}
    </text>
  );
  return (
    <g>
      {LEFT_TICKS.map((tk) => label(tk, -16, 0, "end"))}
      {BOTTOM_TICKS.map((tk) => label(tk, 0, 22, "middle"))}
      {RIGHT_TICKS.map((tk) => label(tk, 16, 0, "start"))}
    </g>
  );
}

function VertexLabels() {
  const xs = useXScale();
  const ys = useYScale();
  return (
    <g fontFamily={FONT} fontSize={22} fontWeight={700} fill={t.ink}>
      <text x={xs(VERTEX_TOP.x)} y={ys(VERTEX_TOP.y) - 24} textAnchor="middle" dominantBaseline="baseline">
        Copper (%)
      </text>
      <text x={xs(VERTEX_LEFT.x)} y={ys(VERTEX_LEFT.y) + 32} textAnchor="middle" dominantBaseline="hanging">
        Tin (%)
      </text>
      <text x={xs(VERTEX_RIGHT.x)} y={ys(VERTEX_RIGHT.y) + 32} textAnchor="middle" dominantBaseline="hanging">
        Zinc (%)
      </text>
    </g>
  );
}

function Title() {
  const xs = useXScale();
  const ys = useYScale();
  const n = TITLE.length;
  const ratio = n > 67 ? 67 / n : 1.0;
  const fontSize = Math.max(15, Math.round(22 * ratio));
  return (
    <text
      x={xs((X_MIN + X_MAX) / 2)}
      y={ys(Y_MAX) + fontSize}
      textAnchor="middle"
      dominantBaseline="hanging"
      fontFamily={FONT}
      fontSize={fontSize}
      fontWeight={600}
      fill={t.ink}
    >
      {TITLE}
    </text>
  );
}

// --- Chart (default-exported component — the harness mounts it) -------------
export default function Chart() {
  return (
    <ChartContainer
      width={SIZE.width}
      height={SIZE.height}
      margin={{ top: 20, right: 20, bottom: 20, left: 20 }}
      series={[
        {
          type: "scatter",
          label: "Alloy sample",
          color: "rgba(0, 158, 115, 0.85)",
          markerSize: 8,
          data: points,
          valueFormatter: (_value, ctx) =>
            `Cu ${cu[ctx.dataIndex].toFixed(0)}% · Sn ${sn[ctx.dataIndex].toFixed(0)}% · Zn ${zn[ctx.dataIndex].toFixed(0)}%`,
        },
      ]}
      xAxis={[{ scaleType: "linear", min: X_MIN, max: X_MAX }]}
      yAxis={[{ scaleType: "linear", min: Y_MIN, max: Y_MAX }]}
      skipAnimation
    >
      <TriangleGrid />
      <ScatterPlot />
      <EdgeTicks />
      <VertexLabels />
      <Title />
      <ChartsTooltip trigger="item" />
    </ChartContainer>
  );
}

Part of Basic Ternary Plot on anyplot.ai.

Other implementations