{"spec_id":"dendrogram-basic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\ndendrogram-basic: Basic Dendrogram\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 88/100 | Updated: 2026-06-18\n\"\"\"\n\nimport os\nimport sys\n\n\n# Prevent this file (pygal.py) from shadowing the installed pygal package\n_here = os.path.dirname(os.path.realpath(__file__))\nsys.path = [p for p in sys.path if os.path.realpath(p) != _here]\nos.chdir(_here)\n\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\nfrom scipy.cluster.hierarchy import fcluster, linkage\n\n\n# Theme tokens — Imprint palette 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\"\nANYPLOT_AMBER = \"#DDCC77\"\n\n# Imprint categorical palette — first series is always #009E73\nIMPRINT_PALETTE = (\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\")\n\n# Data - Iris flower measurements (4 features for 15 samples)\nnp.random.seed(42)\nsamples_per_species = 5\n\nlabels = []\nmeasurements = []\n\n# Setosa: shorter petals, wider sepals\nfor i in range(samples_per_species):\n    labels.append(f\"Setosa-{i + 1}\")\n    measurements.append(\n        [\n            5.0 + np.random.randn() * 0.35,\n            3.4 + np.random.randn() * 0.35,\n            1.5 + np.random.randn() * 0.25,\n            0.3 + np.random.randn() * 0.12,\n        ]\n    )\n\n# Versicolor: medium measurements\nfor i in range(samples_per_species):\n    labels.append(f\"Versicolor-{i + 1}\")\n    measurements.append(\n        [\n            5.9 + np.random.randn() * 0.5,\n            2.8 + np.random.randn() * 0.35,\n            4.3 + np.random.randn() * 0.5,\n            1.3 + np.random.randn() * 0.25,\n        ]\n    )\n\n# Virginica: longer petals and sepals\nfor i in range(samples_per_species):\n    labels.append(f\"Virginica-{i + 1}\")\n    measurements.append(\n        [\n            6.6 + np.random.randn() * 0.55,\n            3.0 + np.random.randn() * 0.35,\n            5.5 + np.random.randn() * 0.55,\n            2.0 + np.random.randn() * 0.3,\n        ]\n    )\n\nmeasurements = np.array(measurements)\n\n# Compute hierarchical clustering\nlinkage_matrix = linkage(measurements, method=\"ward\")\nn = len(labels)\n\n# Assign cluster colors - cut at 3 clusters matching species\ncluster_ids = fcluster(linkage_matrix, t=3, criterion=\"maxclust\")\n\n# Build leaf ordering from linkage (iterative traversal)\nleaf_order = []\nstack = [2 * n - 2]\nwhile stack:\n    node_id = stack.pop()\n    if node_id < n:\n        leaf_order.append(node_id)\n    else:\n        idx = node_id - n\n        left = int(linkage_matrix[idx, 0])\n        right = int(linkage_matrix[idx, 1])\n        stack.append(right)\n        stack.append(left)\n\n# Compute node positions and determine cluster membership for coloring\nnode_x = {}\nnode_height = {}\nnode_cluster = {}\n\nfor pos, leaf_id in enumerate(leaf_order):\n    node_x[leaf_id] = pos\n    node_height[leaf_id] = 0\n    node_cluster[leaf_id] = cluster_ids[leaf_id]\n\n# Map cluster IDs to species names\ncluster_species = {}\nfor leaf_id in range(n):\n    cid = cluster_ids[leaf_id]\n    species = labels[leaf_id].rsplit(\"-\", 1)[0]\n    cluster_species[cid] = species\n\n# Species colors from Imprint palette (positions 1-3)\nspecies_colors = {\n    \"Setosa\": IMPRINT_PALETTE[0],  # #009E73 brand green\n    \"Versicolor\": IMPRINT_PALETTE[1],  # #C475FD lavender\n    \"Virginica\": IMPRINT_PALETTE[2],  # #4467A3 blue\n}\nmixed_color = INK_MUTED  # theme-adaptive for inter-cluster merges\n\n# Build U-shape series with color and distance metadata\nu_shapes = []\nmax_dist = linkage_matrix[:, 2].max()\n\nfor idx in range(len(linkage_matrix)):\n    left = int(linkage_matrix[idx, 0])\n    right = int(linkage_matrix[idx, 1])\n    dist = linkage_matrix[idx, 2]\n    new_node = n + idx\n\n    x_left = node_x[left]\n    x_right = node_x[right]\n    node_x[new_node] = (x_left + x_right) / 2\n    node_height[new_node] = dist\n\n    h_left = node_height[left]\n    h_right = node_height[right]\n\n    cl = node_cluster[left]\n    cr = node_cluster[right]\n    if cl == cr:\n        node_cluster[new_node] = cl\n        color = species_colors.get(cluster_species.get(cl, \"\"), mixed_color)\n    else:\n        node_cluster[new_node] = -1\n        color = mixed_color\n\n    # Stroke width scales with merge distance — minimum 5 ensures lower branches stay visible\n    stroke_w = 5 + 8 * (dist / max_dist)\n\n    u_shapes.append((color, stroke_w, dist, [(x_left, h_left), (x_left, dist), (x_right, dist), (x_right, h_right)]))\n\n# Ordered labels for x-axis\nordered_labels = [labels[i] for i in leaf_order]\n\n# Title with length-scaled fontsize\ntitle = \"Iris Species Clustering · dendrogram-basic · python · pygal · anyplot.ai\"\ntitle_len = len(title)\ntitle_fontsize = round(66 * 67 / title_len) if title_len > 67 else 66\n\n# Extended color tuple: U-shape colors + reference line colors (amber + ink)\nu_shape_colors = tuple(color for color, _, _, _ in u_shapes)\nBETWEEN_SPECIES_COLOR = IMPRINT_PALETTE[3]  # #BD8233 ochre — distinct from gray inter-cluster bridge\nall_series_colors = u_shape_colors + (ANYPLOT_AMBER, BETWEEN_SPECIES_COLOR)\n\n# Style — Imprint palette, theme-adaptive chrome\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=all_series_colors,\n    title_font_size=title_fontsize,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    stroke_width=5,\n    opacity=1.0,\n)\n\n# Chart — pygal XY configured as dendrogram (3200×1800 landscape)\nchart = pygal.XY(\n    width=3200,\n    height=1800,\n    style=custom_style,\n    title=title,\n    x_title=\"Sample\",\n    y_title=\"Ward's Distance\",\n    show_legend=True,\n    show_dots=False,\n    fill=False,\n    show_x_guides=False,\n    show_y_guides=True,\n    show_minor_x_labels=False,\n    x_label_rotation=35,\n    truncate_label=30,\n    xrange=(-1.0, n + 0.2),\n    range=(0, max_dist * 1.08),\n    margin_top=50,\n    margin_bottom=80,\n    margin_left=100,\n    margin_right=80,\n    legend_at_bottom=True,\n    legend_box_size=30,\n    tooltip_border_radius=10,\n    print_values=False,\n    spacing=35,\n    js=[],\n)\n\n# Custom x-axis labels at leaf positions\nchart.x_labels = list(range(n))\nchart.x_labels_major = list(range(n))\nchart.x_value_formatter = lambda x: ordered_labels[int(round(x))] if 0 <= round(x) < n else \"\"\n\n# Y-axis: formatted distances\ny_max_nice = int(np.ceil(max_dist))\nstep = 1 if y_max_nice <= 6 else 2\nchart.y_labels = [{\"value\": v, \"label\": f\"{v:.0f}\"} for v in range(0, y_max_nice + 1, step)]\n\n# Draw dendrogram — each U-shape as its own series\ncolor_to_species = {v: k for k, v in species_colors.items()}\ncolor_to_species[mixed_color] = \"Inter-cluster\"\n\nnamed_colors = set()\nfor color, stroke_w, dist, points in u_shapes:\n    if color not in named_colors:\n        series_name = color_to_species.get(color, \"Other\")\n        named_colors.add(color)\n    else:\n        series_name = None\n\n    chart.add(\n        series_name,\n        [{\"value\": p, \"label\": f\"d={dist:.2f}\"} for p in points],\n        show_dots=False,\n        stroke_style={\"width\": stroke_w, \"linecap\": \"round\", \"linejoin\": \"round\"},\n        allow_interruptions=False,\n    )\n\n# Reference lines for key distance thresholds — amber color, clearly visible\nkey_merges = sorted(linkage_matrix[:, 2])\nwithin_cluster_max = key_merges[n - 4]\nbetween_cluster = key_merges[-2]\n\nfor ref_dist, ref_label in [(within_cluster_max, \"Within-species max\"), (between_cluster, \"Between-species merge\")]:\n    chart.add(\n        ref_label,\n        [(-0.8, ref_dist), (n - 0.2, ref_dist)],\n        show_dots=False,\n        stroke_style={\"width\": 4, \"dasharray\": \"16, 8\", \"linecap\": \"butt\"},\n    )\n\n# Save — both PNG and interactive HTML\nchart.render_to_file(f\"plot-{THEME}.html\")\nchart.render_to_png(f\"plot-{THEME}.png\")\n"}