Radial Dendrogram — Makie.jl

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.

Radial Dendrogram rendered with Makie.jl

Renders

Julia source (Makie.jl)

# anyplot.ai
# dendrogram-radial: Radial Dendrogram
# Library: makie 0.21.9 | Julia 1.11.9
# Quality: 88/100 | Created: 2026-09-05

using CairoMakie
using Colors
using Random

# Theme tokens (see prompts/default-style-guide.md "Background" + "Theme-adaptive Chrome")
THEME = get(ENV, "ANYPLOT_THEME", "light")
PAGE_BG = THEME == "light" ? colorant"#FAF8F1" : colorant"#1A1A17"
INK = THEME == "light" ? colorant"#1A1A17" : colorant"#F0EFE8"
INK_SOFT = THEME == "light" ? colorant"#4A4A44" : colorant"#B8B7B0"

IMPRINT_PALETTE = [
    colorant"#009E73",  # 1 — brand green, Immune module
    colorant"#C475FD",  # 2 — lavender, Metabolic module
    colorant"#4467A3",  # 3 — blue, Neural module
    colorant"#BD8233",  # 4 — ochre, Cardiac module
]

# Data — synthetic gene-expression profiles for four functional modules,
# clustered hierarchically from scratch (no Clustering.jl in the CI runtime).
Random.seed!(42)

group_names = ["Immune", "Metabolic", "Neural", "Cardiac"]
group_prefixes = ["IMM", "MET", "NEU", "CAR"]
group_means = [
    [3.0, 0.5, 0.2, 2.8, 0.4, 3.2],
    [0.3, 3.1, 2.9, 0.5, 3.0, 0.2],
    [2.9, 2.8, 0.3, 0.4, 0.3, 0.5],
    [0.4, 0.3, 3.0, 3.1, 2.9, 2.8],
]
n_per_group = 20
n = n_per_group * length(group_names)

features = Matrix{Float64}(undef, n, 6)
leaf_group = Vector{Int}(undef, n)
labels = Vector{String}(undef, n)

for g in 1:length(group_names), k in 1:n_per_group
    idx = (g - 1) * n_per_group + k
    features[idx, :] = group_means[g] .+ 0.7 .* randn(6)
    leaf_group[idx] = g
    labels[idx] = group_prefixes[g] * "-" * lpad(k, 2, "0")
end

# Agglomerative clustering (UPGMA / average linkage) via the Lance-Williams
# update — produces a scipy-style merge sequence (id1, id2, distance, new_id)
# without needing an external clustering package.
distkey(a, b) = a < b ? (a, b) : (b, a)

dist = Dict{Tuple{Int,Int},Float64}()
for i in 1:n, j in (i + 1):n
    dist[(i, j)] = sqrt(sum((features[i, :] .- features[j, :]) .^ 2))
end

sizes = Dict{Int,Int}(i => 1 for i in 1:n)
active = collect(1:n)
next_id = n + 1
merges = Tuple{Int,Int,Float64,Int}[]

while length(active) > 1
    best_d = Inf
    best_i = 0
    best_j = 0
    for a in 1:(length(active) - 1), b in (a + 1):length(active)
        p, q = active[a], active[b]
        d = dist[distkey(p, q)]
        if d < best_d
            best_d = d
            best_i = p
            best_j = q
        end
    end
    ni, nj = sizes[best_i], sizes[best_j]
    nid = next_id
    for k in active
        if k == best_i || k == best_j
            continue
        end
        d_ik = dist[distkey(best_i, k)]
        d_jk = dist[distkey(best_j, k)]
        dist[distkey(nid, k)] = (ni * d_ik + nj * d_jk) / (ni + nj)
    end
    sizes[nid] = ni + nj
    push!(merges, (best_i, best_j, best_d, nid))
    filter!(x -> x != best_i && x != best_j, active)
    push!(active, nid)
    global next_id += 1
end

root_id = merges[end][4]
max_height = merges[end][3]

children = Dict{Int,Tuple{Int,Int}}()
for (i, j, d, nid) in merges
    children[nid] = (i, j)
end

# Leaf order (iterative DFS, left child first) keeps sibling subtrees
# angularly contiguous so branches never cross.
order = Int[]
stack = [root_id]
while !isempty(stack)
    node = pop!(stack)
    if node <= n
        push!(order, node)
    else
        i, j = children[node]
        push!(stack, j)
        push!(stack, i)
    end
end

# Angle (leaves equally spaced, small gap at the top) and radius
# (proportional to merge distance, root at the center) per node.
R_max = 1.0
gap = deg2rad(6)
angle_span = 2 * pi - gap
start_angle = pi / 2 + gap / 2
angle_step = angle_span / (n - 1)

node_angle = Dict{Int,Float64}()
node_radius = Dict{Int,Float64}()
for (p, leaf) in enumerate(order)
    node_angle[leaf] = start_angle + angle_step * (p - 1)
    node_radius[leaf] = R_max
end
for (i, j, d, nid) in merges
    node_angle[nid] = (node_angle[i] + node_angle[j]) / 2
    node_radius[nid] = R_max * (1 - d / max_height)
end

# Pure-cluster color propagation: a node keeps its module color while every
# leaf beneath it shares the same module, else it falls back to neutral ink
# (mirrors the standard dendrogram color_threshold convention).
node_group = Dict{Int,Int}()
for leaf in 1:n
    node_group[leaf] = leaf_group[leaf]
end
for (i, j, d, nid) in merges
    gi, gj = node_group[i], node_group[j]
    node_group[nid] = gi == gj ? gi : 0
end

# Data storytelling: the distance at which each module's own leaves last
# coalesce into one pure branch tells us how internally tight that module is.
group_top_d = Dict{Int,Float64}()
for (i, j, d, nid) in merges
    g = node_group[nid]
    if g != 0
        group_top_d[g] = max(get(group_top_d, g, -Inf), d)
    end
end
tightest_group = argmin(group_top_d)
tightest_name = group_names[tightest_group]

# Plot — see default-style-guide.md "Visual Sizing Defaults" for the canvas + sizing values
title_text = "dendrogram-radial · julia · makie · anyplot.ai"

fig = Figure(
    resolution = (1200, 1200),
    fontsize = 14,
    backgroundcolor = PAGE_BG,
)

ax = Axis(
    fig[1, 1];
    title = title_text,
    titlesize = 26,
    titlecolor = INK,
    aspect = DataAspect(),
    backgroundcolor = PAGE_BG,
)
hidedecorations!(ax)
hidespines!(ax)

L = 1.32
limits!(ax, -L, L, -L, L)

for (i, j, d, nid) in merges
    rp = node_radius[nid]
    ai, aj = node_angle[i], node_angle[j]

    color_i = node_group[i] == 0 ? INK_SOFT : IMPRINT_PALETTE[node_group[i]]
    xi1, yi1 = node_radius[i] * cos(ai), node_radius[i] * sin(ai)
    xi2, yi2 = rp * cos(ai), rp * sin(ai)
    lines!(ax, [xi1, xi2], [yi1, yi2]; color = color_i, linewidth = 2.0)

    color_j = node_group[j] == 0 ? INK_SOFT : IMPRINT_PALETTE[node_group[j]]
    xj1, yj1 = node_radius[j] * cos(aj), node_radius[j] * sin(aj)
    xj2, yj2 = rp * cos(aj), rp * sin(aj)
    lines!(ax, [xj1, xj2], [yj1, yj2]; color = color_j, linewidth = 2.0)

    color_nid = node_group[nid] == 0 ? INK_SOFT : IMPRINT_PALETTE[node_group[nid]]
    a_lo, a_hi = ai < aj ? (ai, aj) : (aj, ai)
    arc!(ax, Point2f(0, 0), rp, a_lo, a_hi; color = color_nid, linewidth = 2.0)
end

# Color-coded outer ring: contiguous leaf runs sharing a module, one arc per run.
ring_r = R_max * 1.05
half_step = angle_step / 2
seg_start = 1
for p in 2:(n + 1)
    if p == n + 1 || leaf_group[order[p]] != leaf_group[order[seg_start]]
        g = leaf_group[order[seg_start]]
        a0 = node_angle[order[seg_start]] - half_step
        a1 = node_angle[order[p - 1]] + half_step
        arc!(ax, Point2f(0, 0), ring_r, a0, a1; color = IMPRINT_PALETTE[g], linewidth = 5)
        global seg_start = p
    end
end

leaf_x = [R_max * cos(node_angle[l]) for l in 1:n]
leaf_y = [R_max * sin(node_angle[l]) for l in 1:n]
leaf_colors = [IMPRINT_PALETTE[leaf_group[l]] for l in 1:n]
scatter!(ax, leaf_x, leaf_y; color = leaf_colors, markersize = 8, strokewidth = 0)

label_r = R_max * 1.16
for l in 1:n
    a = node_angle[l]
    flip = cos(a) < 0
    rot = flip ? a + pi : a
    halign = flip ? :right : :left
    text!(
        ax, label_r * cos(a), label_r * sin(a);
        text = labels[l], rotation = rot, align = (halign, :center),
        fontsize = 10, color = INK_SOFT,
    )
end

# Data-storytelling callout: the root's empty center has no branches to
# collide with, so it doubles as free space for the tightest-module insight.
text!(
    ax, 0, 0;
    text = "Tightest cluster:\n$(tightest_name)", align = (:center, :center),
    fontsize = 13, color = INK, justification = :center,
)

legend_elements = [LineElement(color = IMPRINT_PALETTE[g], linewidth = 4) for g in 1:4]
Legend(
    fig[2, 1], legend_elements, group_names, "Module";
    orientation = :horizontal, framevisible = false, labelcolor = INK, titlecolor = INK,
    labelsize = 14, titlesize = 16,
)
rowsize!(fig.layout, 1, Relative(0.92))
rowsize!(fig.layout, 2, Relative(0.08))

# Save
save("plot-$(THEME).png", fig; px_per_unit = 2)

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/dendrogram-radial/makie/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": "dendrogram-radial",
  "language": "julia",
  "library": "makie",
  "page": "https://anyplot.ai/dendrogram-radial/julia/makie",
  "hub": "https://anyplot.ai/dendrogram-radial",
  "code_json": "https://api.anyplot.ai/specs/dendrogram-radial/makie/code",
  "spec_json": "https://api.anyplot.ai/specs/dendrogram-radial",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/dendrogram-radial/julia/makie/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/dendrogram-radial/julia/makie/plot-dark.png",
  "quality_score": 88.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Radial Dendrogram on anyplot.ai.

Other implementations