{"spec_id":"dendrogram-radial","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\ndendrogram-radial: Radial Dendrogram\nLibrary: letsplot 4.11.0 | Python 3.13.15\nQuality: 92/100 | Created: 2026-08-24\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import *\nfrom scipy.cluster.hierarchy import dendrogram, fcluster, linkage\nfrom sklearn.datasets import make_blobs\n\n\nLetsPlot.setup_html()\n\n# Theme tokens (see prompts/default-style-guide.md \"Background\" + \"Theme-adaptive Chrome\")\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\n# Imprint palette — first series always #009E73 (brand green)\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\"]\n\n# Data — synthetic marker-gene expression profiles for 32 tissue samples that\n# form 4 latent cell-type clusters, summarized by hierarchical clustering\nnp.random.seed(42)\nn_leaves = 32\nn_clusters = 4\nexpression, _ = make_blobs(n_samples=n_leaves, centers=n_clusters, n_features=6, cluster_std=1.6, random_state=42)\n\nlinkage_matrix = linkage(expression, method=\"average\")\ncluster_ids = fcluster(linkage_matrix, t=n_clusters, criterion=\"maxclust\")\n\nsample_labels = [f\"S{i + 1:02d}\" for i in range(n_leaves)]\ncell_type_names = {1: \"Cell type A\", 2: \"Cell type B\", 3: \"Cell type C\", 4: \"Cell type D\"}\ncluster_color = {cluster_id: IMPRINT_PALETTE[cluster_id - 1] for cluster_id in cell_type_names}\nSTRUCTURAL = \"Between clusters\"\n\n# Propagate cluster identity bottom-up through the linkage tree; a merge keeps\n# its children's cluster label only while both sides still share one cluster,\n# matching scipy's link_color_func node-id contract (node id = n_leaves + row)\nnode_cluster = {i: cluster_ids[i] for i in range(n_leaves)}\nfor i, (left, right, *_rest) in enumerate(linkage_matrix):\n    left_cluster = node_cluster[int(left)]\n    right_cluster = node_cluster[int(right)]\n    node_cluster[n_leaves + i] = left_cluster if left_cluster == right_cluster else None\n\nlink_labels = {}\nfor i in range(len(linkage_matrix)):\n    node_id = n_leaves + i\n    cluster = node_cluster[node_id]\n    link_labels[node_id] = cell_type_names[cluster] if cluster is not None else STRUCTURAL\n\nddata = dendrogram(\n    linkage_matrix, no_plot=True, labels=sample_labels, link_color_func=lambda node_id: link_labels[node_id]\n)\n\nleaf_order = ddata[\"leaves\"]  # original sample indices, left-to-right\nicoord = ddata[\"icoord\"]\ndcoord = ddata[\"dcoord\"]\nbranch_labels = ddata[\"color_list\"]\n\n# Radial layout — root at center, leaves on the circumference, radial\n# distance proportional to merge height (as in the linear dendrogram). The\n# center hole is sized to hold an in-panel legend (see \"Legend\" below) so the\n# figure never needs an external ggplot legend, which lets-plot's PNG\n# exporter fails to size correctly against a fixed square canvas.\nR_OUTER = 1.0\nR_INNER = 0.32\nmax_x = n_leaves * 10\nmax_height = linkage_matrix[:, 2].max()\n\n\ndef to_angle(x):\n    return (x / max_x) * 2 * np.pi\n\n\ndef to_radius(height):\n    return R_OUTER - (height / max_height) * (R_OUTER - R_INNER)\n\n\ndef polar_to_xy(angle, radius):\n    return radius * np.sin(angle), radius * np.cos(angle)\n\n\n# Branches — each scipy \"U\" shape (left riser, merge bar, right riser)\n# becomes a radial line, an arc sampled at the merge radius, and a radial\n# line back down, all pre-projected into Cartesian x/y\nbranch_rows = []\nfor path_id, ((x0, _x1, x2, _x3), (y0, y1, _y2, y3), branch_label) in enumerate(\n    zip(icoord, dcoord, branch_labels, strict=True)\n):\n    left_angle = to_angle(x0)\n    right_angle = to_angle(x2)\n    merge_radius = to_radius(y1)\n    left_child_radius = to_radius(y0)\n    right_child_radius = to_radius(y3)\n\n    arc_points = max(2, round(abs(right_angle - left_angle) / (2 * np.pi) * n_leaves * 8))\n    arc_angles = np.linspace(left_angle, right_angle, arc_points)\n\n    polar_points = [(left_angle, left_child_radius), (left_angle, merge_radius)]\n    polar_points += [(angle, merge_radius) for angle in arc_angles]\n    polar_points.append((right_angle, right_child_radius))\n\n    for order, (angle, radius) in enumerate(polar_points):\n        px, py = polar_to_xy(angle, radius)\n        branch_rows.append({\"path_id\": path_id, \"order\": order, \"x\": px, \"y\": py, \"cluster\": branch_label})\n\nbranches_df = pd.DataFrame(branch_rows)\n\n# Leaves — an outer ring tick (redundant cluster encoding) plus a rotated\n# label that stays upright and reads outward on both halves of the circle\nleaf_rows = []\nfor position, sample_index in enumerate(leaf_order):\n    angle = to_angle(5 + position * 10)\n    cluster = cell_type_names[cluster_ids[sample_index]]\n\n    x_in, y_in = polar_to_xy(angle, R_OUTER)\n    x_out, y_out = polar_to_xy(angle, R_OUTER + 0.035)\n    x_label, y_label = polar_to_xy(angle, R_OUTER + 0.075)\n\n    angle_deg = np.degrees(angle) % 360\n    if angle_deg < 180:\n        rotation = angle_deg - 90\n        hjust = 0\n    else:\n        rotation = angle_deg - 270\n        hjust = 1\n\n    leaf_rows.append(\n        {\n            \"sample\": sample_labels[sample_index],\n            \"cluster\": cluster,\n            \"x_in\": x_in,\n            \"y_in\": y_in,\n            \"x_out\": x_out,\n            \"y_out\": y_out,\n            \"x_label\": x_label,\n            \"y_label\": y_label,\n            \"rotation\": rotation,\n            \"hjust\": hjust,\n        }\n    )\n\nleaves_df = pd.DataFrame(leaf_rows)\n\n# Radial reference guide — faint dashed rings at two intermediate merge-height\n# levels, so branch length has a scale anchor beyond eyeballing bare radius\nRING_FRACTIONS = [1 / 3, 2 / 3]\nring_angles = np.linspace(0, 2 * np.pi, 120)\nring_rows = []\nring_label_rows = []\nfor ring_id, fraction in enumerate(RING_FRACTIONS):\n    ring_height = fraction * max_height\n    ring_radius = to_radius(ring_height)\n    ring_rows += [\n        {\"ring_id\": ring_id, \"x\": px, \"y\": py} for px, py in (polar_to_xy(a, ring_radius) for a in ring_angles)\n    ]\n    label_x, label_y = polar_to_xy(0.02, ring_radius)\n    ring_label_rows.append({\"x\": label_x, \"y\": label_y, \"text\": f\"h={ring_height:.1f}\"})\n\nrings_df = pd.DataFrame(ring_rows)\nring_labels_df = pd.DataFrame(ring_label_rows)\n\n# Colors — Imprint palette for the 4 clusters, muted ink for merges that join\n# two different clusters (the conventional \"above cluster cut\" styling)\ncolor_values = {name: cluster_color[cid] for cid, name in cell_type_names.items()}\ncolor_values[STRUCTURAL] = INK_MUTED\n\n# Legend — drawn manually inside the empty center hole (color swatch + label\n# per row) instead of a ggplot guide_legend(). lets-plot's PNG exporter (via\n# ggsave scale=) cannot size a side/bottom legend against a fixed square\n# canvas without leaving the panel letterboxed by transparent padding, so an\n# in-panel legend is the reliable way to label 5 categories on this layout.\nlegend_rows = [\n    (0.26, \"Type A\", \"Cell type A\"),\n    (0.13, \"Type B\", \"Cell type B\"),\n    (0.0, \"Mixed\", STRUCTURAL),\n    (-0.13, \"Type C\", \"Cell type C\"),\n    (-0.26, \"Type D\", \"Cell type D\"),\n]\nlegend_df = pd.DataFrame(\n    [\n        {\"y\": y, \"x0\": -0.22, \"x1\": -0.13, \"x_text\": -0.10, \"text\": text, \"cluster\": cluster}\n        for y, text, cluster in legend_rows\n    ]\n)\n\n# Plot\ntitle = \"dendrogram-radial · python · letsplot · anyplot.ai\"\nplot = (\n    ggplot()\n    + geom_path(\n        aes(x=\"x\", y=\"y\", group=\"ring_id\"),\n        data=rings_df,\n        color=INK_MUTED,\n        size=0.4,\n        alpha=0.5,\n        linetype=\"dashed\",\n        show_legend=False,\n    )\n    + geom_text(\n        aes(x=\"x\", y=\"y\", label=\"text\"),\n        data=ring_labels_df,\n        color=INK_MUTED,\n        size=3.5,\n        hjust=0,\n        vjust=-0.3,\n        show_legend=False,\n    )\n    + geom_path(aes(x=\"x\", y=\"y\", group=\"path_id\", color=\"cluster\"), data=branches_df, size=1.1, show_legend=False)\n    + geom_segment(\n        aes(x=\"x_in\", y=\"y_in\", xend=\"x_out\", yend=\"y_out\", color=\"cluster\"),\n        data=leaves_df,\n        size=3,\n        show_legend=False,\n        tooltips=layer_tooltips().line(\"Sample|@sample\").line(\"Cluster|@cluster\"),\n    )\n    + geom_text(\n        aes(x=\"x_label\", y=\"y_label\", label=\"sample\", angle=\"rotation\", hjust=\"hjust\"),\n        data=leaves_df,\n        color=INK_SOFT,\n        size=8.5,\n        vjust=0.5,\n        show_legend=False,\n    )\n    + geom_segment(aes(x=\"x0\", y=\"y\", xend=\"x1\", yend=\"y\", color=\"cluster\"), data=legend_df, size=3, show_legend=False)\n    + geom_text(\n        aes(x=\"x_text\", y=\"y\", label=\"text\"),\n        data=legend_df,\n        color=INK_SOFT,\n        size=6,\n        hjust=0,\n        vjust=0.5,\n        show_legend=False,\n    )\n    + scale_color_manual(values=color_values)\n    + coord_fixed(ratio=1, xlim=[-1.55, 1.55], ylim=[-1.55, 1.55])\n    + labs(title=title)\n    + ggsize(600, 600)\n    + theme(\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_background=element_rect(fill=PAGE_BG),\n        panel_grid=element_blank(),\n        axis_title=element_blank(),\n        axis_text=element_blank(),\n        axis_ticks=element_blank(),\n        axis_line=element_blank(),\n        plot_title=element_text(color=INK, size=16),\n    )\n)\n\n# Save\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=4)\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}