Ternary Density Plot — Plotly

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 Plotly

Python source (Plotly)

""" anyplot.ai
ternary-density: Ternary Density Plot
Library: plotly 6.7.0 | Python 3.13.13
Quality: 91/100 | Updated: 2026-05-19
"""

import os
import sys


# Remove the script directory from sys.path so that sibling implementations
# (e.g. matplotlib.py) do not shadow installed packages.
_script_dir = os.path.dirname(os.path.abspath(__file__))
sys.path = [p for p in sys.path if os.path.abspath(p or os.getcwd()) != _script_dir]

import matplotlib


matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import plotly.graph_objects as go
from scipy import ndimage
from scipy.stats import gaussian_kde


# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
GRID = "rgba(26,26,23,0.12)" if THEME == "light" else "rgba(240,239,232,0.12)"

# Data — synthetic sediment composition (sand/silt/clay)
np.random.seed(42)
n_samples = 500

# Cluster 1: Sand-dominant samples (beaches/river channels)
cluster1_a = np.random.beta(8, 2, n_samples // 3) * 70 + 25
cluster1_b = np.random.beta(2, 5, n_samples // 3) * 40
cluster1_c = 100 - cluster1_a - cluster1_b
mask1 = cluster1_c >= 0
cluster1_a, cluster1_b, cluster1_c = cluster1_a[mask1], cluster1_b[mask1], cluster1_c[mask1]

# Cluster 2: Silt-dominant samples (floodplains/estuaries)
cluster2_b = np.random.beta(7, 2, n_samples // 3) * 55 + 35
cluster2_a = np.random.beta(2, 5, n_samples // 3) * 30
cluster2_c = 100 - cluster2_a - cluster2_b
mask2 = cluster2_c >= 0
cluster2_a, cluster2_b, cluster2_c = cluster2_a[mask2], cluster2_b[mask2], cluster2_c[mask2]

# Cluster 3: Clay-dominant mixed samples (deep lake sediments)
cluster3_c = np.random.beta(6, 2, n_samples // 3) * 50 + 30
cluster3_a = np.random.beta(2, 4, n_samples // 3) * 35
cluster3_b = 100 - cluster3_a - cluster3_c
mask3 = cluster3_b >= 0
cluster3_a, cluster3_b, cluster3_c = cluster3_a[mask3], cluster3_b[mask3], cluster3_c[mask3]

# Combine clusters
sand = np.concatenate([cluster1_a, cluster2_a, cluster3_a])
silt = np.concatenate([cluster1_b, cluster2_b, cluster3_b])
clay = np.concatenate([cluster1_c, cluster2_c, cluster3_c])

# Normalize to sum = 100
total = sand + silt + clay
sand = sand / total * 100
silt = silt / total * 100
clay = clay / total * 100

# Convert ternary → Cartesian for KDE
x_cart = 0.5 * (2 * silt + clay) / 100
y_cart = (np.sqrt(3) / 2) * clay / 100

# 2D kernel density estimation
coords = np.vstack([x_cart, y_cart])
kde = gaussian_kde(coords, bw_method="scott")

# Evaluation grid (Cartesian space)
grid_size = 100
x_grid = np.linspace(0, 1, grid_size)
y_grid = np.linspace(0, np.sqrt(3) / 2, grid_size)
xx, yy = np.meshgrid(x_grid, y_grid)
grid_coords = np.vstack([xx.ravel(), yy.ravel()])

density = kde(grid_coords).reshape(xx.shape)

# Mask outside the ternary triangle
inside_triangle = (yy >= 0) & (yy <= np.sqrt(3) * xx) & (yy <= np.sqrt(3) * (1 - xx))
density[~inside_triangle] = np.nan

# Back to ternary coordinates for plotting
clay_grid = yy * (2 / np.sqrt(3)) * 100
silt_grid = (xx - clay_grid / 200) * 100
sand_grid = 100 - silt_grid - clay_grid

# Plot
fig = go.Figure()

# Density layer — Viridis-colored scatter markers
valid_mask = inside_triangle & ~np.isnan(density)
a_flat = sand_grid[valid_mask]
b_flat = silt_grid[valid_mask]
c_flat = clay_grid[valid_mask]
d_flat = density[valid_mask]

fig.add_trace(
    go.Scatterternary(
        a=a_flat,
        b=b_flat,
        c=c_flat,
        mode="markers",
        marker={
            "size": 6,
            "color": d_flat,
            "colorscale": "Viridis",
            "showscale": True,
            "colorbar": {
                "title": {"text": "Density", "font": {"size": 20, "color": INK}},
                "tickfont": {"size": 16, "color": INK_SOFT},
                "len": 0.7,
                "thickness": 25,
                "x": 1.02,
                "bgcolor": ELEVATED_BG,
                "bordercolor": INK_SOFT,
                "borderwidth": 1,
            },
            "opacity": 0.85,
        },
        hovertemplate="Sand: %{a:.1f}%<br>Silt: %{b:.1f}%<br>Clay: %{c:.1f}%<extra></extra>",
        showlegend=False,
    )
)

# Smooth density for clean contour extraction
density_filled = density.copy()
density_filled[np.isnan(density_filled)] = 0
smoothed = ndimage.gaussian_filter(density_filled, sigma=2)

# Contour levels from smoothed valid region
smoothed_valid_vals = smoothed[inside_triangle]
contour_levels = np.percentile(smoothed_valid_vals[smoothed_valid_vals > 0], [25, 50, 75, 90])

# Extract smooth contour paths via matplotlib (data extraction only, no display)
fig_tmp, ax_tmp = plt.subplots()
CS = ax_tmp.contour(xx, yy, smoothed, levels=contour_levels)
plt.close(fig_tmp)

contour_color = "rgba(255,255,255,0.85)" if THEME == "light" else "rgba(240,239,232,0.85)"
for segs in CS.allsegs:
    for seg in segs:
        if len(seg) < 5:
            continue
        x_c, y_c = seg[:, 0], seg[:, 1]
        # Cartesian → ternary
        clay_c = y_c * (2 / np.sqrt(3)) * 100
        silt_c = (x_c - clay_c / 200) * 100
        sand_c = 100 - silt_c - clay_c
        # Filter to valid ternary region
        valid = (sand_c >= 0) & (silt_c >= 0) & (clay_c >= 0)
        if valid.sum() > 5:
            fig.add_trace(
                go.Scatterternary(
                    a=sand_c[valid],
                    b=silt_c[valid],
                    c=clay_c[valid],
                    mode="lines",
                    line={"color": contour_color, "width": 2.5},
                    hoverinfo="skip",
                    showlegend=False,
                )
            )

# Style
fig.update_layout(
    title={
        "text": "Sediment Composition Distribution · ternary-density · python · plotly · anyplot.ai",
        "font": {"size": 28, "color": INK},
        "x": 0.5,
        "xanchor": "center",
    },
    paper_bgcolor=PAGE_BG,
    plot_bgcolor=PAGE_BG,
    font={"color": INK},
    ternary={
        "sum": 100,
        "bgcolor": PAGE_BG,
        "aaxis": {
            "title": {"text": "Sand (%)", "font": {"size": 22, "color": INK}},
            "tickfont": {"size": 16, "color": INK_SOFT},
            "tickangle": 0,
            "dtick": 20,
            "gridcolor": GRID,
            "linecolor": INK_SOFT,
            "linewidth": 2,
        },
        "baxis": {
            "title": {"text": "Silt (%)", "font": {"size": 22, "color": INK}},
            "tickfont": {"size": 16, "color": INK_SOFT},
            "tickangle": 45,
            "dtick": 20,
            "gridcolor": GRID,
            "linecolor": INK_SOFT,
            "linewidth": 2,
        },
        "caxis": {
            "title": {"text": "Clay (%)", "font": {"size": 22, "color": INK}},
            "tickfont": {"size": 16, "color": INK_SOFT},
            "tickangle": -45,
            "dtick": 20,
            "gridcolor": GRID,
            "linecolor": INK_SOFT,
            "linewidth": 2,
        },
    },
    template="none",
    margin={"l": 80, "r": 120, "t": 100, "b": 80},
)

# Save
fig.write_image(f"plot-{THEME}.png", width=1600, height=900, scale=3)
fig.write_html(f"plot-{THEME}.html", include_plotlyjs="cdn")

Part of Ternary Density Plot on anyplot.ai.

Other implementations