Ternary Density Plot — plotnine

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 plotnine

Python source (plotnine)

""" anyplot.ai
ternary-density: Ternary Density Plot
Library: plotnine 0.15.4 | Python 3.13.13
Quality: 80/100 | Updated: 2026-05-19
"""

import numpy as np
import pandas as pd
from plotnine import (
    aes,
    coord_fixed,
    element_blank,
    element_text,
    geom_density_2d,
    geom_polygon,
    geom_segment,
    geom_text,
    geom_tile,
    ggplot,
    labs,
    scale_fill_cmap,
    theme,
    theme_void,
)
from scipy.stats import gaussian_kde


# Generate synthetic compositional data (sediment: sand/silt/clay)
np.random.seed(42)

# Create three clusters of compositions
# Cluster 1: Sandy sediments (high sand)
n1 = 300
sand1 = np.random.beta(5, 2, n1) * 60 + 35
silt1 = np.random.beta(2, 3, n1) * (100 - sand1) * 0.6
clay1 = 100 - sand1 - silt1

# Cluster 2: Silty sediments (high silt)
n2 = 250
silt2 = np.random.beta(5, 2, n2) * 50 + 40
sand2 = np.random.beta(2, 4, n2) * (100 - silt2) * 0.5
clay2 = 100 - sand2 - silt2

# Cluster 3: Clay-rich sediments (high clay)
n3 = 250
clay3 = np.random.beta(4, 2, n3) * 45 + 40
sand3 = np.random.beta(2, 5, n3) * (100 - clay3) * 0.4
silt3 = 100 - sand3 - clay3

# Combine all clusters
sand = np.concatenate([sand1, sand2, sand3])
silt = np.concatenate([silt1, silt2, silt3])
clay = np.concatenate([clay1, clay2, clay3])

# Ensure non-negative values and normalize to sum to 100
sand = np.clip(sand, 0, 100)
silt = np.clip(silt, 0, 100)
clay = np.clip(clay, 0, 100)
total = sand + silt + clay
sand = sand / total * 100
silt = silt / total * 100
clay = clay / total * 100

# Convert ternary to Cartesian coordinates
# Formula: x = 0.5 * (2*b + c) / total, y = (sqrt(3)/2) * c / total
# Where a=sand (bottom-left), b=silt (bottom-right), c=clay (top)
x_data = 0.5 * (2 * silt + clay) / 100
y_data = (np.sqrt(3) / 2) * clay / 100

# Compute KDE on the data points
points = np.vstack([x_data, y_data])
kde = gaussian_kde(points, bw_method="silverman")

# Create grid for density estimation
grid_resolution = 150
x_grid = np.linspace(0, 1, grid_resolution)
y_grid = np.linspace(0, np.sqrt(3) / 2, grid_resolution)
xx, yy = np.meshgrid(x_grid, y_grid)

# Evaluate KDE on grid
positions = np.vstack([xx.ravel(), yy.ravel()])
density = kde(positions).reshape(xx.shape)

# Create mask for valid ternary region (inside triangle)
# Convert grid points back to ternary to check bounds
# c = y * 2/sqrt(3), b = x - c/2, a = 1 - b - c
cc = yy * 2 / np.sqrt(3)
bb = xx - cc / 2
aa = 1 - bb - cc
mask = (aa >= -0.001) & (bb >= -0.001) & (cc >= -0.001) & (aa <= 1.001) & (bb <= 1.001) & (cc <= 1.001)

# Apply mask (set values outside triangle to NaN)
density_masked = np.where(mask, density, np.nan)

# Create DataFrame for density grid
density_df = pd.DataFrame({"x": xx.ravel(), "y": yy.ravel(), "density": density_masked.ravel()}).dropna()

# Triangle vertices for frame
vertices = pd.DataFrame({"x": [0, 1, 0.5, 0], "y": [0, 0, np.sqrt(3) / 2, 0]})

# Grid lines at 20% intervals
grid_lines = []
for pct in [0.2, 0.4, 0.6, 0.8]:
    # Lines parallel to bottom (constant clay)
    x1 = 0.5 * (2 * 0 + pct)
    y1 = (np.sqrt(3) / 2) * pct
    x2 = 0.5 * (2 * (1 - pct) + pct)
    y2 = (np.sqrt(3) / 2) * pct
    grid_lines.append({"x": x1, "y": y1, "xend": x2, "yend": y2})

    # Lines parallel to left side (constant silt)
    x1 = 0.5 * (2 * pct + (1 - pct))
    y1 = (np.sqrt(3) / 2) * (1 - pct)
    x2 = 0.5 * (2 * pct + 0)
    y2 = 0
    grid_lines.append({"x": x1, "y": y1, "xend": x2, "yend": y2})

    # Lines parallel to right side (constant sand)
    x1 = 0.5 * (2 * 0 + (1 - pct))
    y1 = (np.sqrt(3) / 2) * (1 - pct)
    x2 = 0.5 * (2 * (1 - pct) + 0)
    y2 = 0
    grid_lines.append({"x": x1, "y": y1, "xend": x2, "yend": y2})

grid_df = pd.DataFrame(grid_lines)

# Tick labels along edges
tick_labels = []
label_offset = 0.06
for pct in [0, 20, 40, 60, 80, 100]:
    frac = pct / 100
    # Sand axis (left edge going up)
    x = 0.5 * (2 * 0 + frac)
    y = (np.sqrt(3) / 2) * frac
    tick_labels.append({"x": x - label_offset, "y": y, "label": str(pct)})

    # Silt axis (bottom edge)
    x = 0.5 * (2 * frac + 0)
    y = 0
    tick_labels.append({"x": x, "y": y - label_offset * 0.8, "label": str(pct)})

    # Clay axis (right edge going up)
    x = 0.5 * (2 * (1 - frac) + frac)
    y = (np.sqrt(3) / 2) * frac
    tick_labels.append({"x": x + label_offset, "y": y, "label": str(pct)})

tick_df = pd.DataFrame(tick_labels)

# Vertex labels
vertex_labels = pd.DataFrame(
    {
        "x": [0 - 0.02, 1 + 0.02, 0.5],
        "y": [0 - 0.1, 0 - 0.1, np.sqrt(3) / 2 + 0.08],
        "label": ["Sand (%)", "Silt (%)", "Clay (%)"],
    }
)

# Build the plot
# Calculate tile size for proper coverage
tile_size = 1.0 / grid_resolution

plot = (
    ggplot()
    # Density heatmap using tiles
    + geom_tile(
        data=density_df,
        mapping=aes(x="x", y="y", fill="density"),
        width=tile_size * 1.1,
        height=tile_size * 1.1,
        alpha=0.85,
    )
    + scale_fill_cmap(cmap_name="viridis", name="Density")
    # Contour lines for key density levels using geom_density_2d
    + geom_density_2d(
        data=pd.DataFrame({"x": x_data, "y": y_data}),
        mapping=aes(x="x", y="y"),
        color="white",
        size=0.8,
        alpha=0.7,
        levels=6,
    )
    # Grid lines beneath density (already visible due to alpha)
    + geom_segment(
        data=grid_df, mapping=aes(x="x", y="y", xend="xend", yend="yend"), color="#ffffff", size=0.8, alpha=0.3
    )
    # Triangle frame on top
    + geom_polygon(data=vertices, mapping=aes(x="x", y="y"), fill=None, color="#306998", size=2.5)
    # Tick labels
    + geom_text(data=tick_df, mapping=aes(x="x", y="y", label="label"), size=14, color="#666666")
    # Vertex labels
    + geom_text(
        data=vertex_labels, mapping=aes(x="x", y="y", label="label"), size=18, fontweight="bold", color="#306998"
    )
    # Title and theme
    + labs(title="ternary-density · plotnine · pyplots.ai")
    + coord_fixed(ratio=1)
    + theme_void()
    + theme(
        figure_size=(12, 12),
        plot_title=element_text(size=28, ha="center", weight="bold"),
        plot_margin=0.02,
        legend_position="right",
        legend_title=element_text(size=18),
        legend_text=element_text(size=14),
        legend_key_size=40,
        legend_background=element_blank(),
    )
)

# Save
plot.save("plot.png", dpi=300, verbose=False)

Part of Ternary Density Plot on anyplot.ai.

Other implementations