{"spec_id":"dendrogram-radial","library":"plotly","language":"python","code":"\"\"\" anyplot.ai\ndendrogram-radial: Radial Dendrogram\nLibrary: plotly 6.7.0 | Python 3.13.13\nQuality: 81/100 | Created: 2026-05-14\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport plotly.graph_objects as go\nfrom scipy.cluster.hierarchy import dendrogram as scipy_dendrogram\nfrom scipy.cluster.hierarchy import linkage\nfrom sklearn.datasets import load_iris\nfrom sklearn.preprocessing import StandardScaler\n\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\n\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\"]\nSPECIES = [\"Setosa\", \"Versicolor\", \"Virginica\"]\n\n# Data: 12 samples per iris species (36 leaves total)\nnp.random.seed(42)\niris = load_iris()\npicks = []\nfor cls in range(3):\n    idx = np.where(iris.target == cls)[0]\n    picks.extend(np.random.choice(idx, 12, replace=False).tolist())\npicks = np.array(picks)\n\nX = StandardScaler().fit_transform(iris.data[picks])\ny = iris.target[picks]\n\nspecies_count = [0, 0, 0]\nlabels = []\nfor cls in y:\n    species_count[cls] += 1\n    prefix = [\"Se\", \"Ve\", \"Vi\"][cls]\n    labels.append(f\"{prefix}-{species_count[cls]:02d}\")\n\n# Linkage and dendrogram leaf order\nZ = linkage(X, method=\"ward\")\nn_leaves = len(X)\nn_nodes = 2 * n_leaves - 1\nmax_dist = Z[-1, 2]\n\ndend = scipy_dendrogram(Z, no_plot=True)\nleaf_order = dend[\"leaves\"]\nleaf_pos = {leaf: pos for pos, leaf in enumerate(leaf_order)}\n\n# Radial layout: degrees (counterclockwise from +x) and normalised radii\nangles = np.zeros(n_nodes)\nfor leaf in range(n_leaves):\n    angles[leaf] = leaf_pos[leaf] * 360.0 / n_leaves\n\nradii = np.ones(n_nodes)  # leaves at r=1, root converges to r≈0\nfor i, row in enumerate(Z):\n    node_id = n_leaves + i\n    radii[node_id] = 1.0 - row[2] / max_dist\n\n# Internal node angle = midpoint of its two children (safe: subtrees never wrap 0°/360°)\nfor i, row in enumerate(Z):\n    node_id = n_leaves + i\n    left, right = int(row[0]), int(row[1])\n    angles[node_id] = (angles[left] + angles[right]) / 2.0\n\n# Cluster purity per node for branch coloring\nnode_species = [set() for _ in range(n_nodes)]\nfor leaf in range(n_leaves):\n    node_species[leaf] = {y[leaf]}\nfor i, row in enumerate(Z):\n    node_id = n_leaves + i\n    left, right = int(row[0]), int(row[1])\n    node_species[node_id] = node_species[left] | node_species[right]\n\n\ndef branch_color(node_id):\n    sp = node_species[node_id]\n    if len(sp) == 1:\n        return IMPRINT[next(iter(sp))]\n    return INK_SOFT\n\n\n# Build traces: one radial segment per child + one arc per merge\ntraces = []\n\nfor i, row in enumerate(Z):\n    node_id = n_leaves + i\n    left, right = int(row[0]), int(row[1])\n    node_r = radii[node_id]\n\n    # Left radial segment\n    la = np.deg2rad(angles[left])\n    traces.append(\n        go.Scatter(\n            x=[radii[left] * np.cos(la), node_r * np.cos(la)],\n            y=[radii[left] * np.sin(la), node_r * np.sin(la)],\n            mode=\"lines\",\n            line={\"color\": branch_color(left), \"width\": 2.5},\n            showlegend=False,\n            hoverinfo=\"skip\",\n        )\n    )\n\n    # Right radial segment\n    ra = np.deg2rad(angles[right])\n    traces.append(\n        go.Scatter(\n            x=[radii[right] * np.cos(ra), node_r * np.cos(ra)],\n            y=[radii[right] * np.sin(ra), node_r * np.sin(ra)],\n            mode=\"lines\",\n            line={\"color\": branch_color(right), \"width\": 2.5},\n            showlegend=False,\n            hoverinfo=\"skip\",\n        )\n    )\n\n    # Arc at node_r from left-child angle to right-child angle (always short arc)\n    a1 = min(angles[left], angles[right])\n    a2 = max(angles[left], angles[right])\n    arc_t = np.linspace(np.deg2rad(a1), np.deg2rad(a2), 40)\n    traces.append(\n        go.Scatter(\n            x=node_r * np.cos(arc_t),\n            y=node_r * np.sin(arc_t),\n            mode=\"lines\",\n            line={\"color\": branch_color(node_id), \"width\": 2.5},\n            showlegend=False,\n            hoverinfo=\"skip\",\n        )\n    )\n\n# Leaf markers with hover labels\nleaf_rad = np.deg2rad([angles[i] for i in range(n_leaves)])\ntraces.append(\n    go.Scatter(\n        x=np.cos(leaf_rad),\n        y=np.sin(leaf_rad),\n        mode=\"markers\",\n        marker={\"color\": [IMPRINT[y[i]] for i in range(n_leaves)], \"size\": 10, \"line\": {\"color\": PAGE_BG, \"width\": 1.5}},\n        showlegend=False,\n        hovertext=[f\"{labels[i]} · {SPECIES[y[i]]}\" for i in range(n_leaves)],\n        hoverinfo=\"text\",\n    )\n)\n\n# Legend entries\nfor idx, species in enumerate(SPECIES):\n    traces.append(\n        go.Scatter(\n            x=[None],\n            y=[None],\n            mode=\"markers+lines\",\n            marker={\"color\": IMPRINT[idx], \"size\": 12},\n            line={\"color\": IMPRINT[idx], \"width\": 3},\n            name=species,\n            showlegend=True,\n        )\n    )\n\nfig = go.Figure(data=traces)\n\n# Leaf label annotations placed just outside leaf markers\nannotations = []\nlabel_r = 1.10\nfor leaf in range(n_leaves):\n    a_rad = np.deg2rad(angles[leaf])\n    lx = label_r * np.cos(a_rad)\n    ly = label_r * np.sin(a_rad)\n    xanchor = \"left\" if np.cos(a_rad) >= 0 else \"right\"\n    yanchor = \"bottom\" if np.sin(a_rad) > 0 else \"top\"\n    annotations.append(\n        {\n            \"x\": lx,\n            \"y\": ly,\n            \"text\": labels[leaf],\n            \"font\": {\"size\": 10, \"color\": IMPRINT[y[leaf]]},\n            \"showarrow\": False,\n            \"xanchor\": xanchor,\n            \"yanchor\": yanchor,\n        }\n    )\n\nfig.update_layout(\n    title={\n        \"text\": \"Iris Species Clustering · dendrogram-radial · plotly · anyplot.ai\",\n        \"font\": {\"size\": 28, \"color\": INK},\n        \"x\": 0.5,\n        \"xanchor\": \"center\",\n        \"y\": 0.97,\n    },\n    paper_bgcolor=PAGE_BG,\n    plot_bgcolor=PAGE_BG,\n    font={\"color\": INK},\n    showlegend=True,\n    legend={\n        \"x\": 0.02, \"y\": 0.98, \"bgcolor\": ELEVATED_BG, \"bordercolor\": INK_SOFT, \"borderwidth\": 1, \"font\": {\"color\": INK_SOFT, \"size\": 16}\n    },\n    xaxis={\"visible\": False, \"scaleanchor\": \"y\", \"scaleratio\": 1, \"range\": [-1.38, 1.38]},\n    yaxis={\"visible\": False, \"range\": [-1.38, 1.38]},\n    annotations=annotations,\n    margin={\"l\": 60, \"r\": 60, \"t\": 100, \"b\": 60},\n)\n\nfig.write_image(f\"plot-{THEME}.png\", width=1200, height=1200, scale=3)\nfig.write_html(f\"plot-{THEME}.html\", include_plotlyjs=\"cdn\")\n"}