Basic Ternary Plot — Chart.js

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 Chart.js

JavaScript source (Chart.js)

// anyplot.ai
// ternary-basic: Basic Ternary Plot
// Library: chartjs 4.4.7 | JavaScript 22.23.1
// Quality: 92/100 | Created: 2026-08-04

//# anyplot-orientation: square
const t = window.ANYPLOT_TOKENS;

// --- Data (in-memory, deterministic) ----------------------------------------
// Soil texture samples (sand / silt / clay %, each triplet sums to 100) drawn
// from four notional field sites so the cloud shows realistic clustering
// rather than a uniform smear across the simplex.
let seed = 42;
function rand() {
  seed = (seed * 1664525 + 1013904223) % 4294967296;
  return seed / 4294967296;
}
function gaussian() {
  const u1 = Math.max(rand(), 1e-6);
  const u2 = rand();
  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
}

const SITES = [
  { sand: 65, silt: 25, clay: 10 },
  { sand: 40, silt: 40, clay: 20 },
  { sand: 15, silt: 55, clay: 30 },
  { sand: 30, silt: 30, clay: 40 },
];

const samples = [];
SITES.forEach((site) => {
  for (let i = 0; i < 21; i++) {
    const sand = Math.max(2, site.sand + gaussian() * 7);
    const silt = Math.max(2, site.silt + gaussian() * 7);
    const clay = Math.max(2, site.clay + gaussian() * 7);
    const total = sand + silt + clay;
    samples.push({
      sand: (sand / total) * 100,
      silt: (silt / total) * 100,
      clay: (clay / total) * 100,
    });
  }
});

// --- Ternary <-> Cartesian projection ---------------------------------------
// Equilateral triangle: sand at (0,0), silt at (1,0), clay at (0.5, sqrt(3)/2).
const SQRT3_2 = Math.sqrt(3) / 2;
function ternaryToXY(sandFrac, siltFrac, clayFrac) {
  return { x: siltFrac + 0.5 * clayFrac, y: clayFrac * SQRT3_2 };
}
const VERTEX_SAND = ternaryToXY(1, 0, 0);
const VERTEX_SILT = ternaryToXY(0, 1, 0);
const VERTEX_CLAY = ternaryToXY(0, 0, 1);
const GRID_STEPS = [0.2, 0.4, 0.6, 0.8];

function drawLine(ctx, toPx, toPy, p1, p2) {
  ctx.beginPath();
  ctx.moveTo(toPx(p1), toPy(p1));
  ctx.lineTo(toPx(p2), toPy(p2));
  ctx.stroke();
}

// --- Custom plugin: triangle frame, 20% grid, vertex + tick labels ---------
const ternaryFramePlugin = {
  id: "ternaryFrame",
  beforeDatasetsDraw(chart) {
    const { ctx, scales } = chart;
    const toPx = (p) => scales.x.getPixelForValue(p.x);
    const toPy = (p) => scales.y.getPixelForValue(p.y);

    ctx.save();
    ctx.strokeStyle = t.grid;
    ctx.lineWidth = 1.5;
    GRID_STEPS.forEach((v) => {
      drawLine(ctx, toPx, toPy, ternaryToXY(v, 1 - v, 0), ternaryToXY(v, 0, 1 - v));
      drawLine(ctx, toPx, toPy, ternaryToXY(1 - v, v, 0), ternaryToXY(0, v, 1 - v));
      drawLine(ctx, toPx, toPy, ternaryToXY(1 - v, 0, v), ternaryToXY(0, 1 - v, v));
    });

    ctx.strokeStyle = t.ink;
    ctx.lineWidth = 3;
    ctx.beginPath();
    ctx.moveTo(toPx(VERTEX_SAND), toPy(VERTEX_SAND));
    ctx.lineTo(toPx(VERTEX_SILT), toPy(VERTEX_SILT));
    ctx.lineTo(toPx(VERTEX_CLAY), toPy(VERTEX_CLAY));
    ctx.closePath();
    ctx.stroke();
    ctx.restore();
  },
  afterDraw(chart) {
    const { ctx, scales } = chart;
    const toPx = (p) => scales.x.getPixelForValue(p.x);
    const toPy = (p) => scales.y.getPixelForValue(p.y);

    ctx.save();
    ctx.font = "13px sans-serif";
    ctx.fillStyle = t.inkSoft;
    GRID_STEPS.forEach((v) => {
      const pct = String(Math.round(v * 100));

      ctx.textAlign = "right";
      ctx.textBaseline = "middle";
      const sandPt = ternaryToXY(v, 0, 1 - v);
      ctx.fillText(pct, toPx(sandPt) - 10, toPy(sandPt));

      ctx.textAlign = "center";
      ctx.textBaseline = "top";
      const siltPt = ternaryToXY(1 - v, v, 0);
      ctx.fillText(pct, toPx(siltPt), toPy(siltPt) + 10);

      ctx.textAlign = "left";
      ctx.textBaseline = "middle";
      const clayPt = ternaryToXY(0, 1 - v, v);
      ctx.fillText(pct, toPx(clayPt) + 10, toPy(clayPt));
    });

    ctx.font = "600 20px sans-serif";
    ctx.fillStyle = t.ink;
    ctx.textAlign = "center";
    ctx.textBaseline = "top";
    ctx.fillText("Sand", toPx(VERTEX_SAND), toPy(VERTEX_SAND) + 26);
    ctx.fillText("Silt", toPx(VERTEX_SILT), toPy(VERTEX_SILT) + 26);
    ctx.textBaseline = "bottom";
    ctx.fillText("Clay", toPx(VERTEX_CLAY), toPy(VERTEX_CLAY) - 14);
    ctx.restore();
  },
};

// --- Mount -------------------------------------------------------------------
const canvas = document.createElement("canvas");
document.getElementById("container").appendChild(canvas);

// --- Chart ---------------------------------------------------------------
new Chart(canvas, {
  type: "scatter",
  data: {
    datasets: [
      {
        label: "Soil sample",
        data: samples.map((s) => ternaryToXY(s.sand / 100, s.silt / 100, s.clay / 100)),
        backgroundColor: t.palette[0],
        borderColor: t.pageBg,
        borderWidth: 1.5,
        pointRadius: 8,
        pointHoverRadius: 8,
      },
    ],
  },
  options: {
    responsive: true,
    maintainAspectRatio: false,
    animation: false,
    layout: { padding: 20 },
    plugins: {
      title: {
        display: true,
        text: "ternary-basic · javascript · chartjs · anyplot.ai",
        color: t.ink,
        font: { size: 22, weight: "500" },
        padding: { bottom: 20 },
      },
      legend: { display: false },
      tooltip: {
        enabled: true,
        callbacks: {
          title: () => "",
          label: (ctx) => {
            const s = samples[ctx.dataIndex];
            return `Sand ${s.sand.toFixed(1)}% · Silt ${s.silt.toFixed(1)}% · Clay ${s.clay.toFixed(1)}%`;
          },
        },
      },
    },
    scales: {
      x: { type: "linear", min: -0.16, max: 1.16, display: false },
      y: { type: "linear", min: -0.3, max: 1.02, display: false },
    },
  },
  plugins: [ternaryFramePlugin],
});

Part of Basic Ternary Plot on anyplot.ai.

Other implementations