{"spec_id":"dendrogram-radial","library":"makie","language":"julia","code":"# anyplot.ai\n# dendrogram-radial: Radial Dendrogram\n# Library: makie 0.21.9 | Julia 1.11.9\n# Quality: 88/100 | Created: 2026-09-05\n\nusing CairoMakie\nusing Colors\nusing Random\n\n# Theme tokens (see prompts/default-style-guide.md \"Background\" + \"Theme-adaptive Chrome\")\nTHEME = get(ENV, \"ANYPLOT_THEME\", \"light\")\nPAGE_BG = THEME == \"light\" ? colorant\"#FAF8F1\" : colorant\"#1A1A17\"\nINK = THEME == \"light\" ? colorant\"#1A1A17\" : colorant\"#F0EFE8\"\nINK_SOFT = THEME == \"light\" ? colorant\"#4A4A44\" : colorant\"#B8B7B0\"\n\nIMPRINT_PALETTE = [\n    colorant\"#009E73\",  # 1 — brand green, Immune module\n    colorant\"#C475FD\",  # 2 — lavender, Metabolic module\n    colorant\"#4467A3\",  # 3 — blue, Neural module\n    colorant\"#BD8233\",  # 4 — ochre, Cardiac module\n]\n\n# Data — synthetic gene-expression profiles for four functional modules,\n# clustered hierarchically from scratch (no Clustering.jl in the CI runtime).\nRandom.seed!(42)\n\ngroup_names = [\"Immune\", \"Metabolic\", \"Neural\", \"Cardiac\"]\ngroup_prefixes = [\"IMM\", \"MET\", \"NEU\", \"CAR\"]\ngroup_means = [\n    [3.0, 0.5, 0.2, 2.8, 0.4, 3.2],\n    [0.3, 3.1, 2.9, 0.5, 3.0, 0.2],\n    [2.9, 2.8, 0.3, 0.4, 0.3, 0.5],\n    [0.4, 0.3, 3.0, 3.1, 2.9, 2.8],\n]\nn_per_group = 20\nn = n_per_group * length(group_names)\n\nfeatures = Matrix{Float64}(undef, n, 6)\nleaf_group = Vector{Int}(undef, n)\nlabels = Vector{String}(undef, n)\n\nfor g in 1:length(group_names), k in 1:n_per_group\n    idx = (g - 1) * n_per_group + k\n    features[idx, :] = group_means[g] .+ 0.7 .* randn(6)\n    leaf_group[idx] = g\n    labels[idx] = group_prefixes[g] * \"-\" * lpad(k, 2, \"0\")\nend\n\n# Agglomerative clustering (UPGMA / average linkage) via the Lance-Williams\n# update — produces a scipy-style merge sequence (id1, id2, distance, new_id)\n# without needing an external clustering package.\ndistkey(a, b) = a < b ? (a, b) : (b, a)\n\ndist = Dict{Tuple{Int,Int},Float64}()\nfor i in 1:n, j in (i + 1):n\n    dist[(i, j)] = sqrt(sum((features[i, :] .- features[j, :]) .^ 2))\nend\n\nsizes = Dict{Int,Int}(i => 1 for i in 1:n)\nactive = collect(1:n)\nnext_id = n + 1\nmerges = Tuple{Int,Int,Float64,Int}[]\n\nwhile length(active) > 1\n    best_d = Inf\n    best_i = 0\n    best_j = 0\n    for a in 1:(length(active) - 1), b in (a + 1):length(active)\n        p, q = active[a], active[b]\n        d = dist[distkey(p, q)]\n        if d < best_d\n            best_d = d\n            best_i = p\n            best_j = q\n        end\n    end\n    ni, nj = sizes[best_i], sizes[best_j]\n    nid = next_id\n    for k in active\n        if k == best_i || k == best_j\n            continue\n        end\n        d_ik = dist[distkey(best_i, k)]\n        d_jk = dist[distkey(best_j, k)]\n        dist[distkey(nid, k)] = (ni * d_ik + nj * d_jk) / (ni + nj)\n    end\n    sizes[nid] = ni + nj\n    push!(merges, (best_i, best_j, best_d, nid))\n    filter!(x -> x != best_i && x != best_j, active)\n    push!(active, nid)\n    global next_id += 1\nend\n\nroot_id = merges[end][4]\nmax_height = merges[end][3]\n\nchildren = Dict{Int,Tuple{Int,Int}}()\nfor (i, j, d, nid) in merges\n    children[nid] = (i, j)\nend\n\n# Leaf order (iterative DFS, left child first) keeps sibling subtrees\n# angularly contiguous so branches never cross.\norder = Int[]\nstack = [root_id]\nwhile !isempty(stack)\n    node = pop!(stack)\n    if node <= n\n        push!(order, node)\n    else\n        i, j = children[node]\n        push!(stack, j)\n        push!(stack, i)\n    end\nend\n\n# Angle (leaves equally spaced, small gap at the top) and radius\n# (proportional to merge distance, root at the center) per node.\nR_max = 1.0\ngap = deg2rad(6)\nangle_span = 2 * pi - gap\nstart_angle = pi / 2 + gap / 2\nangle_step = angle_span / (n - 1)\n\nnode_angle = Dict{Int,Float64}()\nnode_radius = Dict{Int,Float64}()\nfor (p, leaf) in enumerate(order)\n    node_angle[leaf] = start_angle + angle_step * (p - 1)\n    node_radius[leaf] = R_max\nend\nfor (i, j, d, nid) in merges\n    node_angle[nid] = (node_angle[i] + node_angle[j]) / 2\n    node_radius[nid] = R_max * (1 - d / max_height)\nend\n\n# Pure-cluster color propagation: a node keeps its module color while every\n# leaf beneath it shares the same module, else it falls back to neutral ink\n# (mirrors the standard dendrogram color_threshold convention).\nnode_group = Dict{Int,Int}()\nfor leaf in 1:n\n    node_group[leaf] = leaf_group[leaf]\nend\nfor (i, j, d, nid) in merges\n    gi, gj = node_group[i], node_group[j]\n    node_group[nid] = gi == gj ? gi : 0\nend\n\n# Data storytelling: the distance at which each module's own leaves last\n# coalesce into one pure branch tells us how internally tight that module is.\ngroup_top_d = Dict{Int,Float64}()\nfor (i, j, d, nid) in merges\n    g = node_group[nid]\n    if g != 0\n        group_top_d[g] = max(get(group_top_d, g, -Inf), d)\n    end\nend\ntightest_group = argmin(group_top_d)\ntightest_name = group_names[tightest_group]\n\n# Plot — see default-style-guide.md \"Visual Sizing Defaults\" for the canvas + sizing values\ntitle_text = \"dendrogram-radial · julia · makie · anyplot.ai\"\n\nfig = Figure(\n    resolution = (1200, 1200),\n    fontsize = 14,\n    backgroundcolor = PAGE_BG,\n)\n\nax = Axis(\n    fig[1, 1];\n    title = title_text,\n    titlesize = 26,\n    titlecolor = INK,\n    aspect = DataAspect(),\n    backgroundcolor = PAGE_BG,\n)\nhidedecorations!(ax)\nhidespines!(ax)\n\nL = 1.32\nlimits!(ax, -L, L, -L, L)\n\nfor (i, j, d, nid) in merges\n    rp = node_radius[nid]\n    ai, aj = node_angle[i], node_angle[j]\n\n    color_i = node_group[i] == 0 ? INK_SOFT : IMPRINT_PALETTE[node_group[i]]\n    xi1, yi1 = node_radius[i] * cos(ai), node_radius[i] * sin(ai)\n    xi2, yi2 = rp * cos(ai), rp * sin(ai)\n    lines!(ax, [xi1, xi2], [yi1, yi2]; color = color_i, linewidth = 2.0)\n\n    color_j = node_group[j] == 0 ? INK_SOFT : IMPRINT_PALETTE[node_group[j]]\n    xj1, yj1 = node_radius[j] * cos(aj), node_radius[j] * sin(aj)\n    xj2, yj2 = rp * cos(aj), rp * sin(aj)\n    lines!(ax, [xj1, xj2], [yj1, yj2]; color = color_j, linewidth = 2.0)\n\n    color_nid = node_group[nid] == 0 ? INK_SOFT : IMPRINT_PALETTE[node_group[nid]]\n    a_lo, a_hi = ai < aj ? (ai, aj) : (aj, ai)\n    arc!(ax, Point2f(0, 0), rp, a_lo, a_hi; color = color_nid, linewidth = 2.0)\nend\n\n# Color-coded outer ring: contiguous leaf runs sharing a module, one arc per run.\nring_r = R_max * 1.05\nhalf_step = angle_step / 2\nseg_start = 1\nfor p in 2:(n + 1)\n    if p == n + 1 || leaf_group[order[p]] != leaf_group[order[seg_start]]\n        g = leaf_group[order[seg_start]]\n        a0 = node_angle[order[seg_start]] - half_step\n        a1 = node_angle[order[p - 1]] + half_step\n        arc!(ax, Point2f(0, 0), ring_r, a0, a1; color = IMPRINT_PALETTE[g], linewidth = 5)\n        global seg_start = p\n    end\nend\n\nleaf_x = [R_max * cos(node_angle[l]) for l in 1:n]\nleaf_y = [R_max * sin(node_angle[l]) for l in 1:n]\nleaf_colors = [IMPRINT_PALETTE[leaf_group[l]] for l in 1:n]\nscatter!(ax, leaf_x, leaf_y; color = leaf_colors, markersize = 8, strokewidth = 0)\n\nlabel_r = R_max * 1.16\nfor l in 1:n\n    a = node_angle[l]\n    flip = cos(a) < 0\n    rot = flip ? a + pi : a\n    halign = flip ? :right : :left\n    text!(\n        ax, label_r * cos(a), label_r * sin(a);\n        text = labels[l], rotation = rot, align = (halign, :center),\n        fontsize = 10, color = INK_SOFT,\n    )\nend\n\n# Data-storytelling callout: the root's empty center has no branches to\n# collide with, so it doubles as free space for the tightest-module insight.\ntext!(\n    ax, 0, 0;\n    text = \"Tightest cluster:\\n$(tightest_name)\", align = (:center, :center),\n    fontsize = 13, color = INK, justification = :center,\n)\n\nlegend_elements = [LineElement(color = IMPRINT_PALETTE[g], linewidth = 4) for g in 1:4]\nLegend(\n    fig[2, 1], legend_elements, group_names, \"Module\";\n    orientation = :horizontal, framevisible = false, labelcolor = INK, titlecolor = INK,\n    labelsize = 14, titlesize = 16,\n)\nrowsize!(fig.layout, 1, Relative(0.92))\nrowsize!(fig.layout, 2, Relative(0.08))\n\n# Save\nsave(\"plot-$(THEME).png\", fig; px_per_unit = 2)\n"}