{"spec_id":"heatmap-clustered","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nheatmap-clustered: Clustered Heatmap\nLibrary: pygal 3.1.0 | Python 3.13.13\nQuality: 92/100 | Updated: 2026-05-09\n\"\"\"\n\nimport os\nimport sys\n\nimport numpy as np\nfrom scipy.cluster.hierarchy import dendrogram, linkage\nfrom scipy.spatial.distance import pdist\n\n\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\n\n# Temporarily remove current directory from path to avoid name collision\n_cwd = sys.path[0] if sys.path[0] else \".\"\nif _cwd in sys.path:\n    sys.path.remove(_cwd)\n\nfrom pygal.graph.graph import Graph\nfrom pygal.style import Style\n\n\n# Restore path\nsys.path.insert(0, _cwd)\n\n# Theme tokens\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\n\n\nclass ClusteredHeatmap(Graph):\n    \"\"\"Custom Clustered Heatmap for pygal - displays matrix with hierarchical clustering dendrograms.\"\"\"\n\n    def __init__(self, *args, **kwargs):\n        self.matrix_data = kwargs.pop(\"matrix_data\", [])\n        self.row_labels = kwargs.pop(\"row_labels\", [])\n        self.col_labels = kwargs.pop(\"col_labels\", [])\n        self.colormap = kwargs.pop(\"colormap\", [])\n        self.show_values = kwargs.pop(\"show_values\", False)\n        self.row_linkage = kwargs.pop(\"row_linkage\", None)\n        self.col_linkage = kwargs.pop(\"col_linkage\", None)\n        self.row_order = kwargs.pop(\"row_order\", None)\n        self.col_order = kwargs.pop(\"col_order\", None)\n        self.colorbar_label = kwargs.pop(\"colorbar_label\", \"Value\")\n        super().__init__(*args, **kwargs)\n\n    def _interpolate_color(self, value, min_val, max_val):\n        \"\"\"Interpolate color for diverging colormap.\"\"\"\n        if max_val == min_val:\n            return self.colormap[len(self.colormap) // 2]\n\n        normalized = (value - min_val) / (max_val - min_val)\n        normalized = max(0, min(1, normalized))\n\n        pos = normalized * (len(self.colormap) - 1)\n        idx1 = int(pos)\n        idx2 = min(idx1 + 1, len(self.colormap) - 1)\n        frac = pos - idx1\n\n        c1 = self.colormap[idx1]\n        c2 = self.colormap[idx2]\n\n        r1, g1, b1 = int(c1[1:3], 16), int(c1[3:5], 16), int(c1[5:7], 16)\n        r2, g2, b2 = int(c2[1:3], 16), int(c2[3:5], 16), int(c2[5:7], 16)\n\n        r = int(r1 + (r2 - r1) * frac)\n        g = int(g1 + (g2 - g1) * frac)\n        b = int(b1 + (b2 - b1) * frac)\n\n        return f\"#{r:02x}{g:02x}{b:02x}\"\n\n    def _get_text_color(self, bg_color):\n        \"\"\"Get contrasting text color based on background brightness.\"\"\"\n        r, g, b = int(bg_color[1:3], 16), int(bg_color[3:5], 16), int(bg_color[5:7], 16)\n        brightness = (r * 299 + g * 587 + b * 114) / 1000\n        return \"#ffffff\" if brightness < 140 else INK\n\n    def _draw_dendrogram(self, group, linkage_matrix, x_offset, y_offset, width, height, orientation=\"left\"):\n        \"\"\"Draw dendrogram from hierarchical clustering linkage matrix.\"\"\"\n        if linkage_matrix is None or len(linkage_matrix) == 0:\n            return\n\n        dend = dendrogram(linkage_matrix, no_plot=True, orientation=orientation)\n\n        icoord = np.array(dend[\"icoord\"])\n        dcoord = np.array(dend[\"dcoord\"])\n\n        max_d = dcoord.max() if dcoord.max() > 0 else 1\n        n_leaves = len(dend[\"leaves\"])\n        stroke_width = 3\n\n        for i in range(len(icoord)):\n            if orientation in (\"left\", \"right\"):\n                xs = dcoord[i] / max_d * width\n                ys = (icoord[i] / (n_leaves * 10)) * height\n\n                if orientation == \"left\":\n                    xs = width - xs\n\n                path_data = f\"M {x_offset + xs[0]} {y_offset + ys[0]} \"\n                for j in range(1, 4):\n                    path_data += f\"L {x_offset + xs[j]} {y_offset + ys[j]} \"\n\n            else:\n                xs = (icoord[i] / (n_leaves * 10)) * width\n                ys = dcoord[i] / max_d * height\n\n                if orientation == \"top\":\n                    ys = height - ys\n\n                path_data = f\"M {x_offset + xs[0]} {y_offset + ys[0]} \"\n                for j in range(1, 4):\n                    path_data += f\"L {x_offset + xs[j]} {y_offset + ys[j]} \"\n\n            path = self.svg.node(group, \"path\")\n            path.set(\"d\", path_data)\n            path.set(\"fill\", \"none\")\n            path.set(\"stroke\", INK_SOFT)\n            path.set(\"stroke-width\", str(stroke_width))\n\n    def _plot(self):\n        \"\"\"Draw the clustered heatmap with dendrograms.\"\"\"\n        if not self.matrix_data:\n            return\n\n        matrix = np.array(self.matrix_data)\n        if self.row_order is not None:\n            matrix = matrix[self.row_order, :]\n            reordered_row_labels = [self.row_labels[i] for i in self.row_order]\n        else:\n            reordered_row_labels = self.row_labels\n\n        if self.col_order is not None:\n            matrix = matrix[:, self.col_order]\n            reordered_col_labels = [self.col_labels[i] for i in self.col_order]\n        else:\n            reordered_col_labels = self.col_labels\n\n        n_rows, n_cols = matrix.shape\n\n        min_val = matrix.min()\n        max_val = matrix.max()\n        abs_max = max(abs(min_val), abs(max_val))\n        min_val, max_val = -abs_max, abs_max\n\n        plot_width = self.view.width\n        plot_height = self.view.height\n\n        axis_label_width = 80\n        row_dend_width = 280\n        col_dend_height = 180\n        label_margin_left = 280\n        label_margin_bottom = 300\n        label_margin_top = 80\n        colorbar_width = 180\n\n        heatmap_x = axis_label_width + row_dend_width + label_margin_left\n        heatmap_width = plot_width - heatmap_x - colorbar_width - 20\n        heatmap_height = plot_height - col_dend_height - label_margin_bottom - label_margin_top\n\n        cell_width = heatmap_width / n_cols\n        cell_height = heatmap_height / n_rows\n\n        plot_node = self.nodes[\"plot\"]\n        heatmap_group = self.svg.node(plot_node, class_=\"clustered-heatmap\")\n\n        self._draw_dendrogram(\n            heatmap_group,\n            self.row_linkage,\n            self.view.x(0) + axis_label_width + 40,\n            self.view.y(n_rows) + label_margin_top,\n            row_dend_width - 40,\n            heatmap_height,\n            orientation=\"left\",\n        )\n\n        self._draw_dendrogram(\n            heatmap_group,\n            self.col_linkage,\n            self.view.x(0) + heatmap_x,\n            self.view.y(n_rows) + heatmap_height + label_margin_top + 10,\n            heatmap_width,\n            col_dend_height,\n            orientation=\"bottom\",\n        )\n\n        row_font_size = min(38, int(cell_height * 0.7))\n        for i, label in enumerate(reordered_row_labels):\n            x = self.view.x(0) + heatmap_x - 15\n            y = self.view.y(n_rows) + label_margin_top + i * cell_height + cell_height / 2\n            text_node = self.svg.node(heatmap_group, \"text\", x=x, y=y + row_font_size * 0.35)\n            text_node.set(\"text-anchor\", \"end\")\n            text_node.set(\"fill\", INK)\n            text_node.set(\"style\", f\"font-size:{row_font_size}px;font-weight:600;font-family:sans-serif\")\n            text_node.text = label\n\n        col_font_size = min(30, int(cell_width * 0.5))\n        for j, label in enumerate(reordered_col_labels):\n            x = self.view.x(0) + heatmap_x + j * cell_width + cell_width / 2\n            y = self.view.y(n_rows) + label_margin_top + heatmap_height + col_dend_height + 25\n            text_node = self.svg.node(heatmap_group, \"text\", x=x, y=y)\n            text_node.set(\"text-anchor\", \"start\")\n            text_node.set(\"fill\", INK)\n            text_node.set(\"style\", f\"font-size:{col_font_size}px;font-weight:600;font-family:sans-serif\")\n            text_node.set(\"transform\", f\"rotate(45, {x}, {y})\")\n            text_node.text = label\n\n        value_font_size = min(28, int(min(cell_width, cell_height) * 0.35))\n        for i in range(n_rows):\n            for j in range(n_cols):\n                value = matrix[i, j]\n                color = self._interpolate_color(value, min_val, max_val)\n\n                x = self.view.x(0) + heatmap_x + j * cell_width\n                y = self.view.y(n_rows) + label_margin_top + i * cell_height\n\n                rect = self.svg.node(\n                    heatmap_group, \"rect\", x=x, y=y, width=cell_width - 1, height=cell_height - 1, rx=2, ry=2\n                )\n                rect.set(\"fill\", color)\n                rect.set(\"stroke\", PAGE_BG)\n                rect.set(\"stroke-width\", \"1\")\n\n                if self.show_values:\n                    text_color = self._get_text_color(color)\n                    text_x = x + cell_width / 2\n                    text_y = y + cell_height / 2 + value_font_size * 0.35\n\n                    text_node = self.svg.node(heatmap_group, \"text\", x=text_x, y=text_y)\n                    text_node.set(\"text-anchor\", \"middle\")\n                    text_node.set(\"fill\", text_color)\n                    text_node.set(\"style\", f\"font-size:{value_font_size}px;font-weight:bold;font-family:sans-serif\")\n                    text_node.text = f\"{value:.1f}\"\n\n        colorbar_bar_width = 45\n        colorbar_height = heatmap_height * 0.75\n        colorbar_x = self.view.x(0) + heatmap_x + heatmap_width + 40\n        colorbar_y = self.view.y(n_rows) + label_margin_top + (heatmap_height - colorbar_height) / 2\n\n        n_segments = 60\n        segment_height = colorbar_height / n_segments\n        for seg_i in range(n_segments):\n            seg_value = max_val - (max_val - min_val) * seg_i / (n_segments - 1)\n            seg_color = self._interpolate_color(seg_value, min_val, max_val)\n            seg_y = colorbar_y + seg_i * segment_height\n\n            self.svg.node(\n                heatmap_group,\n                \"rect\",\n                x=colorbar_x,\n                y=seg_y,\n                width=colorbar_bar_width,\n                height=segment_height + 1,\n                fill=seg_color,\n            )\n\n        self.svg.node(\n            heatmap_group,\n            \"rect\",\n            x=colorbar_x,\n            y=colorbar_y,\n            width=colorbar_bar_width,\n            height=colorbar_height,\n            fill=\"none\",\n            stroke=INK_SOFT,\n            stroke_width=\"2\",\n        )\n\n        cb_label_size = 32\n        for val, y_pos in [\n            (max_val, colorbar_y),\n            (0, colorbar_y + colorbar_height / 2),\n            (min_val, colorbar_y + colorbar_height),\n        ]:\n            text_node = self.svg.node(\n                heatmap_group, \"text\", x=colorbar_x + colorbar_bar_width + 12, y=y_pos + cb_label_size * 0.35\n            )\n            text_node.set(\"fill\", INK)\n            text_node.set(\"style\", f\"font-size:{cb_label_size}px;font-family:sans-serif\")\n            text_node.text = f\"{val:+.1f}\"\n\n        cb_title_size = 36\n        cb_title_x = colorbar_x + colorbar_bar_width / 2\n        cb_title_y = colorbar_y - 25\n        text_node = self.svg.node(heatmap_group, \"text\", x=cb_title_x, y=cb_title_y)\n        text_node.set(\"text-anchor\", \"middle\")\n        text_node.set(\"fill\", INK)\n        text_node.set(\"style\", f\"font-size:{cb_title_size}px;font-weight:bold;font-family:sans-serif\")\n        text_node.text = self.colorbar_label\n\n        axis_label_size = 48\n        genes_label_x = self.view.x(0) + 50\n        genes_label_y = self.view.y(n_rows) + label_margin_top + heatmap_height / 2\n        genes_text = self.svg.node(heatmap_group, \"text\", x=genes_label_x, y=genes_label_y)\n        genes_text.set(\"text-anchor\", \"middle\")\n        genes_text.set(\"fill\", INK)\n        genes_text.set(\"style\", f\"font-size:{axis_label_size}px;font-weight:bold;font-family:sans-serif\")\n        genes_text.set(\"transform\", f\"rotate(-90, {genes_label_x}, {genes_label_y})\")\n        genes_text.text = \"Drugs\"\n\n        samples_label_x = self.view.x(0) + heatmap_x + heatmap_width / 2\n        samples_label_y = (\n            self.view.y(n_rows) + label_margin_top + heatmap_height + col_dend_height + label_margin_bottom - 40\n        )\n        samples_text = self.svg.node(heatmap_group, \"text\", x=samples_label_x, y=samples_label_y)\n        samples_text.set(\"text-anchor\", \"middle\")\n        samples_text.set(\"fill\", INK)\n        samples_text.set(\"style\", f\"font-size:{axis_label_size}px;font-weight:bold;font-family:sans-serif\")\n        samples_text.text = \"Cell Lines\"\n\n    def _compute(self):\n        \"\"\"Compute the box for rendering.\"\"\"\n        n_rows = len(self.matrix_data) if self.matrix_data else 1\n        n_cols = len(self.matrix_data[0]) if self.matrix_data and len(self.matrix_data) > 0 else 1\n        self._box.xmin = 0\n        self._box.xmax = n_cols\n        self._box.ymin = 0\n        self._box.ymax = n_rows\n\n\n# Data: Cell-line drug sensitivity (IC50 values) - different domain from tumor/normal gene expression\nnp.random.seed(42)\n\ndrugs = [\n    \"Paclitaxel\",\n    \"Doxorubicin\",\n    \"Cisplatin\",\n    \"Gemcitabine\",\n    \"5-Fluorouracil\",\n    \"Irinotecan\",\n    \"Bortezomib\",\n    \"Sorafenib\",\n    \"Sunitinib\",\n    \"Erlotinib\",\n    \"Gefitinib\",\n    \"Lapatinib\",\n]\n\ncell_lines = [\"A549\", \"HCT116\", \"HT29\", \"MCF7\", \"MDA231\", \"OVCAR3\", \"SKOV3\", \"SW480\", \"U87\", \"KHOS\"]\n\nn_drugs = len(drugs)\nn_lines = len(cell_lines)\n\nsensitivity_data = np.zeros((n_drugs, n_lines))\n\n# Chemotherapy drugs (Paclitaxel, Doxorubicin, Cisplatin, Gemcitabine, 5-FU, Irinotecan)\n# Higher sensitivity (lower IC50) in epithelial-origin lung/colon lines\nchemo_drugs = [0, 1, 2, 3, 4, 5]\nepithelial_lines = [0, 1, 2, 7]\nmesenchymal_lines = [3, 4]\n\nfor i in chemo_drugs:\n    for j in epithelial_lines:\n        sensitivity_data[i, j] = np.random.randn() * 0.6 - 1.2  # High sensitivity\n    for j in mesenchymal_lines:\n        sensitivity_data[i, j] = np.random.randn() * 0.6 + 0.9  # Low sensitivity\n    for j in [5, 6, 8, 9]:\n        sensitivity_data[i, j] = np.random.randn() * 0.7  # Moderate\n\n# Targeted drugs (Bortezomib, Sorafenib, Sunitinib, Erlotinib, Gefitinib, Lapatinib)\ntargeted_drugs = [6, 7, 8, 9, 10, 11]\nfor i in targeted_drugs:\n    for j in [8, 9]:\n        sensitivity_data[i, j] = np.random.randn() * 0.5 - 1.3  # EGFR-mutant lines\n    for j in [0, 1, 2, 3]:\n        sensitivity_data[i, j] = np.random.randn() * 0.5 + 0.5  # Lower sensitivity\n    for j in [4, 5, 6, 7]:\n        sensitivity_data[i, j] = np.random.randn() * 0.6  # Variable\n\nrow_linkage = linkage(pdist(sensitivity_data), method=\"ward\")\ncol_linkage = linkage(pdist(sensitivity_data.T), method=\"ward\")\n\nrow_dend = dendrogram(row_linkage, no_plot=True)\ncol_dend = dendrogram(col_linkage, no_plot=True)\nrow_order = row_dend[\"leaves\"]\ncol_order = col_dend[\"leaves\"]\n\nmatrix_data = sensitivity_data.tolist()\n\n# Diverging colormap: blue (low sensitivity/high IC50) -> white (neutral) -> red (high sensitivity/low IC50)\ndiverging_colormap = [\n    \"#0d47a1\",\n    \"#1565c0\",\n    \"#1e88e5\",\n    \"#42a5f5\",\n    \"#90caf9\",\n    \"#e3f2fd\",\n    \"#ffebee\",\n    \"#ffcdd2\",\n    \"#ef9a9a\",\n    \"#e53935\",\n    \"#c62828\",\n]\n\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_SOFT,\n    colors=(\"#009E73\",),\n    title_font_size=28,\n    label_font_size=18,\n    major_label_font_size=16,\n    legend_font_size=16,\n    value_font_size=14,\n    stroke_width=3,\n)\n\nchart = ClusteredHeatmap(\n    width=4800,\n    height=2700,\n    style=custom_style,\n    title=\"heatmap-clustered · pygal · anyplot.ai\",\n    matrix_data=matrix_data,\n    row_labels=drugs,\n    col_labels=cell_lines,\n    colormap=diverging_colormap,\n    colorbar_label=\"Sensitivity (log IC50)\",\n    row_linkage=row_linkage,\n    col_linkage=col_linkage,\n    row_order=row_order,\n    col_order=col_order,\n    show_values=False,\n    show_legend=False,\n    margin=100,\n    margin_top=180,\n    margin_bottom=80,\n    margin_left=60,\n    show_x_labels=False,\n    show_y_labels=False,\n)\n\nchart.add(\"\", [0])\n\nchart.render_to_png(f\"plot-{THEME}.png\")\n\nchart_svg = chart.render(is_unicode=True)\nif isinstance(chart_svg, bytes):\n    chart_svg = chart_svg.decode(\"utf-8\")\n\nwith open(f\"plot-{THEME}.html\", \"w\", encoding=\"utf-8\") as f:\n    f.write(\n        f\"\"\"<!DOCTYPE html>\n<html>\n<head>\n    <meta charset=\"utf-8\">\n    <title>heatmap-clustered - pygal</title>\n    <style>\n        body {{ margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: {PAGE_BG}; }}\n        .chart {{ max-width: 100%; height: auto; }}\n    </style>\n</head>\n<body>\n    <figure class=\"chart\">\n        {chart_svg}\n    </figure>\n</body>\n</html>\n\"\"\"\n    )\n"}