{"spec_id":"heatmap-correlation","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nheatmap-correlation: Correlation Matrix Heatmap\nLibrary: pygal 3.1.3 | Python 3.13.15\nQuality: 87/100 | Updated: 2026-08-18\n\"\"\"\n\nimport os\nimport sys\n\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\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\nclass CorrelationHeatmap(Graph):\n    \"\"\"Custom Correlation Matrix Heatmap for pygal - displays correlation coefficients with diverging colors.\"\"\"\n\n    def __init__(self, *args, **kwargs):\n        self.matrix_data = kwargs.pop(\"matrix_data\", [])\n        self.labels = kwargs.pop(\"labels\", [])\n        self.colormap = kwargs.pop(\"colormap\", [])\n        self.show_values = kwargs.pop(\"show_values\", True)\n        self.x_axis_title = kwargs.pop(\"x_axis_title\", \"\")\n        self.y_axis_title = kwargs.pop(\"y_axis_title\", \"\")\n        super().__init__(*args, **kwargs)\n\n    def _interpolate_color(self, value):\n        \"\"\"Interpolate color for diverging colormap centered at 0, fixed range -1 to 1.\"\"\"\n        normalized = (value + 1) / 2\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 \"#1A1A17\"\n\n    def _plot(self):\n        \"\"\"Draw the correlation matrix heatmap.\"\"\"\n        if not self.matrix_data:\n            return\n\n        n = len(self.matrix_data)\n\n        plot_width = self.view.width\n        plot_height = self.view.height\n\n        # Row-label width scales with the longest label so the y-axis title never\n        # collides with row text, regardless of how long the variable names are.\n        max_row_chars = max((len(label) for label in self.labels), default=0)\n        row_label_width = max_row_chars * 54 * 0.56\n        y_title_block = 90 if self.y_axis_title else 0\n\n        # Column labels are rotated 45deg, so their vertical drop below the grid\n        # scales with label length too — reserve room before the x-axis title.\n        max_col_chars = max((len(label) for label in self.labels), default=0)\n        col_label_drop = max_col_chars * 54 * 0.56 * 0.7071\n        x_title_block = 90 if self.x_axis_title else 0\n\n        label_margin_left = row_label_width + y_title_block + 60\n        label_margin_bottom = 25 + col_label_drop + x_title_block + 40\n        label_margin_top = 20\n        label_margin_right = 320\n\n        available_width = plot_width - label_margin_left - label_margin_right\n        available_height = plot_height - label_margin_bottom - label_margin_top\n\n        cell_size = min(available_width, available_height) / n * 0.95\n        gap = cell_size * 0.02\n\n        grid_size = n * (cell_size + gap) - gap\n\n        left_edge = self.view.x(0)\n        x_offset = left_edge + label_margin_left + (available_width - grid_size) / 2\n        y_offset = self.view.y(n) + label_margin_top + (available_height - grid_size) / 2\n\n        plot_node = self.nodes[\"plot\"]\n        heatmap_group = self.svg.node(plot_node, class_=\"correlation-heatmap\")\n\n        if self.y_axis_title:\n            y_title_size = 52\n            y_title_x = left_edge + y_title_block / 2\n            y_title_y = y_offset + grid_size / 2\n            text_node = self.svg.node(heatmap_group, \"text\", x=y_title_x, y=y_title_y)\n            text_node.set(\"text-anchor\", \"middle\")\n            text_node.set(\"fill\", INK)\n            text_node.set(\"style\", f\"font-size:{y_title_size}px;font-weight:bold;font-family:sans-serif\")\n            text_node.set(\"transform\", f\"rotate(-90, {y_title_x}, {y_title_y})\")\n            text_node.text = self.y_axis_title\n\n        row_font_size = min(54, int(cell_size * 0.55))\n        for i, label in enumerate(self.labels):\n            y = y_offset + i * (cell_size + gap) + cell_size / 2\n            text_node = self.svg.node(heatmap_group, \"text\", x=x_offset - 25, y=y + row_font_size * 0.35)\n            text_node.set(\"text-anchor\", \"end\")\n            text_node.set(\"fill\", INK_SOFT)\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(54, int(cell_size * 0.55))\n        col_label_y = y_offset + n * (cell_size + gap) + 25\n        for j, label in enumerate(self.labels):\n            x = x_offset + j * (cell_size + gap) + cell_size / 2\n            text_node = self.svg.node(heatmap_group, \"text\", x=x, y=col_label_y)\n            text_node.set(\"text-anchor\", \"start\")\n            text_node.set(\"fill\", INK_SOFT)\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}, {col_label_y})\")\n            text_node.text = label\n\n        if self.x_axis_title:\n            x_title_size = 52\n            x_title_x = x_offset + grid_size / 2\n            x_title_y = col_label_y + col_label_drop + 40\n            text_node = self.svg.node(heatmap_group, \"text\", x=x_title_x, y=x_title_y)\n            text_node.set(\"text-anchor\", \"middle\")\n            text_node.set(\"fill\", INK)\n            text_node.set(\"style\", f\"font-size:{x_title_size}px;font-weight:bold;font-family:sans-serif\")\n            text_node.text = self.x_axis_title\n\n        value_font_size = min(46, int(cell_size * 0.38))\n        for i in range(n):\n            for j in range(n):\n                value = self.matrix_data[i][j]\n                color = self._interpolate_color(value)\n                text_color = self._get_text_color(color)\n\n                x = x_offset + j * (cell_size + gap)\n                y = y_offset + i * (cell_size + gap)\n\n                rect = self.svg.node(heatmap_group, \"rect\", x=x, y=y, width=cell_size, height=cell_size, rx=4, ry=4)\n                rect.set(\"fill\", color)\n                rect.set(\"stroke\", PAGE_BG)\n                rect.set(\"stroke-width\", \"2\")\n\n                if self.show_values:\n                    text_x = x + cell_size / 2\n                    text_y = y + cell_size / 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:.2f}\"\n\n        colorbar_width = 55\n        colorbar_height = grid_size * 0.85\n        colorbar_x = x_offset + grid_size + 80\n        colorbar_y = y_offset + (grid_size - 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 = 1 - (2 * seg_i / (n_segments - 1))\n            seg_color = self._interpolate_color(seg_value)\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_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_width,\n            height=colorbar_height,\n            fill=\"none\",\n            stroke=INK_SOFT,\n        )\n\n        cb_label_size = 42\n        text_node = self.svg.node(\n            heatmap_group, \"text\", x=colorbar_x + colorbar_width + 18, y=colorbar_y + cb_label_size * 0.35\n        )\n        text_node.set(\"fill\", INK_SOFT)\n        text_node.set(\"style\", f\"font-size:{cb_label_size}px;font-family:sans-serif\")\n        text_node.text = \"+1.00\"\n\n        mid_y = colorbar_y + colorbar_height / 2\n        text_node = self.svg.node(\n            heatmap_group, \"text\", x=colorbar_x + colorbar_width + 18, y=mid_y + cb_label_size * 0.35\n        )\n        text_node.set(\"fill\", INK_SOFT)\n        text_node.set(\"style\", f\"font-size:{cb_label_size}px;font-family:sans-serif\")\n        text_node.text = \" 0.00\"\n\n        text_node = self.svg.node(\n            heatmap_group,\n            \"text\",\n            x=colorbar_x + colorbar_width + 18,\n            y=colorbar_y + colorbar_height + cb_label_size * 0.35,\n        )\n        text_node.set(\"fill\", INK_SOFT)\n        text_node.set(\"style\", f\"font-size:{cb_label_size}px;font-family:sans-serif\")\n        text_node.text = \"-1.00\"\n\n        cb_title_size = 46\n        cb_title_x = colorbar_x + colorbar_width / 2\n        cb_title_y = colorbar_y - 40\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 = \"Correlation\"\n\n    def _compute(self):\n        \"\"\"Compute the box for rendering.\"\"\"\n        n = len(self.matrix_data) if self.matrix_data else 1\n        self._box.xmin = 0\n        self._box.xmax = n\n        self._box.ymin = 0\n        self._box.ymax = n\n\n\n# Data: Correlation matrix for molecular descriptors (cheminformatics QSAR feature set)\nvariables = [\n    \"Mol. Weight\",\n    \"LogP\",\n    \"TPSA\",\n    \"H-Bond Donors\",\n    \"H-Bond Acceptors\",\n    \"Rotatable Bonds\",\n    \"Aromatic Rings\",\n    \"LogS\",\n]\nn = len(variables)\n\nmatrix_data = [\n    [1.00, 0.45, 0.55, 0.25, 0.60, 0.70, 0.50, -0.55],\n    [0.45, 1.00, -0.75, -0.50, -0.35, 0.20, 0.40, -0.80],\n    [0.55, -0.75, 1.00, 0.80, 0.85, 0.15, -0.10, 0.50],\n    [0.25, -0.50, 0.80, 1.00, 0.45, 0.10, -0.15, 0.40],\n    [0.60, -0.35, 0.85, 0.45, 1.00, 0.25, 0.05, 0.35],\n    [0.70, 0.20, 0.15, 0.10, 0.25, 1.00, -0.20, -0.15],\n    [0.50, 0.40, -0.10, -0.15, 0.05, -0.20, 1.00, -0.35],\n    [-0.55, -0.80, 0.50, 0.40, 0.35, -0.15, -0.35, 1.00],\n]\n\n# Custom style with theme-adaptive colors\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=(\"#009E73\",),\n    title_font_size=72,\n    legend_font_size=48,\n    label_font_size=50,\n    value_font_size=44,\n    font_family=\"sans-serif\",\n)\n\n\ndef _lerp_hex(c0, c1, t):\n    \"\"\"Linearly interpolate between two hex colors.\"\"\"\n    r0, g0, b0 = (int(c0[i : i + 2], 16) for i in (1, 3, 5))\n    r1, g1, b1 = (int(c1[i : i + 2], 16) for i in (1, 3, 5))\n    r, g, b = (int(round(a + (b - a) * t)) for a, b in ((r0, r1), (g0, g1), (b0, b1)))\n    return f\"#{r:02x}{g:02x}{b:02x}\"\n\n\n# Diverging Imprint colormap: matte-red (negative) -> theme-adaptive midpoint (zero) -> blue (positive)\n_half_stops = 16\ndiverging_colormap = [_lerp_hex(\"#AE3030\", PAGE_BG, i / _half_stops) for i in range(_half_stops)] + [\n    _lerp_hex(PAGE_BG, \"#4467A3\", i / _half_stops) for i in range(_half_stops + 1)\n]\n\n# Create correlation heatmap\nchart = CorrelationHeatmap(\n    width=2400,\n    height=2400,\n    style=custom_style,\n    title=\"heatmap-correlation · pygal · anyplot.ai\",\n    matrix_data=matrix_data,\n    labels=variables,\n    colormap=diverging_colormap,\n    show_values=True,\n    show_legend=False,\n    margin=120,\n    margin_top=60,\n    margin_bottom=100,\n    show_x_labels=False,\n    show_y_labels=False,\n    x_axis_title=\"Molecular Descriptors\",\n    y_axis_title=\"Molecular Descriptors\",\n)\n\nchart.add(\"\", [0])\n\n# Save outputs with theme-suffixed filenames\nchart.render_to_file(f\"plot-{THEME}.svg\")\nchart.render_to_png(f\"plot-{THEME}.png\")\n\n# Save HTML for interactivity\nhtml_content = f\"\"\"<!DOCTYPE html>\n<html>\n<head>\n    <meta charset=\"utf-8\">\n    <title>heatmap-correlation - 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.render(is_unicode=True)}\n    </figure>\n</body>\n</html>\n\"\"\"\n\nwith open(f\"plot-{THEME}.html\", \"w\", encoding=\"utf-8\") as f:\n    f.write(html_content)\n"}