Basic Ternary Plot — Bokeh

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 Bokeh

Renders

Python source (Bokeh)

""" anyplot.ai
ternary-basic: Basic Ternary Plot
Library: bokeh 3.9.2 | Python 3.13.14
Quality: 92/100 | Created: 2026-08-04
"""

import os
import time
from pathlib import Path

import numpy as np
from bokeh.io import output_file, save
from bokeh.models import ColumnDataSource, HoverTool, Label
from bokeh.plotting import figure
from selenium import webdriver
from selenium.webdriver.chrome.options import Options


THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
BRAND = "#009E73"  # Imprint palette position 1

# Data - Soil composition samples (Sand, Silt, Clay)
np.random.seed(42)
n_points = 50

# Generate random compositions that sum to 100%
raw = np.random.dirichlet(alpha=[2, 2, 2], size=n_points) * 100
sand = raw[:, 0]
silt = raw[:, 1]
clay = raw[:, 2]

# Compositional "purity" (distance from the balanced 1/3-1/3-1/3 centroid) —
# drives the size/opacity emphasis below so near-pure samples pop forward
# and balanced (loam-like) samples recede, surfacing the clustering pattern
# instead of a flat, uniform scatter.
dominance = raw.max(axis=1) / 100
purity = np.clip((dominance - 1 / 3) / (1 - 1 / 3), 0, 1)
marker_size = 12 + purity * 16
marker_alpha = 0.55 + purity * 0.35

# Most extreme sample — the single highest-purity point becomes the plot's
# explicit focal callout, giving viewers a concrete entry point beyond the
# implicit size/alpha gradient.
idx_extreme = int(np.argmax(purity))
extreme_component = ["Sand", "Silt", "Clay"][int(np.argmax(raw[idx_extreme]))]
extreme_pct = raw[idx_extreme].max()


# Convert ternary coordinates to Cartesian (equilateral triangle)
def ternary_to_cartesian(a, b, c):
    """Convert ternary coordinates (a, b, c) to Cartesian (x, y).
    Triangle vertices: bottom-left (1,0,0), bottom-right (0,1,0), top (0,0,1)
    """
    total = a + b + c
    b_norm = b / total
    c_norm = c / total
    x = 0.5 * (2 * b_norm + c_norm)
    y = (np.sqrt(3) / 2) * c_norm
    return x, y


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

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

# Create figure. Square canvas: a ternary plot has no preferred horizontal
# axis. Equal-span ranges below (x: -0.12..1.12, y: -0.15..1.09, both span
# 1.24) paired with symmetric min_border on a square figure keep the pixel
# scale uniform in x and y, so the triangle renders truly equilateral.
# HoverTool works as an active inspector even with toolbar_location=None (it
# only shows a toolbar *button*, not the hover behavior), so the static PNG
# render is unaffected while the HTML artifact gains sand/silt/clay tooltips.
hover = HoverTool(tooltips=[("Sand", "@sand{0.0}%"), ("Silt", "@silt{0.0}%"), ("Clay", "@clay{0.0}%")])

p = figure(
    width=2400,
    height=2400,
    title="Soil Composition · ternary-basic · python · bokeh · anyplot.ai",
    x_range=(-0.12, 1.12),
    y_range=(-0.15, 1.09),
    tools=[hover],
    toolbar_location=None,  # IMPORTANT: default toolbar adds ~30-50px, shrinking the saved PNG
    min_border_left=60,
    min_border_right=60,
    min_border_top=60,
    min_border_bottom=60,
)

# Theme styling
p.background_fill_color = PAGE_BG
p.border_fill_color = PAGE_BG
p.outline_line_color = None

# Remove default axes
p.xaxis.visible = False
p.yaxis.visible = False
p.xgrid.visible = False
p.ygrid.visible = False

# Draw triangle outline
p.line(tri_x, tri_y, line_width=3, color=INK_SOFT)

# Draw grid lines at 20% intervals
grid_color = INK_SOFT
grid_alpha = 0.15
grid_width = 1.5

for pct in [20, 40, 60, 80]:
    frac = pct / 100

    # Lines parallel to each side
    a1, b1, c1 = frac, 1 - frac, 0
    a2, b2, c2 = frac, 0, 1 - frac
    x1, y1 = ternary_to_cartesian(a1, b1, c1)
    x2, y2 = ternary_to_cartesian(a2, b2, c2)
    p.line([x1, x2], [y1, y2], line_width=grid_width, color=grid_color, alpha=grid_alpha)

    a1, b1, c1 = 1 - frac, frac, 0
    a2, b2, c2 = 0, frac, 1 - frac
    x1, y1 = ternary_to_cartesian(a1, b1, c1)
    x2, y2 = ternary_to_cartesian(a2, b2, c2)
    p.line([x1, x2], [y1, y2], line_width=grid_width, color=grid_color, alpha=grid_alpha)

    a1, b1, c1 = 1 - frac, 0, frac
    a2, b2, c2 = 0, 1 - frac, frac
    x1, y1 = ternary_to_cartesian(a1, b1, c1)
    x2, y2 = ternary_to_cartesian(a2, b2, c2)
    p.line([x1, x2], [y1, y2], line_width=grid_width, color=grid_color, alpha=grid_alpha)

# Add tick labels along each edge
tick_font_size = "34pt"
tick_offset = 0.045

for pct in [0, 20, 40, 60, 80, 100]:
    frac = pct / 100

    x_tick, y_tick = ternary_to_cartesian(1 - frac, frac, 0)
    label = Label(
        x=x_tick,
        y=y_tick - tick_offset,
        text=f"{int(100 - pct)}",
        text_font_size=tick_font_size,
        text_color=INK_SOFT,
        text_align="center",
        text_baseline="top",
    )
    p.add_layout(label)

    x_tick, y_tick = ternary_to_cartesian(0, 1 - frac, frac)
    label = Label(
        x=x_tick + tick_offset * 0.8,
        y=y_tick + tick_offset * 0.5,
        text=f"{int(100 - pct)}",
        text_font_size=tick_font_size,
        text_color=INK_SOFT,
        text_align="left",
        text_baseline="middle",
    )
    p.add_layout(label)

    x_tick, y_tick = ternary_to_cartesian(frac, 0, 1 - frac)
    label = Label(
        x=x_tick - tick_offset * 0.8,
        y=y_tick + tick_offset * 0.5,
        text=f"{int(100 - pct)}",
        text_font_size=tick_font_size,
        text_color=INK_SOFT,
        text_align="right",
        text_baseline="middle",
    )
    p.add_layout(label)

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

sand_label = Label(
    x=0 - label_offset,
    y=0 - label_offset,
    text="Sand",
    text_font_size=label_font_size,
    text_font_style="bold",
    text_color=INK,
    text_align="center",
    text_baseline="top",
)
p.add_layout(sand_label)

silt_label = Label(
    x=1 + label_offset,
    y=0 - label_offset,
    text="Silt",
    text_font_size=label_font_size,
    text_font_style="bold",
    text_color=INK,
    text_align="center",
    text_baseline="top",
)
p.add_layout(silt_label)

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

# Plot data points — size and opacity scale with compositional purity so
# near-pure (single-component-dominant) samples read as prominent, distinct
# markers while balanced/loam-like samples recede into the cluster.
source = ColumnDataSource(
    data={
        "x": x_data,
        "y": y_data,
        "sand": sand,
        "silt": silt,
        "clay": clay,
        "size": marker_size,
        "alpha": marker_alpha,
    }
)

p.scatter(x="x", y="y", source=source, size="size", color=BRAND, fill_alpha="alpha", line_color=PAGE_BG, line_width=1.5)

# Ring the single most extreme (highest-purity) sample and annotate it —
# an explicit focal callout so the viewer has a concrete entry point into
# the composition space, not just the implicit size/alpha gradient.
p.scatter(
    x=[x_data[idx_extreme]],
    y=[y_data[idx_extreme]],
    size=marker_size[idx_extreme] + 14,
    fill_color=None,
    line_color=INK,
    line_width=2.5,
)
extreme_label = Label(
    x=x_data[idx_extreme],
    y=y_data[idx_extreme] + 0.05,
    text=f"{extreme_pct:.0f}% {extreme_component}",
    text_font_size="30pt",
    text_font_style="bold",
    text_color=INK,
    text_align="center",
    text_baseline="bottom",
)
p.add_layout(extreme_label)

# Style title
p.title.text_font_size = "50pt"
p.title.text_color = INK
p.title.align = "center"

# Save HTML
output_file(f"plot-{THEME}.html")
save(p)

# Screenshot with headless Chrome
W, H = 2400, 2400
opts = Options()
for arg in (
    "--headless=new",
    "--no-sandbox",
    "--disable-dev-shm-usage",
    "--disable-gpu",
    f"--window-size={W},{H}",
    "--hide-scrollbars",
):
    opts.add_argument(arg)
driver = webdriver.Chrome(options=opts)
driver.set_window_size(W, H)
driver.get(f"file://{Path(f'plot-{THEME}.html').resolve()}")
# IMPORTANT: headless Chrome's --window-size sets the OUTER window, which
# still reserves a phantom title-bar height even headless; pin the viewport
# exactly via CDP so the screenshot matches W x H.
driver.execute_cdp_cmd(
    "Emulation.setDeviceMetricsOverride", {"width": W, "height": H, "deviceScaleFactor": 1, "mobile": False}
)
time.sleep(3)
driver.save_screenshot(f"plot-{THEME}.png")
driver.quit()

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/ternary-basic/bokeh/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-basic",
  "language": "python",
  "library": "bokeh",
  "page": "https://anyplot.ai/ternary-basic/python/bokeh",
  "hub": "https://anyplot.ai/ternary-basic",
  "code_json": "https://api.anyplot.ai/specs/ternary-basic/bokeh/code",
  "spec_json": "https://api.anyplot.ai/specs/ternary-basic",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/ternary-basic/python/bokeh/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/ternary-basic/python/bokeh/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/ternary-basic/python/bokeh/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/ternary-basic/python/bokeh/plot-dark.html",
  "quality_score": 92.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Basic Ternary Plot on anyplot.ai.

Other implementations