{"spec_id":"dendrogram-basic","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\ndendrogram-basic: Basic Dendrogram\nLibrary: bokeh 3.9.1 | Python 3.13.13\nQuality: 91/100 | Updated: 2026-06-18\n\"\"\"\n\nimport io\nimport os\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import ColumnDataSource, FixedTicker, HoverTool, Label, Span\nfrom bokeh.plotting import figure\nfrom PIL import Image\nfrom scipy.cluster.hierarchy import leaves_list, linkage\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\n# Theme tokens (see prompts/default-style-guide.md)\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\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint palette — semantic assignment\nCOLOR_WITHIN = \"#009E73\"  # brand green — within-cluster cohesion\nCOLOR_BETWEEN = \"#AE3030\"  # matte red — cross-cluster boundary (semantic: separation)\n\n# Data — Iris flower measurements (4 features, 15 samples)\nnp.random.seed(42)\nsamples_per_species = 5\nlabels = []\ndata = []\n\n# Setosa: shorter petals, wider sepals\nfor i in range(samples_per_species):\n    labels.append(f\"Setosa-{i + 1}\")\n    data.append(\n        [\n            5.0 + np.random.randn() * 0.3,\n            3.4 + np.random.randn() * 0.3,\n            1.5 + np.random.randn() * 0.2,\n            0.3 + np.random.randn() * 0.1,\n        ]\n    )\n\n# Versicolor: medium measurements\nfor i in range(samples_per_species):\n    labels.append(f\"Versicolor-{i + 1}\")\n    data.append(\n        [\n            5.9 + np.random.randn() * 0.4,\n            2.8 + np.random.randn() * 0.3,\n            4.3 + np.random.randn() * 0.4,\n            1.3 + np.random.randn() * 0.2,\n        ]\n    )\n\n# Virginica: longer petals and sepals\nfor i in range(samples_per_species):\n    labels.append(f\"Virginica-{i + 1}\")\n    data.append(\n        [\n            6.6 + np.random.randn() * 0.5,\n            3.0 + np.random.randn() * 0.3,\n            5.5 + np.random.randn() * 0.5,\n            2.0 + np.random.randn() * 0.3,\n        ]\n    )\n\ndata = np.array(data)\nn_samples = len(labels)\n\n# Hierarchical clustering via Ward's method\nlinkage_matrix = linkage(data, method=\"ward\")\nleaf_order = leaves_list(linkage_matrix)\nordered_labels = [labels[i] for i in leaf_order]\n\n# Map each node to its x position\nnode_positions = {leaf_idx: idx for idx, leaf_idx in enumerate(leaf_order)}\n\n# Track cluster members for hover tooltips\ncluster_members = {i: [labels[i]] for i in range(n_samples)}\n\nmax_dist = linkage_matrix[:, 2].max()\ncolor_threshold = 0.7 * max_dist\n\n# Build U-shaped connector segments for each merge\nall_xs, all_ys = [], []\nall_colors, all_distances, all_left_items, all_right_items, all_cluster_sizes = [], [], [], [], []\n\nfor i, (left, right, dist, count) in enumerate(linkage_matrix):\n    left, right = int(left), int(right)\n    new_node = n_samples + i\n\n    left_x = node_positions[left]\n    right_x = node_positions[right]\n    left_y = 0 if left < n_samples else linkage_matrix[left - n_samples, 2]\n    right_y = 0 if right < n_samples else linkage_matrix[right - n_samples, 2]\n\n    node_positions[new_node] = (left_x + right_x) / 2\n\n    left_members = cluster_members[left]\n    right_members = cluster_members[right]\n    cluster_members[new_node] = left_members + right_members\n\n    all_xs.append([left_x, left_x, right_x, right_x])\n    all_ys.append([left_y, dist, dist, right_y])\n    all_colors.append(COLOR_BETWEEN if dist > color_threshold else COLOR_WITHIN)\n    all_distances.append(f\"{dist:.2f}\")\n    all_left_items.append(\", \".join(left_members[:3]) + (\"...\" if len(left_members) > 3 else \"\"))\n    all_right_items.append(\", \".join(right_members[:3]) + (\"...\" if len(right_members) > 3 else \"\"))\n    all_cluster_sizes.append(str(int(count)))\n\n# Sqrt-scale y values for better visibility of lower-level merges\nsqrt_max = np.sqrt(max_dist)\nall_ys_scaled = [[np.sqrt(y) for y in ys] for ys in all_ys]\n\n# Plot — landscape 3200×1800 (canonical)\nW, H = 3200, 1800\ntitle = \"dendrogram-basic · python · bokeh · anyplot.ai\"\np = figure(\n    width=W,\n    height=H,\n    title=title,\n    x_axis_label=\"Iris Sample\",\n    y_axis_label=\"Distance (Ward's Method, √ scale)\",\n    x_range=(-0.8, n_samples - 0.2),\n    y_range=(-sqrt_max * 0.02, sqrt_max * 1.12),\n    toolbar_location=None,\n    min_border_bottom=160,\n    min_border_left=180,\n    min_border_top=110,\n    min_border_right=50,\n)\n\n# Dendrogram branches\nsource = ColumnDataSource(\n    data={\n        \"xs\": all_xs,\n        \"ys\": all_ys_scaled,\n        \"color\": all_colors,\n        \"distance\": all_distances,\n        \"left_cluster\": all_left_items,\n        \"right_cluster\": all_right_items,\n        \"cluster_size\": all_cluster_sizes,\n    }\n)\n\nbranch_renderer = p.multi_line(\n    xs=\"xs\",\n    ys=\"ys\",\n    source=source,\n    line_width=6,\n    line_color=\"color\",\n    line_alpha=0.9,\n    hover_line_width=9,\n    hover_line_alpha=1.0,\n    hover_line_color=\"#BD8233\",\n)\n\nhover = HoverTool(\n    renderers=[branch_renderer],\n    tooltips=[\n        (\"Merge Distance\", \"@distance\"),\n        (\"Cluster Size\", \"@cluster_size items\"),\n        (\"Left\", \"@left_cluster\"),\n        (\"Right\", \"@right_cluster\"),\n    ],\n    line_policy=\"interp\",\n)\np.add_tools(hover)\n\n# Cluster threshold line\nthreshold_y_scaled = np.sqrt(color_threshold)\np.add_layout(\n    Span(\n        location=threshold_y_scaled,\n        dimension=\"width\",\n        line_color=INK_MUTED,\n        line_dash=\"dashed\",\n        line_width=2,\n        line_alpha=0.6,\n    )\n)\np.add_layout(\n    Label(\n        x=n_samples - 1.2,\n        y=threshold_y_scaled,\n        text=\"cluster threshold\",\n        text_font_size=\"26pt\",\n        text_color=INK_MUTED,\n        text_font_style=\"italic\",\n        y_offset=8,\n        text_align=\"right\",\n    )\n)\n\n# Legend via off-screen glyphs\np.line([-99, -98], [-99, -99], line_color=COLOR_WITHIN, line_width=8, legend_label=\"Within-cluster\")\np.line([-99, -98], [-99, -99], line_color=COLOR_BETWEEN, line_width=8, legend_label=\"Between-cluster\")\n\n# Leaf labels on x-axis\np.xaxis.ticker = FixedTicker(ticks=list(range(n_samples)))\np.xaxis.major_label_overrides = {i: ordered_labels[i] for i in range(n_samples)}\np.xaxis.major_label_orientation = 0.785  # 45 degrees\n\n# Style — canonical font sizes per bokeh.md\np.title.text_font_size = \"50pt\"\np.title.text_font_style = \"normal\"\np.title.text_color = INK\np.xaxis.axis_label_text_font_size = \"42pt\"\np.yaxis.axis_label_text_font_size = \"42pt\"\np.xaxis.axis_label_text_color = INK\np.yaxis.axis_label_text_color = INK\np.xaxis.major_label_text_font_size = \"34pt\"\np.xaxis.major_label_text_color = INK_SOFT\np.yaxis.major_label_text_font_size = \"34pt\"\np.yaxis.major_label_text_color = INK_SOFT\n\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = INK_SOFT\n\np.xgrid.visible = False\np.ygrid.grid_line_color = INK\np.ygrid.grid_line_alpha = 0.15\n\n# Remove axis lines (address review weakness)\np.xaxis.axis_line_color = None\np.yaxis.axis_line_color = None\np.xaxis.major_tick_line_color = None\np.xaxis.minor_tick_line_color = None\np.yaxis.major_tick_line_color = None\np.yaxis.minor_tick_line_color = None\n\n# Legend\np.legend.location = \"top_left\"\np.legend.label_text_font_size = \"34pt\"\np.legend.label_text_color = INK_SOFT\np.legend.background_fill_color = ELEVATED_BG\np.legend.border_line_color = INK_SOFT\np.legend.glyph_width = 60\np.legend.glyph_height = 10\np.legend.spacing = 14\np.legend.padding = 22\np.legend.margin = 16\n\n# Save HTML then screenshot via headless Chrome (export_png unavailable in CI)\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 + 200}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\ndriver.set_window_size(W, H + 200)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ntime.sleep(3)\nraw = driver.get_screenshot_as_png()\ndriver.quit()\nImage.open(io.BytesIO(raw)).crop((0, 0, W, H)).save(f\"plot-{THEME}.png\")\n"}