Basic Ternary Plot — Pygal

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 Pygal

Renders

Python source (Pygal)

""" anyplot.ai
ternary-basic: Basic Ternary Plot
Library: pygal 3.1.3 | Python 3.13.14
Quality: 92/100 | Updated: 2026-08-04
"""

import math
import os
import re

import cairosvg
import pygal
from pygal.style import Style


THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

IMPRINT_PALETTE = ("#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314")
BRAND = IMPRINT_PALETTE[0]

H = math.sqrt(3) / 2

# (copper, tin, zinc) proportions, % — two groups so the plot can show a visual
# hierarchy: established named alloy families vs. exploratory interior points
# that survey the rest of the compositional space (a common ternary-diagram
# practice, distinct from a real commercial alloy).
named_compositions = [
    (88, 12, 0),  # traditional bronze
    (94, 6, 0),  # statuary bronze
    (78, 22, 0),  # bell bronze (bell metal)
    (90, 5, 5),  # gunmetal
    (85, 5, 10),  # admiralty gunmetal
    (70, 0, 30),  # cartridge brass
]
exploratory_compositions = [
    (60, 5, 35),
    (65, 25, 10),
    (55, 35, 10),
    (50, 10, 40),
    (33, 34, 33),
    (30, 35, 35),
    (24, 38, 38),
    (40, 40, 20),
    (20, 65, 15),
    (28, 22, 50),
]

# Barycentric (copper, tin, zinc) -> cartesian (x, y) on the unit equilateral triangle.
named_points = [(0.5 * (2 * s[1] + s[0]) / 100, H * s[0] / 100) for s in named_compositions]
exploratory_points = [(0.5 * (2 * s[1] + s[0]) / 100, H * s[0] / 100) for s in exploratory_compositions]

vertex_copper = (0.5, H)
vertex_tin = (1.0, 0.0)
vertex_zinc = (0.0, 0.0)

# Each entry is its own line segment — pygal's XY chart has no gap marker for a
# single series (a bare (None, None) point is silently dropped, not treated as a
# break, which would wrongly connect consecutive segments into one zig-zag path).
grid_segments = []
for pct in [0.2, 0.4, 0.6, 0.8]:
    grid_segments.append([(0.5 * (2 * (1 - pct) + pct), H * pct), (0.5 * pct, H * pct)])
    grid_segments.append([(0.5 * (2 * pct + (1 - pct)), H * (1 - pct)), (pct, 0.0)])
    grid_segments.append([(0.5 * (1 - pct), H * (1 - pct)), (0.5 * (2 * (1 - pct)), 0.0)])

tick_len = 0.03
tick_segments = []
for pct in [0.2, 0.4, 0.6, 0.8]:
    x_left, y_left = 0.5 * pct, H * pct
    tick_segments.append([(x_left, y_left), (x_left - tick_len, y_left)])

    x_right, y_right = 0.5 * (2 - pct), H * pct
    tick_segments.append([(x_right, y_right), (x_right + tick_len, y_right)])

    x_base = pct
    tick_segments.append([(x_base, 0.0), (x_base, -tick_len)])

title = "Bronze Alloy Composition · ternary-basic · python · pygal · anyplot.ai"
title_font_size = max(44, round(66 * min(1.0, 67 / len(title))))

# One chart.add() call per series (boundary, each grid segment, the two data
# groups, each tick segment) — Style.colors is indexed by that same series order.
series_colors = [INK] + [INK_MUTED] * len(grid_segments) + [BRAND, BRAND] + [INK] * len(tick_segments)

custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=tuple(series_colors),
    title_font_size=title_font_size,
    label_font_size=56,
    major_label_font_size=44,
    legend_font_size=44,
    value_font_size=36,
    stroke_width=2.5,
    opacity=0.85,
)

chart = pygal.XY(
    width=2400,
    height=2400,
    style=custom_style,
    show_legend=True,
    legend_at_bottom=True,
    show_x_guides=False,
    show_y_guides=False,
    show_x_labels=False,
    show_y_labels=False,
    x_title="",
    y_title="",
    title=title,
    stroke=False,
    include_x_axis=False,
    xrange=(-0.15, 1.15),
    yrange=(-0.20, 1.05),
    explicit_size=True,
    margin_top=290,
    margin_right=50,
    margin_bottom=120,
    margin_left=50,
)

chart.add(
    None, [vertex_zinc, vertex_tin, vertex_copper, vertex_zinc], stroke=True, show_dots=False, stroke_style={"width": 4}
)

for segment in grid_segments:
    chart.add(None, segment, stroke=True, show_dots=False, stroke_style={"width": 1.5, "dasharray": "8,5"})

# Larger dots for named alloy families, smaller for exploratory survey points —
# a size-based hierarchy (same brand color, so palette compliance is untouched
# and both groups stay theme-invariant) that makes the two-category story in
# the data visible in the image itself.
chart.add("Named Alloys", named_points, stroke=False, dots_size=27)
chart.add("Survey Points", exploratory_points, stroke=False, dots_size=14)

for segment in tick_segments:
    chart.add(None, segment, stroke=True, show_dots=False, stroke_style={"width": 2.5})

svg_content = chart.render().decode("utf-8")

# Derive the exact data-space -> pixel-space affine transform from the boundary
# triangle pygal already rendered (serie-0), instead of hand-calibrating offsets.
# The series live inside a translated <g> ("plot overlay"); its offset must be
# folded in since the labels below are inserted as untransformed top-level SVG.
overlay_dx, overlay_dy = (
    float(v) for v in re.search(r'translate\(([\d.\-]+), ?([\d.\-]+)\)"\s*class="plot overlay"', svg_content).groups()
)

boundary_segment = svg_content[
    svg_content.index('<g class="series serie-0') : svg_content.index('<g class="series serie-1')
]
path_d = re.search(r'<path d="([^"]+)"', boundary_segment).group(1)
zinc_px, tin_px, copper_px, _ = re.findall(r"(-?\d+\.\d+) (-?\d+\.\d+)", path_d)
zinc_px = tuple(float(v) for v in zinc_px)
tin_px = tuple(float(v) for v in tin_px)
copper_px = tuple(float(v) for v in copper_px)

origin_x, origin_y = zinc_px[0] + overlay_dx, zinc_px[1] + overlay_dy
scale_x = tin_px[0] - zinc_px[0]
scale_y = (zinc_px[1] - copper_px[1]) / H


def to_px(x, y):
    return origin_x + scale_x * x, origin_y - scale_y * y


vertex_labels = [
    ("COPPER (%)", (0.5, H + 0.07), "middle"),
    ("TIN (%)", (1.0 + 0.05, -0.05), "start"),
    ("ZINC (%)", (0.0 - 0.05, -0.05), "end"),
]

vertex_labels_svg = ""
for name, (x, y), anchor in vertex_labels:
    px, py = to_px(x, y)
    vertex_labels_svg += (
        f'  <text x="{px:.1f}" y="{py:.1f}" text-anchor="{anchor}" font-size="52" '
        f'font-weight="bold" fill="{INK}" font-family="sans-serif">{name}</text>\n'
    )

pct_labels_svg = ""
for pct in [20, 40, 60, 80]:
    frac = pct / 100.0

    px, py = to_px(0.5 * frac - 0.055, H * frac)
    pct_labels_svg += (
        f'  <text x="{px:.1f}" y="{py:.1f}" text-anchor="end" font-size="42" '
        f'fill="{INK_MUTED}" font-family="sans-serif">{pct}</text>\n'
    )

    px, py = to_px(0.5 * (2 - frac) + 0.045, H * frac)
    pct_labels_svg += (
        f'  <text x="{px:.1f}" y="{py:.1f}" text-anchor="start" font-size="42" '
        f'fill="{INK_MUTED}" font-family="sans-serif">{pct}</text>\n'
    )

    px, py = to_px(frac, -0.05)
    pct_labels_svg += (
        f'  <text x="{px:.1f}" y="{py:.1f}" text-anchor="middle" font-size="42" '
        f'fill="{INK_MUTED}" font-family="sans-serif">{pct}</text>\n'
    )

svg_content = svg_content.replace("</svg>", vertex_labels_svg + pct_labels_svg + "</svg>")

with open(f"plot-{THEME}.html", "w") as f:
    f.write(svg_content)

cairosvg.svg2png(bytestring=svg_content.encode("utf-8"), write_to=f"plot-{THEME}.png")

Retrieve this implementation

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