Ternary Density Plot — Apache ECharts

A ternary density plot combines a three-component ternary diagram with kernel density estimation to visualize where compositional data concentrates. Instead of showing individual points, this visualization uses a heatmap overlay to reveal the underlying probability distribution of compositions, making it ideal for identifying clusters, modes, and patterns in large compositional datasets.

Ternary Density Plot rendered with Apache ECharts

Renders

JavaScript source (Apache ECharts)

// anyplot.ai
// ternary-density: Ternary Density Plot
// Library: echarts 6.1.0 | JavaScript 22.23.2
// Quality: 89/100 | Created: 2026-09-02
//# anyplot-orientation: square

const t = window.ANYPLOT_TOKENS;

// --- Small fixed-seed PRNG (LCG) + Box-Muller gaussian ----------------------
function makeRng(seed) {
  let s = seed >>> 0;
  return function () {
    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
    return s / 4294967296;
  };
}
const rng = makeRng(42);
function gaussian() {
  const u1 = Math.max(rng(), 1e-12);
  const u2 = rng();
  return Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2);
}

// --- Barycentric <-> Cartesian projection (equilateral triangle) -----------
// Vertices: A (component_a, top), B (component_b, bottom-left), C (component_c, bottom-right)
const H = Math.sqrt(3) / 2;
function baryToCart(a, b, c) {
  return [0.5 * a + c, a * H];
}
function cartToBary(x, y) {
  const a = y / H;
  const c = x - 0.5 * a;
  const b = 1 - a - c;
  return [a, b, c];
}
function clipToSimplex(a, b, c) {
  a = Math.max(a, 0);
  b = Math.max(b, 0);
  c = Math.max(c, 0);
  const s = a + b + c || 1;
  return [a / s, b / s, c / s];
}

// --- Data: soil texture composition (sand / silt / clay), 3 sample clusters -
// component_a = sand, component_b = silt, component_c = clay (fractions of 1)
const clusters = [
  { a: 0.62, b: 0.3, c: 0.08, n: 300, sigma: 0.055 }, // sandy loam
  { a: 0.12, b: 0.55, c: 0.33, n: 250, sigma: 0.045 }, // silty clay loam
  { a: 0.2, b: 0.15, c: 0.65, n: 150, sigma: 0.05 }, // clay-rich
];
const samplesByCluster = clusters.map((cl) => {
  const [cx, cy] = baryToCart(cl.a, cl.b, cl.c);
  const pts = [];
  for (let i = 0; i < cl.n; i++) {
    const x = cx + gaussian() * cl.sigma;
    const y = cy + gaussian() * cl.sigma;
    const [a, b, c] = clipToSimplex(...cartToBary(x, y));
    pts.push(baryToCart(a, b, c));
  }
  return pts;
});

// --- Kernel density estimate on a fine grid, clipped to the triangle -------
// Density is estimated per cluster and each field is normalized to its own
// peak before combining (max across clusters), so all three modes read with
// comparable visual weight regardless of their sample count — the plot's
// purpose is revealing every mode, not weighting shade by how many points
// happened to be drawn from it.
const nx = 68;
const dx = 1 / nx;
const dy = dx;
const ny = Math.ceil(H / dy);
const bandwidth = 0.05;
const twoH2 = 2 * bandwidth * bandwidth;
const cells = [];
const clusterPeaks = samplesByCluster.map(() => 0);
const cellRows = [];
for (let iy = 0; iy < ny; iy++) {
  const cy = (iy + 0.5) * dy;
  for (let ix = 0; ix < nx; ix++) {
    const cx = (ix + 0.5) * dx;
    const [a, b, c] = cartToBary(cx, cy);
    if (a < -1e-6 || b < -1e-6 || c < -1e-6) continue;
    const clusterDensities = samplesByCluster.map((pts, ci) => {
      let density = 0;
      for (let k = 0; k < pts.length; k++) {
        const ddx = cx - pts[k][0];
        const ddy = cy - pts[k][1];
        density += Math.exp(-(ddx * ddx + ddy * ddy) / twoH2);
      }
      density /= pts.length;
      if (density > clusterPeaks[ci]) clusterPeaks[ci] = density;
      return density;
    });
    cellRows.push([cx, cy, clusterDensities]);
  }
}
let maxDensity = 0;
cellRows.forEach(([cx, cy, clusterDensities]) => {
  let density = 0;
  clusterDensities.forEach((d, ci) => {
    const norm = d / clusterPeaks[ci];
    if (norm > density) density = norm;
  });
  if (density > maxDensity) maxDensity = density;
  cells.push([cx, cy, density]);
});

// --- Ternary grid lines (20% intervals), drawn beneath the density layer ---
const gridFractions = [0.2, 0.4, 0.6, 0.8];
const gridSegments = [];
gridFractions.forEach((k) => {
  gridSegments.push([baryToCart(k, 1 - k, 0), baryToCart(k, 0, 1 - k)]); // constant sand
  gridSegments.push([baryToCart(1 - k, k, 0), baryToCart(0, k, 1 - k)]); // constant silt
  gridSegments.push([baryToCart(1 - k, 0, k), baryToCart(0, 1 - k, k)]); // constant clay
});

// --- Title (fontsize scales with title length, see default-style-guide.md) -
const TITLE = "Soil Texture Composition · ternary-density · javascript · echarts · anyplot.ai";
const titleFontSize = Math.max(15, Math.round(22 * Math.min(1, 67 / TITLE.length)));

// --- Init ---------------------------------------------------------------
const chart = echarts.init(document.getElementById("container"));

// --- Option ---------------------------------------------------------------
chart.setOption({
  animation: false,
  backgroundColor: "transparent",
  title: {
    text: TITLE,
    left: "center",
    top: 34,
    textStyle: { color: t.ink, fontSize: titleFontSize, fontWeight: 600 },
  },
  grid: { left: 130, right: 190, top: 210, bottom: 160 },
  xAxis: { type: "value", min: -0.0545, max: 1.0545, show: false },
  yAxis: { type: "value", min: -0.09, max: 0.956, show: false },
  visualMap: {
    type: "continuous",
    seriesIndex: 1,
    dimension: 2,
    min: 0,
    max: maxDensity,
    calculable: false,
    orient: "vertical",
    right: 24,
    top: "middle",
    itemWidth: 20,
    itemHeight: 280,
    text: ["High", "Low"],
    textGap: 14,
    textStyle: { color: t.inkSoft, fontSize: 14 },
    inRange: { color: t.seq },
  },
  series: [
    {
      // Ternary grid lines at 20% intervals, semi-transparent, beneath the density fill
      type: "custom",
      data: gridSegments.map(() => 0),
      renderItem: (params, api) => {
        const seg = gridSegments[params.dataIndex];
        return {
          type: "polyline",
          shape: { points: [api.coord(seg[0]), api.coord(seg[1])] },
          style: { stroke: t.grid, lineWidth: 1.5 },
        };
      },
      silent: true,
      z: 1,
    },
    {
      // KDE density heatmap over the compositional simplex
      type: "custom",
      data: cells,
      renderItem: (params, api) => {
        const x = api.value(0);
        const y = api.value(1);
        const pt = api.coord([x, y]);
        const size = api.size([dx, dy]);
        const norm = api.value(2) / maxDensity;
        return {
          type: "rect",
          shape: {
            x: pt[0] - size[0] / 2,
            y: pt[1] - size[1] / 2,
            width: Math.ceil(size[0]) + 1,
            height: Math.ceil(size[1]) + 1,
          },
          style: { fill: api.visual("color"), opacity: 0.06 + 0.88 * Math.pow(norm, 1.3) },
        };
      },
      silent: true,
      z: 2,
    },
    {
      // Triangle border
      type: "custom",
      data: [0],
      renderItem: (params, api) => ({
        type: "polygon",
        shape: {
          points: [baryToCart(1, 0, 0), baryToCart(0, 1, 0), baryToCart(0, 0, 1)].map((p) => api.coord(p)),
        },
        style: { fill: "none", stroke: t.inkSoft, lineWidth: 2.5 },
      }),
      silent: true,
      z: 3,
    },
    {
      // Vertex labels
      type: "custom",
      data: [baryToCart(1, 0, 0), baryToCart(0, 1, 0), baryToCart(0, 0, 1)],
      renderItem: (params, api) => {
        const idx = params.dataIndex;
        const labels = ["Sand", "Silt", "Clay"];
        const offsets = [
          [0, -34],
          [-46, 26],
          [46, 26],
        ];
        const aligns = ["center", "right", "left"];
        const pt = api.coord([api.value(0), api.value(1)]);
        return {
          type: "text",
          style: {
            text: labels[idx],
            x: pt[0] + offsets[idx][0],
            y: pt[1] + offsets[idx][1],
            fill: t.ink,
            fontSize: 24,
            fontWeight: "bold",
            align: aligns[idx],
            verticalAlign: "middle",
          },
        };
      },
      silent: true,
      z: 4,
    },
  ],
});

chart.on("finished", () => {
  window.__anyplotReady = true;
});

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/ternary-density/echarts/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "ternary-density",
  "language": "javascript",
  "library": "echarts",
  "page": "https://anyplot.ai/ternary-density/javascript/echarts",
  "hub": "https://anyplot.ai/ternary-density",
  "code_json": "https://api.anyplot.ai/specs/ternary-density/echarts/code",
  "spec_json": "https://api.anyplot.ai/specs/ternary-density",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/ternary-density/javascript/echarts/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/ternary-density/javascript/echarts/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/ternary-density/javascript/echarts/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/ternary-density/javascript/echarts/plot-dark.html",
  "quality_score": 89.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Ternary Density Plot on anyplot.ai.

Other implementations