{"spec_id":"dendrogram-radial","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\ndendrogram-radial: Radial Dendrogram\nLibrary: bokeh 3.9.0 | Python 3.13.13\nQuality: 84/100 | Created: 2026-05-14\n\"\"\"\n\nimport math\nimport os\nimport sys\nimport time\nfrom pathlib import Path\n\n\n# Prevent the local bokeh.py from shadowing the installed bokeh package\nsys.path = [p for p in sys.path if os.path.abspath(p) != os.path.dirname(os.path.abspath(__file__))]\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import ColumnDataSource, LabelSet, Legend, LegendItem\nfrom bokeh.plotting import figure\nfrom scipy.cluster.hierarchy import leaves_list, linkage\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\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\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\"]\n\n# Data — gene expression clustering scenario (40 genes, 4 clusters)\nnp.random.seed(42)\nn_genes = 40\nn_clusters = 4\ncluster_size = n_genes // n_clusters\n\ngene_names = [f\"Gene-{i:02d}\" for i in range(n_genes)]\n\ncluster_centers = np.array(\n    [\n        [2.0, 0.5, -1.0, -0.5, 1.5, -0.8],\n        [-1.5, 2.0, 0.8, -1.2, 0.3, 1.5],\n        [0.5, -1.5, 2.0, 1.0, -1.0, 0.5],\n        [-0.8, 0.5, -1.5, 2.0, -0.5, -1.2],\n    ]\n)\nexpression_data = np.vstack(\n    [cluster_centers[c] + np.random.normal(0, 0.3, (cluster_size, 6)) for c in range(n_clusters)]\n)\ntrue_clusters = np.repeat(np.arange(n_clusters), cluster_size)\n\nZ = linkage(expression_data, method=\"ward\")\nordered_leaves = leaves_list(Z)\n\n# Build radial dendrogram geometry\nn_leaves = n_genes\nangles = {i: 2 * math.pi * i / n_leaves for i in range(n_leaves)}\nleaf_angles = {ordered_leaves[i]: angles[i] for i in range(n_leaves)}\n\n# node_radius: leaves at 1.0, internal nodes scaled inversely by merge distance\nmax_dist = Z[-1, 2]\nnode_radius = dict.fromkeys(range(n_leaves), 1.0)\ninternal_id = n_leaves\nfor row in Z:\n    node_radius[internal_id] = 1.0 - row[2] / max_dist\n    internal_id += 1\n\n# Angle for each internal node: mean of child angles\nnode_angle = {i: leaf_angles[i] for i in range(n_leaves)}\ninternal_id = n_leaves\nfor row in Z:\n    left, right = int(row[0]), int(row[1])\n    node_angle[internal_id] = (node_angle[left] + node_angle[right]) / 2\n    internal_id += 1\n\n\ndef polar_to_xy(r, theta):\n    return r * math.cos(theta), r * math.sin(theta)\n\n\ndef get_leaf_cluster(node_id):\n    if node_id < n_leaves:\n        return int(true_clusters[node_id])\n    stack = [node_id]\n    cluster_set = set()\n    while stack:\n        nid = stack.pop()\n        if nid < n_leaves:\n            cluster_set.add(int(true_clusters[nid]))\n        else:\n            row = Z[nid - n_leaves]\n            stack.extend([int(row[0]), int(row[1])])\n    return cluster_set.pop() if len(cluster_set) == 1 else -1\n\n\n# Build branch segments\nseg_xs, seg_ys, seg_colors = [], [], []\ninternal_id = n_leaves\nfor row in Z:\n    left, right = int(row[0]), int(row[1])\n    parent_id = internal_id\n\n    r_parent = node_radius[parent_id]\n    a_parent = node_angle[parent_id]\n    a_left = node_angle[left]\n    a_right = node_angle[right]\n\n    cl = get_leaf_cluster(left)\n    cr = get_leaf_cluster(right)\n    cp = get_leaf_cluster(parent_id)\n    color_left = IMPRINT[cl] if cl >= 0 else INK_SOFT\n    color_right = IMPRINT[cr] if cr >= 0 else INK_SOFT\n    color_arc = IMPRINT[cp] if cp >= 0 else INK_SOFT\n\n    px_l, py_l = polar_to_xy(r_parent, a_left)\n    cx_l, cy_l = polar_to_xy(node_radius[left], a_left)\n    seg_xs.append([px_l, cx_l])\n    seg_ys.append([py_l, cy_l])\n    seg_colors.append(color_left)\n\n    px_r, py_r = polar_to_xy(r_parent, a_right)\n    cx_r, cy_r = polar_to_xy(node_radius[right], a_right)\n    seg_xs.append([px_r, cx_r])\n    seg_ys.append([py_r, cy_r])\n    seg_colors.append(color_right)\n\n    n_arc = max(3, int(abs(a_right - a_left) / (2 * math.pi) * 60))\n    arc_angles = np.linspace(a_left, a_right, n_arc)\n    seg_xs.append([r_parent * math.cos(a) for a in arc_angles])\n    seg_ys.append([r_parent * math.sin(a) for a in arc_angles])\n    seg_colors.append(color_arc)\n\n    internal_id += 1\n\n# Leaf label positions\nlabel_r = 1.08\nlabel_xs, label_ys, label_texts = [], [], []\nfor i, leaf_idx in enumerate(ordered_leaves):\n    a = angles[i]\n    lx, ly = polar_to_xy(label_r, a)\n    label_xs.append(lx)\n    label_ys.append(ly)\n    label_texts.append(gene_names[leaf_idx])\n\n# Cluster color dots at leaf tips\ndot_xs, dot_ys, dot_colors = [], [], []\nfor i, leaf_idx in enumerate(ordered_leaves):\n    a = angles[i]\n    dx, dy = polar_to_xy(1.03, a)\n    dot_xs.append(dx)\n    dot_ys.append(dy)\n    dot_colors.append(IMPRINT[int(true_clusters[leaf_idx])])\n\n# Plot\nW, H = 2700, 2700\np = figure(\n    width=W,\n    height=H,\n    title=\"dendrogram-radial · bokeh · anyplot.ai\",\n    x_range=(-1.35, 1.35),\n    y_range=(-1.35, 1.35),\n    toolbar_location=None,\n    match_aspect=True,\n)\n\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = None\np.xgrid.grid_line_color = None\np.ygrid.grid_line_color = None\np.xaxis.visible = False\np.yaxis.visible = False\n\nfor xs, ys, color in zip(seg_xs, seg_ys, seg_colors, strict=False):\n    p.line(xs, ys, line_color=color, line_width=2.5, line_alpha=0.85)\n\np.scatter(x=dot_xs, y=dot_ys, size=10, color=dot_colors, line_color=None, alpha=0.9)\n\nlabel_source = ColumnDataSource({\"x\": label_xs, \"y\": label_ys, \"text\": label_texts})\nlabels_set = LabelSet(\n    x=\"x\",\n    y=\"y\",\n    text=\"text\",\n    text_font_size=\"11pt\",\n    text_color=INK_SOFT,\n    text_align=\"center\",\n    text_baseline=\"middle\",\n    source=label_source,\n)\np.add_layout(labels_set)\n\nlegend_items = []\nfor c_idx in range(n_clusters):\n    r = p.scatter([], [], size=18, color=IMPRINT[c_idx], line_color=None)\n    legend_items.append(LegendItem(label=f\"Cluster {c_idx + 1}\", renderers=[r]))\n\nlegend = Legend(\n    items=legend_items,\n    location=\"bottom_right\",\n    background_fill_color=ELEVATED_BG,\n    border_line_color=INK_SOFT,\n    label_text_color=INK_SOFT,\n    label_text_font_size=\"14pt\",\n    glyph_width=20,\n    glyph_height=20,\n)\np.add_layout(legend)\n\np.title.text_font_size = \"24pt\"\np.title.text_color = INK\np.title.align = \"center\"\n\n# Save\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\n\ndriver = webdriver.Chrome(options=opts)\ndriver.set_window_size(W, H)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}