{"spec_id":"dendrogram-radial","library":"altair","language":"python","code":"\"\"\" anyplot.ai\ndendrogram-radial: Radial Dendrogram\nLibrary: altair 6.1.0 | Python 3.13.13\nQuality: 83/100 | Created: 2026-05-14\n\"\"\"\n\nimport importlib\nimport os\nimport sys\n\nimport numpy as np\nimport pandas as pd\nfrom scipy.cluster.hierarchy import leaves_list, linkage\nfrom scipy.spatial.distance import pdist\n\n\n# Import altair via importlib after removing this script's directory from sys.path,\n# preventing the file named \"altair.py\" from shadowing the installed altair package.\n_script_dir = os.path.dirname(os.path.abspath(__file__))\nif _script_dir in sys.path:\n    sys.path.remove(_script_dir)\nalt = importlib.import_module(\"altair\")\n\n\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\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\"]\n\n# Data: gene expression profiles, 5 well-separated clusters\nnp.random.seed(42)\nn_genes = 30\nn_conditions = 10\nn_clusters = 5\n\ncluster_labels = []\nfor k in range(n_clusters):\n    cluster_labels.extend([k] * (n_genes // n_clusters))\n\ncluster_centers = np.random.randn(n_clusters, n_conditions) * 2.5\nexpression = np.zeros((n_genes, n_conditions))\nfor idx, c in enumerate(cluster_labels):\n    expression[idx] = cluster_centers[c] + np.random.randn(n_conditions) * 0.4\n\ngene_names = [f\"G{idx + 1:02d}\" for idx in range(n_genes)]\n\n# Hierarchical clustering\ndist_matrix = pdist(expression, metric=\"euclidean\")\nZ = linkage(dist_matrix, method=\"ward\")\n\nn_leaves = n_genes\nleaf_order = leaves_list(Z)\nmax_dist = Z[-1, 2]\n\n# Equal angular spacing for leaves (in dendrogram order)\nleaf_angles_arr = np.linspace(0, 2 * np.pi, n_leaves, endpoint=False)\nnode_angles = {}\nfor pos, leaf_idx in enumerate(leaf_order):\n    node_angles[leaf_idx] = leaf_angles_arr[pos]\n\n# Radii: leaves at 1.0 (circumference), root near 0.0 (center)\nnode_radii = dict.fromkeys(range(n_leaves), 1.0)\nsubtree_clusters = {idx: {cluster_labels[idx]} for idx in range(n_leaves)}\n\nfor i, (left, right, dist, _) in enumerate(Z):\n    node_id = n_leaves + i\n    left, right = int(left), int(right)\n    node_radii[node_id] = 1.0 - dist / max_dist\n\n    a1, a2 = node_angles[left], node_angles[right]\n    if abs(a2 - a1) > np.pi:\n        if a1 < a2:\n            a1 += 2 * np.pi\n        else:\n            a2 += 2 * np.pi\n    node_angles[node_id] = ((a1 + a2) / 2) % (2 * np.pi)\n    subtree_clusters[node_id] = subtree_clusters[left] | subtree_clusters[right]\n\n\ndef branch_color(node_id):\n    clusters = subtree_clusters[node_id]\n    if len(clusters) == 1:\n        return IMPRINT[list(clusters)[0]]\n    return INK_SOFT\n\n\n# Build line-segment rows: arc (connecting children) + two radial lines (to each child)\nARC_PTS = 25\nrows = []\ngid = 0\n\nfor i, (left, right, _dist, _) in enumerate(Z):\n    left, right = int(left), int(right)\n    node_id = n_leaves + i\n    parent_r = node_radii[node_id]\n    left_r = node_radii[left]\n    right_r = node_radii[right]\n    left_a = node_angles[left]\n    right_a = node_angles[right]\n\n    # Arc at parent_r spanning from left_a to right_a (short path)\n    a1, a2 = left_a, right_a\n    if abs(a2 - a1) > np.pi:\n        if a1 < a2:\n            a1 += 2 * np.pi\n        else:\n            a2 += 2 * np.pi\n    arc_color = branch_color(node_id)\n    for theta in np.linspace(a1, a2, ARC_PTS):\n        rows.append({\"x\": parent_r * np.cos(theta), \"y\": parent_r * np.sin(theta), \"g\": gid, \"clr\": arc_color})\n    gid += 1\n\n    # Left radial line\n    lc = branch_color(left)\n    rows.extend(\n        [\n            {\"x\": parent_r * np.cos(left_a), \"y\": parent_r * np.sin(left_a), \"g\": gid, \"clr\": lc},\n            {\"x\": left_r * np.cos(left_a), \"y\": left_r * np.sin(left_a), \"g\": gid, \"clr\": lc},\n        ]\n    )\n    gid += 1\n\n    # Right radial line\n    rc = branch_color(right)\n    rows.extend(\n        [\n            {\"x\": parent_r * np.cos(right_a), \"y\": parent_r * np.sin(right_a), \"g\": gid, \"clr\": rc},\n            {\"x\": right_r * np.cos(right_a), \"y\": right_r * np.sin(right_a), \"g\": gid, \"clr\": rc},\n        ]\n    )\n    gid += 1\n\ndf_lines = pd.DataFrame(rows)\n\n# Leaf dots\nleaf_rows = []\nfor leaf_idx in range(n_leaves):\n    a = node_angles[leaf_idx]\n    c = cluster_labels[leaf_idx]\n    leaf_rows.append(\n        {\n            \"x\": np.cos(a),\n            \"y\": np.sin(a),\n            \"label\": gene_names[leaf_idx],\n            \"cluster\": f\"Cluster {c + 1}\",\n            \"clr\": IMPRINT[c],\n        }\n    )\ndf_leaves = pd.DataFrame(leaf_rows)\n\n# Horizontal legend below the circle\nlegend_data = pd.DataFrame(\n    {\n        \"x\": np.linspace(-0.72, 0.72, n_clusters),\n        \"y\": [-1.22] * n_clusters,\n        \"label\": [f\"Cluster {k + 1}\" for k in range(n_clusters)],\n        \"clr\": IMPRINT[:n_clusters],\n    }\n)\n\nall_colors = sorted(set(df_lines[\"clr\"].unique()) | set(IMPRINT[:n_clusters]))\ncscale = alt.Scale(domain=all_colors, range=all_colors)\nXY_DOM = [-1.40, 1.40]\n\nlines_layer = (\n    alt.Chart(df_lines)\n    .mark_line(strokeWidth=2.0)\n    .encode(\n        x=alt.X(\"x:Q\", scale=alt.Scale(domain=XY_DOM), axis=None),\n        y=alt.Y(\"y:Q\", scale=alt.Scale(domain=XY_DOM), axis=None),\n        detail=\"g:N\",\n        color=alt.Color(\"clr:N\", scale=cscale, legend=None),\n    )\n)\n\ndots_layer = (\n    alt.Chart(df_leaves)\n    .mark_circle(size=100)\n    .encode(\n        x=alt.X(\"x:Q\", scale=alt.Scale(domain=XY_DOM), axis=None),\n        y=alt.Y(\"y:Q\", scale=alt.Scale(domain=XY_DOM), axis=None),\n        color=alt.Color(\"clr:N\", scale=cscale, legend=None),\n        tooltip=[\"label:N\", \"cluster:N\"],\n    )\n)\n\nlegend_dots_layer = (\n    alt.Chart(legend_data)\n    .mark_circle(size=160)\n    .encode(\n        x=alt.X(\"x:Q\", scale=alt.Scale(domain=XY_DOM), axis=None),\n        y=alt.Y(\"y:Q\", scale=alt.Scale(domain=XY_DOM), axis=None),\n        color=alt.Color(\"clr:N\", scale=cscale, legend=None),\n    )\n)\n\nlegend_text_layer = (\n    alt.Chart(legend_data)\n    .mark_text(dy=22, fontSize=16, align=\"center\")\n    .encode(\n        x=alt.X(\"x:Q\", scale=alt.Scale(domain=XY_DOM), axis=None),\n        y=alt.Y(\"y:Q\", scale=alt.Scale(domain=XY_DOM), axis=None),\n        text=\"label:N\",\n        color=alt.value(INK_SOFT),\n    )\n)\n\nTITLE = \"dendrogram-radial · altair · anyplot.ai\"\n\nchart = (\n    alt.layer(lines_layer, dots_layer, legend_dots_layer, legend_text_layer)\n    .properties(width=1200, height=1200, title=alt.Title(TITLE, fontSize=22), background=PAGE_BG)\n    .configure_view(fill=PAGE_BG, strokeWidth=0)\n    .configure_title(color=INK, fontSize=22)\n)\n\n# Save\nchart.save(f\"plot-{THEME}.png\", scale_factor=3.0)\nchart.save(f\"plot-{THEME}.html\")\n"}