{"spec_id":"dendrogram-basic","library":"altair","language":"python","code":"\"\"\" anyplot.ai\ndendrogram-basic: Basic Dendrogram\nLibrary: altair 6.2.1 | Python 3.13.13\nQuality: 90/100 | Updated: 2026-06-18\n\"\"\"\n\nimport os\nimport sys\n\n\n# Remove the script directory from sys.path to avoid shadowing the altair package\n# (this file is named altair.py — same as the library being imported)\nsys.path = [p for p in sys.path if p != sys.path[0]] if len(sys.path) > 1 else sys.path\n\nimport altair as alt\nimport pandas as pd\nfrom PIL import Image\nfrom scipy.cluster.hierarchy import dendrogram, fcluster, linkage\nfrom sklearn.datasets import load_iris\n\n\n# Theme tokens — Imprint palette, theme-adaptive chrome\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 positions 1-3 for the three species\nSPECIES_COLORS = {\n    \"Setosa\": \"#009E73\",  # brand green\n    \"Versicolor\": \"#C475FD\",  # lavender\n    \"Virginica\": \"#4467A3\",  # blue\n}\nCLUSTER_COLORS = {1: \"#009E73\", 2: \"#C475FD\", 3: \"#4467A3\"}\nANYPLOT_AMBER = \"#DDCC77\"  # threshold / warning\n\n# Data — Iris flower measurements (15 samples, 5 per species)\niris = load_iris()\nindices = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140]\nfeatures = iris.data[indices]\nspecies_names = [\"Setosa\", \"Versicolor\", \"Virginica\"]\nlabels = [f\"{species_names[iris.target[i]]}-{i}\" for i in indices]\n\n# Hierarchical clustering with Ward's method\nZ = linkage(features, method=\"ward\")\ndendro = dendrogram(Z, labels=labels, no_plot=True)\n\n# Cluster membership at distance threshold\ndistance_threshold = 5.0\ncluster_ids = fcluster(Z, t=distance_threshold, criterion=\"distance\")\n\n# Map leaf indices to Imprint colors, propagate through merge nodes\nn_leaves = len(labels)\nnode_colors = {}\nfor idx in dendro[\"leaves\"]:\n    node_colors[idx] = CLUSTER_COLORS.get(cluster_ids[idx], INK_MUTED)\n\nfor i, row in enumerate(Z):\n    left, right = int(row[0]), int(row[1])\n    left_c = node_colors.get(left, INK_MUTED)\n    right_c = node_colors.get(right, INK_MUTED)\n    node_colors[n_leaves + i] = left_c if left_c == right_c else INK_MUTED\n\n# Extract line segments with per-cluster coloring\nsegments = []\nfor merge_idx, (xpts, ypts) in enumerate(zip(dendro[\"icoord\"], dendro[\"dcoord\"], strict=True)):\n    merge_height = max(ypts)\n    left_node = int(Z[merge_idx, 0])\n    right_node = int(Z[merge_idx, 1])\n    left_c = node_colors.get(left_node, INK_MUTED)\n    right_c = node_colors.get(right_node, INK_MUTED)\n    merge_c = left_c if left_c == right_c else INK_MUTED\n\n    segments.append(\n        {\"x\": xpts[0], \"y\": ypts[0], \"x2\": xpts[1], \"y2\": ypts[1], \"color\": left_c, \"distance\": round(merge_height, 2)}\n    )\n    segments.append(\n        {\"x\": xpts[1], \"y\": ypts[1], \"x2\": xpts[2], \"y2\": ypts[2], \"color\": merge_c, \"distance\": round(merge_height, 2)}\n    )\n    segments.append(\n        {\"x\": xpts[2], \"y\": ypts[2], \"x2\": xpts[3], \"y2\": ypts[3], \"color\": right_c, \"distance\": round(merge_height, 2)}\n    )\n\nsegments_df = pd.DataFrame(segments)\n\n# Leaf positions and species assignments\nleaf_labels = dendro[\"ivl\"]\nleaf_df = pd.DataFrame(\n    {\n        \"x\": [5 + 10 * i for i in range(len(leaf_labels))],\n        \"y_base\": [0.0] * len(leaf_labels),\n        \"label\": leaf_labels,\n        \"species\": [lbl.rsplit(\"-\", 1)[0] for lbl in leaf_labels],\n    }\n)\n\n# Axis domain — tight bounds to reduce empty side space\nx_min = min(min(s[\"x\"], s[\"x2\"]) for s in segments) - 3\nx_max = max(max(s[\"x\"], s[\"x2\"]) for s in segments) + 3\ny_max = Z[:, 2].max() * 1.15\n\n# Annotation at the final (top) merge\ntop_merge_y = Z[-1, 2]\ntop_merge_x = (dendro[\"icoord\"][-1][1] + dendro[\"icoord\"][-1][2]) / 2\nannotation_df = pd.DataFrame(\n    {\"x\": [top_merge_x], \"y\": [top_merge_y], \"text\": [\"Setosa diverges\\nfrom Versicolor + Virginica\"]}\n)\n\n# Interactive legend selection\nspecies_selection = alt.selection_point(fields=[\"species\"], bind=\"legend\")\n\n# Dendrogram branches — cluster-colored with merge-distance tooltips\nbranches = (\n    alt.Chart(segments_df)\n    .mark_rule(strokeWidth=2.5)\n    .encode(\n        x=alt.X(\"x:Q\", scale=alt.Scale(domain=[x_min, x_max]), axis=None),\n        x2=\"x2:Q\",\n        y=alt.Y(\"y:Q\", title=\"Distance (Ward's method)\", scale=alt.Scale(domain=[0, y_max])),\n        y2=\"y2:Q\",\n        color=alt.Color(\"color:N\", scale=None),\n        tooltip=[alt.Tooltip(\"distance:Q\", title=\"Merge Distance\", format=\".2f\")],\n    )\n)\n\n# Leaf dots colored by species with interactive opacity\nleaf_dots = (\n    alt.Chart(leaf_df)\n    .mark_point(size=140, filled=True, strokeWidth=1.5, stroke=PAGE_BG)\n    .encode(\n        x=alt.X(\"x:Q\", scale=alt.Scale(domain=[x_min, x_max]), axis=None),\n        y=alt.Y(\"y_base:Q\", scale=alt.Scale(domain=[0, y_max])),\n        color=alt.Color(\n            \"species:N\",\n            scale=alt.Scale(domain=list(SPECIES_COLORS.keys()), range=list(SPECIES_COLORS.values())),\n            legend=alt.Legend(\n                title=\"Species\",\n                titleFontSize=12,\n                titleFontWeight=\"bold\",\n                labelFontSize=11,\n                symbolSize=160,\n                orient=\"right\",\n                offset=10,\n                titleColor=INK,\n                labelColor=INK_SOFT,\n            ),\n        ),\n        tooltip=[alt.Tooltip(\"label:N\", title=\"Sample\"), alt.Tooltip(\"species:N\", title=\"Species\")],\n        opacity=alt.condition(species_selection, alt.value(1.0), alt.value(0.15)),\n    )\n    .add_params(species_selection)\n)\n\n# Leaf labels — rotated 315°, sized for the 620×320 inner view (y=305 ≈ bottom of 320px view)\nleaf_text = (\n    alt.Chart(leaf_df)\n    .mark_text(angle=315, align=\"right\", baseline=\"top\", fontSize=10, fontWeight=\"bold\", dx=-3, dy=4)\n    .encode(\n        x=alt.X(\"x:Q\", scale=alt.Scale(domain=[x_min, x_max]), axis=None),\n        y=alt.value(305),\n        text=\"label:N\",\n        color=alt.Color(\n            \"species:N\",\n            scale=alt.Scale(domain=list(SPECIES_COLORS.keys()), range=list(SPECIES_COLORS.values())),\n            legend=None,\n        ),\n        opacity=alt.condition(species_selection, alt.value(1.0), alt.value(0.15)),\n    )\n)\n\n# Cluster threshold reference line (amber = caution/warning semantic)\nthreshold_df = pd.DataFrame({\"y\": [distance_threshold]})\nthreshold_line = (\n    alt.Chart(threshold_df)\n    .mark_rule(strokeDash=[8, 6], strokeWidth=1.5, color=ANYPLOT_AMBER, opacity=0.85)\n    .encode(y=\"y:Q\")\n)\nthreshold_label = (\n    alt.Chart(threshold_df)\n    .mark_text(align=\"left\", baseline=\"bottom\", fontSize=10, color=ANYPLOT_AMBER, fontStyle=\"italic\", dx=5, dy=-4)\n    .encode(x=alt.value(10), y=\"y:Q\", text=alt.value(\"cluster threshold (d = 5.0)\"))\n)\n\n# Annotation at top merge\ntop_annotation = (\n    alt.Chart(annotation_df)\n    .mark_text(align=\"left\", baseline=\"middle\", fontSize=10, fontWeight=\"bold\", color=INK_SOFT, lineBreak=\"\\n\", dx=12)\n    .encode(x=\"x:Q\", y=\"y:Q\", text=\"text:N\")\n)\ntop_arrow = (\n    alt.Chart(annotation_df)\n    .mark_point(shape=\"triangle-left\", size=55, filled=True, color=INK_MUTED)\n    .encode(x=\"x:Q\", y=\"y:Q\")\n)\n\n# Compose chart — landscape inner view 620×320, scale_factor=4 → ~3200×1800 after PIL pad\nchart = (\n    alt.layer(branches, threshold_line, threshold_label, leaf_dots, leaf_text, top_arrow, top_annotation)\n    .properties(\n        width=620,\n        height=320,\n        background=PAGE_BG,\n        title=alt.Title(\n            \"dendrogram-basic · python · altair · anyplot.ai\",\n            subtitle=\"Ward's linkage on Iris measurements — Setosa separates clearly from Versicolor / Virginica\",\n            fontSize=16,\n            subtitleFontSize=12,\n            subtitleColor=INK_SOFT,\n            color=INK,\n            anchor=\"start\",\n            offset=16,\n        ),\n    )\n    .configure_view(fill=PAGE_BG, strokeWidth=0)\n    .configure_axis(\n        labelFontSize=10,\n        titleFontSize=12,\n        titleColor=INK,\n        labelColor=INK_SOFT,\n        gridOpacity=0.12,\n        gridDash=[3, 5],\n        gridColor=INK,\n        domainColor=INK_SOFT,\n        domainWidth=1.0,\n        tickColor=INK_SOFT,\n        tickSize=4,\n    )\n    .configure_legend(\n        padding=14, cornerRadius=4, strokeColor=INK_SOFT, fillColor=ELEVATED_BG, labelColor=INK_SOFT, titleColor=INK\n    )\n    .configure_title(subtitlePadding=6)\n)\n\n# Save — scale_factor=4.0, then PIL-pad to exact 3200×1800\nTW, TH = 3200, 1800\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n\nchart.save(f\"plot-{THEME}.html\")\n"}