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