{"spec_id":"heatmap-annotated","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nheatmap-annotated: Annotated Heatmap\nLibrary: pygal 3.1.3 | Python 3.13.14\nQuality: 89/100 | Updated: 2026-08-05\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\"\nRULE = \"rgba(26,26,23,0.10)\" if THEME == \"light\" else \"rgba(240,239,232,0.10)\"\n\n\nclass AnnotatedHeatmap(Graph):\n    \"\"\"Custom Annotated Heatmap for pygal - correlation matrix with values and contrasting text.\"\"\"\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\", True)\n        self.value_format = kwargs.pop(\"value_format\", \".2f\")\n        self.is_symmetric = kwargs.pop(\"is_symmetric\", False)\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        # Normalize against the true correlation domain, not the data's own min/max,\n        # so the neutral midpoint only appears at a real zero-correlation value.\n        normalized = (value + 1) / 2\n        normalized = max(0, min(1, normalized))\n\n        # Get position in colormap\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        # Interpolate between colors\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 (white or dark) 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        # Calculate perceived brightness using ITU-R BT.601\n        brightness = (r * 299 + g * 587 + b * 114) / 1000\n        return \"#ffffff\" if brightness < 140 else \"#333333\"\n\n    def _plot(self):\n        \"\"\"Draw the annotated heatmap.\"\"\"\n        if not self.matrix_data:\n            return\n\n        n_rows = len(self.matrix_data)\n        n_cols = len(self.matrix_data[0]) if n_rows > 0 else 0\n\n        # Find value range\n        all_values = [v for row in self.matrix_data for v in row]\n        min_val = min(all_values)\n        max_val = max(all_values)\n\n        # Get plot dimensions\n        plot_width = self.view.width\n        plot_height = self.view.height\n\n        # Calculate cell size - leave space for labels\n        label_margin_left = 400\n        label_margin_bottom = 220\n        label_margin_top = 20\n        label_margin_right = 280\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_width = available_width / n_cols * 0.95\n        cell_height = available_height / n_rows * 0.95\n        gap = min(cell_width, cell_height) * 0.03\n\n        # Calculate offsets to center the grid\n        grid_width = n_cols * (cell_width + gap) - gap\n        grid_height = n_rows * (cell_height + gap) - gap\n\n        x_offset = self.view.x(0) + label_margin_left + (available_width - grid_width) / 2\n        y_offset = self.view.y(n_rows) + label_margin_top + (available_height - grid_height) / 2\n\n        # Create group for the heatmap\n        plot_node = self.nodes[\"plot\"]\n        heatmap_group = self.svg.node(plot_node, class_=\"annotated-heatmap\")\n\n        # Draw row labels on the left\n        row_font_size = min(44, int(cell_height * 0.55))\n        for i, label in enumerate(self.row_labels):\n            y = y_offset + i * (cell_height + gap) + cell_height / 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)\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        # Draw column labels at the bottom (rotated for better fit)\n        col_font_size = min(44, int(cell_width * 0.55))\n        for j, label in enumerate(self.col_labels):\n            x = x_offset + j * (cell_width + gap) + cell_width / 2\n            y = y_offset + n_rows * (cell_height + gap) + 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        # Draw cells with annotations\n        value_font_size = min(42, int(min(cell_width, cell_height) * 0.35))\n        for i in range(n_rows):\n            for j in range(n_cols):\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_width + gap)\n                y = y_offset + i * (cell_height + gap)\n\n                # Draw cell rectangle with rounded corners\n                rect = self.svg.node(heatmap_group, \"rect\", x=x, y=y, width=cell_width, height=cell_height, rx=4, ry=4)\n                rect.set(\"fill\", color)\n                rect.set(\"stroke\", PAGE_BG)\n                rect.set(\"stroke-width\", \"2\")\n\n                # Add value annotation with automatic contrast\n                if self.show_values:\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:{self.value_format}}\"\n\n        # Draw colorbar on the right\n        colorbar_width = 55\n        colorbar_height = grid_height * 0.8\n        colorbar_x = x_offset + grid_width + 90\n        colorbar_y = y_offset + (grid_height - colorbar_height) / 2\n\n        # Draw gradient colorbar using multiple rectangles\n        n_segments = 50\n        segment_height = colorbar_height / n_segments\n        for seg_i in range(n_segments):\n            seg_value = min_val + (max_val - min_val) * (n_segments - 1 - 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        # Colorbar border\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,\n            stroke_width=\"2\",\n        )\n\n        # Colorbar labels\n        cb_label_size = 38\n        # Max value\n        text_node = self.svg.node(\n            heatmap_group, \"text\", x=colorbar_x + colorbar_width + 15, y=colorbar_y + 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\"{max_val:{self.value_format}}\"\n\n        # Mid value\n        mid_y = colorbar_y + colorbar_height / 2\n        text_node = self.svg.node(\n            heatmap_group, \"text\", x=colorbar_x + colorbar_width + 15, y=mid_y + 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\"{(min_val + max_val) / 2:{self.value_format}}\"\n\n        # Min value\n        text_node = self.svg.node(\n            heatmap_group,\n            \"text\",\n            x=colorbar_x + colorbar_width + 15,\n            y=colorbar_y + colorbar_height + 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\"{min_val:{self.value_format}}\"\n\n        # Colorbar title\n        cb_title_size = 42\n        cb_title_x = colorbar_x + colorbar_width / 2\n        cb_title_y = colorbar_y - 35\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_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: Correlation matrix for business metrics (symmetric, hardcoded for reproducibility)\n\n# Variable names for correlation matrix\nvariables = [\"Revenue\", \"Marketing\", \"R&D Spend\", \"Customers\", \"Satisfaction\", \"Retention\"]\nn = len(variables)\n\n# Realistic correlation matrix (symmetric, diagonal = 1.0), including a couple of\n# mild negative correlations (R&D spend competes with marketing budget and briefly\n# disrupts satisfaction) to exercise the full diverging colormap.\ncorrelation_matrix = [\n    [1.00, 0.85, 0.42, 0.78, 0.65, 0.72],  # Revenue\n    [0.85, 1.00, -0.18, 0.68, 0.55, 0.62],  # Marketing\n    [0.42, -0.18, 1.00, 0.28, -0.12, 0.38],  # R&D Spend\n    [0.78, 0.68, 0.28, 1.00, 0.82, 0.88],  # Customers\n    [0.65, 0.55, -0.12, 0.82, 1.00, 0.75],  # Satisfaction\n    [0.72, 0.62, 0.38, 0.88, 0.75, 1.00],  # Retention\n]\n\n# Theme-adaptive custom style\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=44,\n    value_font_size=38,\n    font_family=\"sans-serif\",\n)\n\n# Diverging colormap (Imprint imprint_div): matte-red -> theme midpoint -> blue\nMIDPOINT = PAGE_BG\ndiverging_colormap = [\n    \"#AE3030\",  # matte red (low correlation)\n    MIDPOINT,  # theme-adaptive neutral midpoint\n    \"#4467A3\",  # blue (high correlation)\n]\n\n# Create annotated heatmap\nchart = AnnotatedHeatmap(\n    width=2400,\n    height=2400,\n    style=custom_style,\n    title=\"heatmap-annotated · pygal · anyplot.ai\",\n    matrix_data=correlation_matrix,\n    row_labels=variables,\n    col_labels=variables,\n    colormap=diverging_colormap,\n    show_values=True,\n    value_format=\".2f\",\n    is_symmetric=True,\n    show_legend=False,\n    margin=120,\n    margin_top=150,\n    margin_bottom=100,\n    show_x_labels=False,\n    show_y_labels=False,\n)\n\n# Add a dummy series to trigger _plot (pygal requires at least one series)\nchart.add(\"\", [0])\n\n# Save outputs with theme-suffixed filenames\nchart.render_to_png(f\"plot-{THEME}.png\")\n\n# Also save HTML for interactivity\nchart_svg = chart.render(is_unicode=True)\nhtml_content = f\"\"\"<!DOCTYPE html>\n<html>\n<head>\n    <meta charset=\"utf-8\">\n    <title>heatmap-annotated - 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\nwith open(f\"plot-{THEME}.html\", \"w\", encoding=\"utf-8\") as f:\n    f.write(html_content)\n"}