Ternary Density Plot — Bokeh

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 Bokeh

Python source (Bokeh)

""" anyplot.ai
ternary-density: Ternary Density Plot
Library: bokeh 3.9.0 | Python 3.13.13
Quality: 72/100 | Updated: 2026-05-19
"""

import numpy as np
from bokeh.io import export_png
from bokeh.models import ColorBar, Label, LinearColorMapper
from bokeh.plotting import figure
from scipy.stats import gaussian_kde


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

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

# Cluster 2: Silty sediments (high silt)
n2 = 150
silt2 = np.random.beta(5, 2, n2) * 70 + 20
sand2 = np.random.beta(2, 3, n2) * (100 - silt2) * 0.5
clay2 = 100 - sand2 - silt2

# Cluster 3: Clayey sediments (high clay)
n3 = 150
clay3 = np.random.beta(5, 2, n3) * 60 + 30
sand3 = np.random.beta(2, 3, 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])

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


# Convert ternary to Cartesian coordinates
def ternary_to_cartesian(a, b, c):
    """Convert ternary coordinates (a, b, c) to Cartesian (x, y)."""
    total = a + b + c
    a, b, c = a / total, b / total, c / total
    x = 0.5 * (2 * b + c)
    y = (np.sqrt(3) / 2) * c
    return x, y


# Transform data points
x_data, y_data = ternary_to_cartesian(sand, silt, clay)

# Create density grid
grid_resolution = 200
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)

# Create mask for valid ternary region (inside the triangle)
# Triangle vertices: (0, 0), (1, 0), (0.5, sqrt(3)/2)
mask = np.zeros_like(xx, dtype=bool)
for i in range(grid_resolution):
    for j in range(grid_resolution):
        px, py = xx[i, j], yy[i, j]
        # Check if point is inside triangle using barycentric coordinates
        # Left edge: y <= sqrt(3) * x
        # Right edge: y <= sqrt(3) * (1 - x)
        # Bottom edge: y >= 0
        if py >= 0 and py <= np.sqrt(3) * px + 1e-6 and py <= np.sqrt(3) * (1 - px) + 1e-6:
            mask[i, j] = True

# Compute kernel density estimation
points = np.vstack([x_data, y_data])
kde = gaussian_kde(points, bw_method="scott")

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

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

# Create figure (4800 x 2700 for landscape format)
p = figure(
    width=4800,
    height=2700,
    title="Sediment Composition · ternary-density · bokeh · pyplots.ai",
    x_range=(-0.15, 1.15),
    y_range=(-0.12, 1.05),
    tools="",
    toolbar_location=None,
)

# Remove axes and grid (we'll draw our own ternary grid)
p.xaxis.visible = False
p.yaxis.visible = False
p.xgrid.visible = False
p.ygrid.visible = False
p.outline_line_color = None

# Color mapper for density
color_mapper = LinearColorMapper(
    palette="Viridis256", low=np.nanmin(density_masked), high=np.nanmax(density_masked), nan_color="rgba(0, 0, 0, 0)"
)

# Plot density as image
p.image(image=[density_masked], x=0, y=0, dw=1, dh=np.sqrt(3) / 2, color_mapper=color_mapper, alpha=0.85)

# Triangle vertices
tri_x = [0, 1, 0.5, 0]
tri_y = [0, 0, np.sqrt(3) / 2, 0]

# Draw triangle outline
p.line(tri_x, tri_y, line_width=4, line_color="#306998", line_alpha=0.9)

# Draw grid lines inside triangle
grid_alpha = 0.4
grid_width = 2
grid_color = "#306998"

# Draw lines parallel to each edge (10%, 20%, ..., 90%)
for pct in [0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9]:
    # Lines parallel to bottom edge (constant clay %)
    y_line = pct * np.sqrt(3) / 2
    x_left = pct / 2
    x_right = 1 - pct / 2
    p.line([x_left, x_right], [y_line, y_line], line_width=grid_width, line_color=grid_color, line_alpha=grid_alpha)

    # Lines parallel to left edge (constant silt %)
    x_start = pct
    y_start = 0
    x_end = pct / 2 + 0.5
    y_end = (1 - pct) * np.sqrt(3) / 2
    p.line([x_start, x_end], [y_start, y_end], line_width=grid_width, line_color=grid_color, line_alpha=grid_alpha)

    # Lines parallel to right edge (constant sand %)
    x_start = 1 - pct
    y_start = 0
    x_end = (1 - pct) / 2
    y_end = pct * np.sqrt(3) / 2
    p.line([x_start, x_end], [y_start, y_end], line_width=grid_width, line_color=grid_color, line_alpha=grid_alpha)

# Add vertex labels
label_font_size = "32pt"
label_offset = 0.08

# Sand (bottom left)
sand_label = Label(
    x=0 - label_offset,
    y=0 - label_offset / 2,
    text="Sand",
    text_font_size=label_font_size,
    text_color="#306998",
    text_font_style="bold",
    text_align="center",
)
p.add_layout(sand_label)

# Silt (bottom right)
silt_label = Label(
    x=1 + label_offset,
    y=0 - label_offset / 2,
    text="Silt",
    text_font_size=label_font_size,
    text_color="#306998",
    text_font_style="bold",
    text_align="center",
)
p.add_layout(silt_label)

# Clay (top)
clay_label = Label(
    x=0.5,
    y=np.sqrt(3) / 2 + label_offset,
    text="Clay",
    text_font_size=label_font_size,
    text_color="#306998",
    text_font_style="bold",
    text_align="center",
)
p.add_layout(clay_label)

# Add percentage labels along edges
tick_font_size = "18pt"

# Bottom edge (Sand percentage, from left to right is 100% to 0%)
for pct in [20, 40, 60, 80]:
    x_pos = pct / 100
    p.add_layout(
        Label(
            x=x_pos,
            y=-0.05,
            text=f"{100 - pct}%",
            text_font_size=tick_font_size,
            text_color="#666666",
            text_align="center",
        )
    )

# Left edge (Clay percentage, from bottom to top is 0% to 100%)
for pct in [20, 40, 60, 80]:
    x_pos = pct / 100 / 2
    y_pos = pct / 100 * np.sqrt(3) / 2
    p.add_layout(
        Label(
            x=x_pos - 0.06,
            y=y_pos,
            text=f"{pct}%",
            text_font_size=tick_font_size,
            text_color="#666666",
            text_align="right",
        )
    )

# Right edge (Silt percentage)
for pct in [20, 40, 60, 80]:
    x_pos = 1 - pct / 100 / 2
    y_pos = pct / 100 * np.sqrt(3) / 2
    p.add_layout(
        Label(
            x=x_pos + 0.06,
            y=y_pos,
            text=f"{pct}%",
            text_font_size=tick_font_size,
            text_color="#666666",
            text_align="left",
        )
    )

# Add color bar
color_bar = ColorBar(
    color_mapper=color_mapper,
    width=40,
    location=(0, 0),
    title="Density",
    title_text_font_size="24pt",
    major_label_text_font_size="18pt",
    title_text_color="#306998",
    major_label_text_color="#666666",
    title_standoff=15,
    margin=20,
)
p.add_layout(color_bar, "right")

# Title styling
p.title.text_font_size = "36pt"
p.title.text_color = "#306998"
p.title.align = "center"

# Save
export_png(p, filename="plot.png")

Part of Ternary Density Plot on anyplot.ai.

Other implementations