Clustered Heatmap — Pygal

A heatmap with hierarchical clustering dendrograms on rows and/or columns, showing both data values and their hierarchical relationships. Rows and columns are automatically reordered based on clustering results to reveal natural groupings in the data. Essential for discovering patterns in high-dimensional data where similar observations or variables should be visually grouped together.

Clustered Heatmap rendered with Pygal

Python source (Pygal)

""" anyplot.ai
heatmap-clustered: Clustered Heatmap
Library: pygal 3.1.0 | Python 3.13.13
Quality: 92/100 | Updated: 2026-05-09
"""

import os
import sys

import numpy as np
from scipy.cluster.hierarchy import dendrogram, linkage
from scipy.spatial.distance import pdist


THEME = os.getenv("ANYPLOT_THEME", "light")

# Temporarily remove current directory from path to avoid name collision
_cwd = sys.path[0] if sys.path[0] else "."
if _cwd in sys.path:
    sys.path.remove(_cwd)

from pygal.graph.graph import Graph
from pygal.style import Style


# Restore path
sys.path.insert(0, _cwd)

# Theme tokens
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"


class ClusteredHeatmap(Graph):
    """Custom Clustered Heatmap for pygal - displays matrix with hierarchical clustering dendrograms."""

    def __init__(self, *args, **kwargs):
        self.matrix_data = kwargs.pop("matrix_data", [])
        self.row_labels = kwargs.pop("row_labels", [])
        self.col_labels = kwargs.pop("col_labels", [])
        self.colormap = kwargs.pop("colormap", [])
        self.show_values = kwargs.pop("show_values", False)
        self.row_linkage = kwargs.pop("row_linkage", None)
        self.col_linkage = kwargs.pop("col_linkage", None)
        self.row_order = kwargs.pop("row_order", None)
        self.col_order = kwargs.pop("col_order", None)
        self.colorbar_label = kwargs.pop("colorbar_label", "Value")
        super().__init__(*args, **kwargs)

    def _interpolate_color(self, value, min_val, max_val):
        """Interpolate color for diverging colormap."""
        if max_val == min_val:
            return self.colormap[len(self.colormap) // 2]

        normalized = (value - min_val) / (max_val - min_val)
        normalized = max(0, min(1, normalized))

        pos = normalized * (len(self.colormap) - 1)
        idx1 = int(pos)
        idx2 = min(idx1 + 1, len(self.colormap) - 1)
        frac = pos - idx1

        c1 = self.colormap[idx1]
        c2 = self.colormap[idx2]

        r1, g1, b1 = int(c1[1:3], 16), int(c1[3:5], 16), int(c1[5:7], 16)
        r2, g2, b2 = int(c2[1:3], 16), int(c2[3:5], 16), int(c2[5:7], 16)

        r = int(r1 + (r2 - r1) * frac)
        g = int(g1 + (g2 - g1) * frac)
        b = int(b1 + (b2 - b1) * frac)

        return f"#{r:02x}{g:02x}{b:02x}"

    def _get_text_color(self, bg_color):
        """Get contrasting text color based on background brightness."""
        r, g, b = int(bg_color[1:3], 16), int(bg_color[3:5], 16), int(bg_color[5:7], 16)
        brightness = (r * 299 + g * 587 + b * 114) / 1000
        return "#ffffff" if brightness < 140 else INK

    def _draw_dendrogram(self, group, linkage_matrix, x_offset, y_offset, width, height, orientation="left"):
        """Draw dendrogram from hierarchical clustering linkage matrix."""
        if linkage_matrix is None or len(linkage_matrix) == 0:
            return

        dend = dendrogram(linkage_matrix, no_plot=True, orientation=orientation)

        icoord = np.array(dend["icoord"])
        dcoord = np.array(dend["dcoord"])

        max_d = dcoord.max() if dcoord.max() > 0 else 1
        n_leaves = len(dend["leaves"])
        stroke_width = 3

        for i in range(len(icoord)):
            if orientation in ("left", "right"):
                xs = dcoord[i] / max_d * width
                ys = (icoord[i] / (n_leaves * 10)) * height

                if orientation == "left":
                    xs = width - xs

                path_data = f"M {x_offset + xs[0]} {y_offset + ys[0]} "
                for j in range(1, 4):
                    path_data += f"L {x_offset + xs[j]} {y_offset + ys[j]} "

            else:
                xs = (icoord[i] / (n_leaves * 10)) * width
                ys = dcoord[i] / max_d * height

                if orientation == "top":
                    ys = height - ys

                path_data = f"M {x_offset + xs[0]} {y_offset + ys[0]} "
                for j in range(1, 4):
                    path_data += f"L {x_offset + xs[j]} {y_offset + ys[j]} "

            path = self.svg.node(group, "path")
            path.set("d", path_data)
            path.set("fill", "none")
            path.set("stroke", INK_SOFT)
            path.set("stroke-width", str(stroke_width))

    def _plot(self):
        """Draw the clustered heatmap with dendrograms."""
        if not self.matrix_data:
            return

        matrix = np.array(self.matrix_data)
        if self.row_order is not None:
            matrix = matrix[self.row_order, :]
            reordered_row_labels = [self.row_labels[i] for i in self.row_order]
        else:
            reordered_row_labels = self.row_labels

        if self.col_order is not None:
            matrix = matrix[:, self.col_order]
            reordered_col_labels = [self.col_labels[i] for i in self.col_order]
        else:
            reordered_col_labels = self.col_labels

        n_rows, n_cols = matrix.shape

        min_val = matrix.min()
        max_val = matrix.max()
        abs_max = max(abs(min_val), abs(max_val))
        min_val, max_val = -abs_max, abs_max

        plot_width = self.view.width
        plot_height = self.view.height

        axis_label_width = 80
        row_dend_width = 280
        col_dend_height = 180
        label_margin_left = 280
        label_margin_bottom = 300
        label_margin_top = 80
        colorbar_width = 180

        heatmap_x = axis_label_width + row_dend_width + label_margin_left
        heatmap_width = plot_width - heatmap_x - colorbar_width - 20
        heatmap_height = plot_height - col_dend_height - label_margin_bottom - label_margin_top

        cell_width = heatmap_width / n_cols
        cell_height = heatmap_height / n_rows

        plot_node = self.nodes["plot"]
        heatmap_group = self.svg.node(plot_node, class_="clustered-heatmap")

        self._draw_dendrogram(
            heatmap_group,
            self.row_linkage,
            self.view.x(0) + axis_label_width + 40,
            self.view.y(n_rows) + label_margin_top,
            row_dend_width - 40,
            heatmap_height,
            orientation="left",
        )

        self._draw_dendrogram(
            heatmap_group,
            self.col_linkage,
            self.view.x(0) + heatmap_x,
            self.view.y(n_rows) + heatmap_height + label_margin_top + 10,
            heatmap_width,
            col_dend_height,
            orientation="bottom",
        )

        row_font_size = min(38, int(cell_height * 0.7))
        for i, label in enumerate(reordered_row_labels):
            x = self.view.x(0) + heatmap_x - 15
            y = self.view.y(n_rows) + label_margin_top + i * cell_height + cell_height / 2
            text_node = self.svg.node(heatmap_group, "text", x=x, y=y + row_font_size * 0.35)
            text_node.set("text-anchor", "end")
            text_node.set("fill", INK)
            text_node.set("style", f"font-size:{row_font_size}px;font-weight:600;font-family:sans-serif")
            text_node.text = label

        col_font_size = min(30, int(cell_width * 0.5))
        for j, label in enumerate(reordered_col_labels):
            x = self.view.x(0) + heatmap_x + j * cell_width + cell_width / 2
            y = self.view.y(n_rows) + label_margin_top + heatmap_height + col_dend_height + 25
            text_node = self.svg.node(heatmap_group, "text", x=x, y=y)
            text_node.set("text-anchor", "start")
            text_node.set("fill", INK)
            text_node.set("style", f"font-size:{col_font_size}px;font-weight:600;font-family:sans-serif")
            text_node.set("transform", f"rotate(45, {x}, {y})")
            text_node.text = label

        value_font_size = min(28, int(min(cell_width, cell_height) * 0.35))
        for i in range(n_rows):
            for j in range(n_cols):
                value = matrix[i, j]
                color = self._interpolate_color(value, min_val, max_val)

                x = self.view.x(0) + heatmap_x + j * cell_width
                y = self.view.y(n_rows) + label_margin_top + i * cell_height

                rect = self.svg.node(
                    heatmap_group, "rect", x=x, y=y, width=cell_width - 1, height=cell_height - 1, rx=2, ry=2
                )
                rect.set("fill", color)
                rect.set("stroke", PAGE_BG)
                rect.set("stroke-width", "1")

                if self.show_values:
                    text_color = self._get_text_color(color)
                    text_x = x + cell_width / 2
                    text_y = y + cell_height / 2 + value_font_size * 0.35

                    text_node = self.svg.node(heatmap_group, "text", x=text_x, y=text_y)
                    text_node.set("text-anchor", "middle")
                    text_node.set("fill", text_color)
                    text_node.set("style", f"font-size:{value_font_size}px;font-weight:bold;font-family:sans-serif")
                    text_node.text = f"{value:.1f}"

        colorbar_bar_width = 45
        colorbar_height = heatmap_height * 0.75
        colorbar_x = self.view.x(0) + heatmap_x + heatmap_width + 40
        colorbar_y = self.view.y(n_rows) + label_margin_top + (heatmap_height - colorbar_height) / 2

        n_segments = 60
        segment_height = colorbar_height / n_segments
        for seg_i in range(n_segments):
            seg_value = max_val - (max_val - min_val) * seg_i / (n_segments - 1)
            seg_color = self._interpolate_color(seg_value, min_val, max_val)
            seg_y = colorbar_y + seg_i * segment_height

            self.svg.node(
                heatmap_group,
                "rect",
                x=colorbar_x,
                y=seg_y,
                width=colorbar_bar_width,
                height=segment_height + 1,
                fill=seg_color,
            )

        self.svg.node(
            heatmap_group,
            "rect",
            x=colorbar_x,
            y=colorbar_y,
            width=colorbar_bar_width,
            height=colorbar_height,
            fill="none",
            stroke=INK_SOFT,
            stroke_width="2",
        )

        cb_label_size = 32
        for val, y_pos in [
            (max_val, colorbar_y),
            (0, colorbar_y + colorbar_height / 2),
            (min_val, colorbar_y + colorbar_height),
        ]:
            text_node = self.svg.node(
                heatmap_group, "text", x=colorbar_x + colorbar_bar_width + 12, y=y_pos + cb_label_size * 0.35
            )
            text_node.set("fill", INK)
            text_node.set("style", f"font-size:{cb_label_size}px;font-family:sans-serif")
            text_node.text = f"{val:+.1f}"

        cb_title_size = 36
        cb_title_x = colorbar_x + colorbar_bar_width / 2
        cb_title_y = colorbar_y - 25
        text_node = self.svg.node(heatmap_group, "text", x=cb_title_x, y=cb_title_y)
        text_node.set("text-anchor", "middle")
        text_node.set("fill", INK)
        text_node.set("style", f"font-size:{cb_title_size}px;font-weight:bold;font-family:sans-serif")
        text_node.text = self.colorbar_label

        axis_label_size = 48
        genes_label_x = self.view.x(0) + 50
        genes_label_y = self.view.y(n_rows) + label_margin_top + heatmap_height / 2
        genes_text = self.svg.node(heatmap_group, "text", x=genes_label_x, y=genes_label_y)
        genes_text.set("text-anchor", "middle")
        genes_text.set("fill", INK)
        genes_text.set("style", f"font-size:{axis_label_size}px;font-weight:bold;font-family:sans-serif")
        genes_text.set("transform", f"rotate(-90, {genes_label_x}, {genes_label_y})")
        genes_text.text = "Drugs"

        samples_label_x = self.view.x(0) + heatmap_x + heatmap_width / 2
        samples_label_y = (
            self.view.y(n_rows) + label_margin_top + heatmap_height + col_dend_height + label_margin_bottom - 40
        )
        samples_text = self.svg.node(heatmap_group, "text", x=samples_label_x, y=samples_label_y)
        samples_text.set("text-anchor", "middle")
        samples_text.set("fill", INK)
        samples_text.set("style", f"font-size:{axis_label_size}px;font-weight:bold;font-family:sans-serif")
        samples_text.text = "Cell Lines"

    def _compute(self):
        """Compute the box for rendering."""
        n_rows = len(self.matrix_data) if self.matrix_data else 1
        n_cols = len(self.matrix_data[0]) if self.matrix_data and len(self.matrix_data) > 0 else 1
        self._box.xmin = 0
        self._box.xmax = n_cols
        self._box.ymin = 0
        self._box.ymax = n_rows


# Data: Cell-line drug sensitivity (IC50 values) - different domain from tumor/normal gene expression
np.random.seed(42)

drugs = [
    "Paclitaxel",
    "Doxorubicin",
    "Cisplatin",
    "Gemcitabine",
    "5-Fluorouracil",
    "Irinotecan",
    "Bortezomib",
    "Sorafenib",
    "Sunitinib",
    "Erlotinib",
    "Gefitinib",
    "Lapatinib",
]

cell_lines = ["A549", "HCT116", "HT29", "MCF7", "MDA231", "OVCAR3", "SKOV3", "SW480", "U87", "KHOS"]

n_drugs = len(drugs)
n_lines = len(cell_lines)

sensitivity_data = np.zeros((n_drugs, n_lines))

# Chemotherapy drugs (Paclitaxel, Doxorubicin, Cisplatin, Gemcitabine, 5-FU, Irinotecan)
# Higher sensitivity (lower IC50) in epithelial-origin lung/colon lines
chemo_drugs = [0, 1, 2, 3, 4, 5]
epithelial_lines = [0, 1, 2, 7]
mesenchymal_lines = [3, 4]

for i in chemo_drugs:
    for j in epithelial_lines:
        sensitivity_data[i, j] = np.random.randn() * 0.6 - 1.2  # High sensitivity
    for j in mesenchymal_lines:
        sensitivity_data[i, j] = np.random.randn() * 0.6 + 0.9  # Low sensitivity
    for j in [5, 6, 8, 9]:
        sensitivity_data[i, j] = np.random.randn() * 0.7  # Moderate

# Targeted drugs (Bortezomib, Sorafenib, Sunitinib, Erlotinib, Gefitinib, Lapatinib)
targeted_drugs = [6, 7, 8, 9, 10, 11]
for i in targeted_drugs:
    for j in [8, 9]:
        sensitivity_data[i, j] = np.random.randn() * 0.5 - 1.3  # EGFR-mutant lines
    for j in [0, 1, 2, 3]:
        sensitivity_data[i, j] = np.random.randn() * 0.5 + 0.5  # Lower sensitivity
    for j in [4, 5, 6, 7]:
        sensitivity_data[i, j] = np.random.randn() * 0.6  # Variable

row_linkage = linkage(pdist(sensitivity_data), method="ward")
col_linkage = linkage(pdist(sensitivity_data.T), method="ward")

row_dend = dendrogram(row_linkage, no_plot=True)
col_dend = dendrogram(col_linkage, no_plot=True)
row_order = row_dend["leaves"]
col_order = col_dend["leaves"]

matrix_data = sensitivity_data.tolist()

# Diverging colormap: blue (low sensitivity/high IC50) -> white (neutral) -> red (high sensitivity/low IC50)
diverging_colormap = [
    "#0d47a1",
    "#1565c0",
    "#1e88e5",
    "#42a5f5",
    "#90caf9",
    "#e3f2fd",
    "#ffebee",
    "#ffcdd2",
    "#ef9a9a",
    "#e53935",
    "#c62828",
]

custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_SOFT,
    colors=("#009E73",),
    title_font_size=28,
    label_font_size=18,
    major_label_font_size=16,
    legend_font_size=16,
    value_font_size=14,
    stroke_width=3,
)

chart = ClusteredHeatmap(
    width=4800,
    height=2700,
    style=custom_style,
    title="heatmap-clustered · pygal · anyplot.ai",
    matrix_data=matrix_data,
    row_labels=drugs,
    col_labels=cell_lines,
    colormap=diverging_colormap,
    colorbar_label="Sensitivity (log IC50)",
    row_linkage=row_linkage,
    col_linkage=col_linkage,
    row_order=row_order,
    col_order=col_order,
    show_values=False,
    show_legend=False,
    margin=100,
    margin_top=180,
    margin_bottom=80,
    margin_left=60,
    show_x_labels=False,
    show_y_labels=False,
)

chart.add("", [0])

chart.render_to_png(f"plot-{THEME}.png")

chart_svg = chart.render(is_unicode=True)
if isinstance(chart_svg, bytes):
    chart_svg = chart_svg.decode("utf-8")

with open(f"plot-{THEME}.html", "w", encoding="utf-8") as f:
    f.write(
        f"""<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>heatmap-clustered - pygal</title>
    <style>
        body {{ margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; background: {PAGE_BG}; }}
        .chart {{ max-width: 100%; height: auto; }}
    </style>
</head>
<body>
    <figure class="chart">
        {chart_svg}
    </figure>
</body>
</html>
"""
    )

Part of Clustered Heatmap on anyplot.ai.

Other implementations