A radial dendrogram renders hierarchical clustering in a circular layout where the root node sits at the center and branches extend outward, with leaf nodes arranged around the circumference. This layout is a space-efficient alternative to linear dendrograms for large hierarchies, making it well-suited for datasets with hundreds of leaves. Branch lengths are proportional to distance or dissimilarity, preserving the quantitative interpretation of cluster merges.

""" anyplot.ai
dendrogram-radial: Radial Dendrogram
Library: pygal 3.1.0 | Python 3.13.13
Quality: 81/100 | Created: 2026-05-14
"""
import importlib
import math
import os
import sys
import cairosvg
import numpy as np
from scipy.cluster.hierarchy import leaves_list, linkage
from scipy.spatial.distance import pdist
# Remove current directory from sys.path so loading 'pygal' finds the installed
# package rather than this script (which is also named pygal.py), then use
# importlib to avoid E402 import-not-at-top violations for the two pygal lines.
_thisdir = os.path.dirname(os.path.abspath(__file__))
sys.path = [p for p in sys.path if p not in ("", _thisdir)]
pygal = importlib.import_module("pygal")
Style = importlib.import_module("pygal.style").Style
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"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
IMPRINT = ("#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477")
custom_style = Style(
background=PAGE_BG,
plot_background=PAGE_BG,
foreground=INK,
foreground_strong=INK,
foreground_subtle=INK_MUTED,
colors=IMPRINT,
title_font_size=42,
legend_font_size=26,
label_font_size=28,
)
# Data: 24 animal species clustered by ecological traits
np.random.seed(42)
species = [
"Dolphin",
"Whale",
"Seal",
"Sea Lion",
"Wolf",
"Fox",
"Dog",
"Coyote",
"Lion",
"Tiger",
"Leopard",
"Cheetah",
"Eagle",
"Hawk",
"Falcon",
"Osprey",
"Frog",
"Toad",
"Salamander",
"Newt",
"Salmon",
"Trout",
"Bass",
"Pike",
]
group_names = ["Marine Mammals", "Canines", "Felines", "Raptors", "Amphibians", "Fish"]
cluster_ids = [0] * 4 + [1] * 4 + [2] * 4 + [3] * 4 + [4] * 4 + [5] * 4
# Trait matrix: [body_size, aquatic_affinity, speed, sociality, carnivory]
traits = np.array(
[
[4, 5, 3, 4, 4],
[5, 5, 2, 3, 4],
[3, 4, 2, 2, 4],
[3, 4, 2, 3, 4],
[3, 1, 4, 5, 4],
[2, 1, 4, 1, 4],
[2, 1, 3, 3, 3],
[2, 1, 4, 1, 4],
[4, 1, 4, 3, 5],
[4, 1, 4, 1, 5],
[3, 1, 4, 1, 5],
[3, 1, 5, 1, 5],
[2, 2, 5, 1, 4],
[2, 2, 4, 1, 4],
[1, 2, 5, 1, 4],
[2, 2, 3, 1, 4],
[1, 3, 2, 1, 3],
[1, 3, 1, 1, 3],
[1, 3, 1, 1, 3],
[1, 3, 1, 1, 3],
[2, 5, 4, 1, 3],
[2, 5, 3, 1, 3],
[2, 5, 2, 1, 3],
[2, 4, 2, 1, 4],
],
dtype=float,
)
# Hierarchical clustering via Ward linkage
dist_matrix = pdist(traits, metric="euclidean")
Z = linkage(dist_matrix, method="ward")
n = len(species)
# Assign angles to leaves using dendrogram leaf ordering
leaf_order = leaves_list(Z)
leaf_angles = {leaf_order[i]: 2 * math.pi * i / n for i in range(n)}
# Radii: leaves at outer edge (1.0), internal nodes proportional to merge distance
max_dist = Z[-1][2]
node_radii = dict.fromkeys(range(n), 1.0)
for i, row in enumerate(Z):
node_radii[n + i] = 1.0 - row[2] / max_dist
# Angular positions: midpoint of each subtree's angular span
node_min_ang = dict(leaf_angles)
node_max_ang = dict(leaf_angles)
node_angles = dict(leaf_angles)
for i, row in enumerate(Z):
left, right = int(row[0]), int(row[1])
nid = n + i
node_min_ang[nid] = min(node_min_ang[left], node_min_ang[right])
node_max_ang[nid] = max(node_max_ang[left], node_max_ang[right])
node_angles[nid] = (node_min_ang[nid] + node_max_ang[nid]) / 2
# Propagate cluster labels: a node gets a color only if all its leaves share one cluster
node_cluster = {i: cluster_ids[i] for i in range(n)}
for i, row in enumerate(Z):
left, right = int(row[0]), int(row[1])
nid = n + i
cl, cr = node_cluster.get(left, -1), node_cluster.get(right, -1)
node_cluster[nid] = cl if cl == cr else -1
# Canvas setup
W, H = 4800, 2700
cx, cy = W // 2, H // 2
R = 900 # Dendrogram outer radius in pixels
LABEL_R = R + 68 # Label placement radius
def polar_xy(r, theta):
return (cx + r * math.cos(theta - math.pi / 2), cy + r * math.sin(theta - math.pi / 2))
elems = []
# Draw dendrogram branches (arcs + radial lines)
for i, row in enumerate(Z):
left, right = int(row[0]), int(row[1])
nid = n + i
par_r = node_radii[nid] * R
par_theta = node_angles[nid]
for child in (left, right):
ch_r = node_radii[child] * R
ch_theta = node_angles[child]
cl = node_cluster.get(child, -1)
color = IMPRINT[cl] if cl >= 0 else INK_SOFT
sw = 6 if child < n else 4
# Arc at par_r from par_theta to ch_theta
if par_r > 1:
x1, y1 = polar_xy(par_r, par_theta)
x2, y2 = polar_xy(par_r, ch_theta)
d_theta = ch_theta - par_theta
while d_theta > math.pi:
d_theta -= 2 * math.pi
while d_theta < -math.pi:
d_theta += 2 * math.pi
sweep = 1 if d_theta > 0 else 0
large = 1 if abs(d_theta) > math.pi else 0
elems.append(
f'<path d="M{x1:.1f},{y1:.1f} A{par_r:.1f},{par_r:.1f} 0 {large},{sweep} {x2:.1f},{y2:.1f}" '
f'stroke="{color}" stroke-width="{sw}" fill="none" stroke-linecap="round" opacity="0.88"/>'
)
# Radial line from (par_r, ch_theta) to (ch_r, ch_theta)
rx1, ry1 = polar_xy(max(par_r, 1), ch_theta)
rx2, ry2 = polar_xy(ch_r, ch_theta)
elems.append(
f'<line x1="{rx1:.1f}" y1="{ry1:.1f}" x2="{rx2:.1f}" y2="{ry2:.1f}" '
f'stroke="{color}" stroke-width="{sw}" stroke-linecap="round" opacity="0.88"/>'
)
# Center root dot
elems.append(f'<circle cx="{cx}" cy="{cy}" r="12" fill="{INK_SOFT}" opacity="0.55"/>')
# Leaf dots and radially-oriented labels
for i in range(n):
theta = leaf_angles[i]
lx, ly = polar_xy(R, theta)
color = IMPRINT[cluster_ids[i]]
elems.append(f'<circle cx="{lx:.1f}" cy="{ly:.1f}" r="15" fill="{color}" opacity="0.95"/>')
label_x, label_y = polar_xy(LABEL_R, theta)
svg_angle = math.degrees(theta - math.pi / 2) % 360
if svg_angle <= 90 or svg_angle >= 270:
anchor, rot = "start", svg_angle
else:
anchor, rot = "end", svg_angle + 180
elems.append(
f'<text x="{label_x:.1f}" y="{label_y:.1f}" '
f'transform="rotate({rot:.1f},{label_x:.1f},{label_y:.1f})" '
f'text-anchor="{anchor}" dominant-baseline="middle" '
f'font-family="sans-serif" font-size="28" fill="{color}" font-weight="500">'
f"{species[i]}</text>"
)
# Legend
lx0, ly0 = 110, 230
elems.append(
f'<text x="{lx0}" y="{ly0}" font-family="sans-serif" font-size="30" '
f'fill="{INK}" font-weight="600">Taxonomic Groups</text>'
)
for g in range(6):
gy = ly0 + 55 + g * 68
elems.append(f'<circle cx="{lx0 + 16}" cy="{gy}" r="15" fill="{IMPRINT[g]}"/>')
elems.append(
f'<text x="{lx0 + 44}" y="{gy}" font-family="sans-serif" font-size="26" '
f'fill="{INK}" dominant-baseline="middle">{group_names[g]}</text>'
)
# Title
elems.append(
f'<text x="{W // 2}" y="72" text-anchor="middle" font-family="sans-serif" '
f'font-size="42" fill="{INK}" font-weight="600">'
f"dendrogram-radial · pygal · anyplot.ai</text>"
)
svg = (
'<?xml version="1.0" encoding="utf-8"?>\n'
f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}">\n'
f' <rect width="{W}" height="{H}" fill="{PAGE_BG}"/>\n' + "\n".join(f" {e}" for e in elems) + "\n</svg>\n"
)
# PNG via cairosvg (pygal's PNG rendering backend)
cairosvg.svg2png(bytestring=svg.encode(), write_to=f"plot-{THEME}.png")
# Interactive HTML via pygal's XY chart — leaf nodes plotted as interactive scatter
# with hover tooltips (species name + group); uses pygal's JS rendering pipeline
xy_chart = pygal.XY(
style=custom_style,
width=W,
height=H,
title="dendrogram-radial · pygal · anyplot.ai",
show_x_labels=False,
show_y_labels=False,
dots_size=6,
)
for g in range(6):
group_pts = []
for i in range(n):
if cluster_ids[i] == g:
theta = leaf_angles[i]
group_pts.append(
{"value": (R * math.cos(theta - math.pi / 2), R * math.sin(theta - math.pi / 2)), "label": species[i]}
)
xy_chart.add(group_names[g], group_pts)
with open(f"plot-{THEME}.html", "wb") as f:
f.write(xy_chart.render())
Part of Radial Dendrogram on anyplot.ai.