Basic Heatmap — Pygal

A heatmap displaying values in a matrix format using color intensity. Each cell's color represents the magnitude of the value, making it easy to identify patterns, clusters, and outliers in two-dimensional data. Essential for visualizing correlations, frequencies, and relationships between variables.

Basic Heatmap rendered with Pygal

Python source (Pygal)

""" anyplot.ai
heatmap-basic: Basic Heatmap
Library: pygal 3.1.0 | Python 3.13.13
Quality: 88/100 | Updated: 2026-05-28
"""

import importlib
import os
import re
import sys

import numpy as np


# Import pygal avoiding name collision with this filename
_cwd = sys.path[0]
sys.path[:] = [p for p in sys.path if p != _cwd]
_pygal = importlib.import_module("pygal")
_Style = importlib.import_module("pygal.style").Style
_cairosvg = importlib.import_module("cairosvg")
sys.path.insert(0, _cwd)

# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Data — Monthly website traffic (thousands) across content categories
np.random.seed(42)

months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]

base_traffic = {
    "Tech": [120, 115, 130, 135, 140, 125, 110, 105, 145, 155, 160, 150],
    "Science": [85, 80, 90, 95, 88, 82, 78, 75, 92, 98, 95, 88],
    "Health": [95, 110, 105, 100, 90, 85, 80, 82, 115, 120, 108, 130],
    "Finance": [140, 135, 150, 145, 130, 125, 120, 118, 155, 160, 165, 173],
    "Sports": [70, 65, 80, 85, 95, 100, 105, 110, 90, 75, 72, 68],
    "Travel": [60, 55, 75, 90, 110, 130, 140, 148, 105, 80, 65, 58],
    "Food": [88, 82, 85, 90, 95, 92, 98, 100, 88, 92, 105, 115],
    "Culture": [72, 68, 78, 82, 85, 88, 75, 70, 80, 90, 95, 92],
}

categories = sorted(base_traffic.keys(), key=lambda c: sum(base_traffic[c]) / len(base_traffic[c]))

matrix = []
for cat in categories:
    row = [max(40, v + np.random.randint(-5, 6)) for v in base_traffic[cat]]
    matrix.append(row)

all_vals = [v for row in matrix for v in row]
vmin, vmax = min(all_vals), max(all_vals)

# Imprint sequential colormap: #009E73 (brand green) → #4467A3 (blue)
# Precompute all cell colors to avoid duplicating interpolation logic
_R0, _G0, _B0 = 0x00, 0x9E, 0x73  # #009E73
_R1, _G1, _B1 = 0x44, 0x67, 0xA3  # #4467A3

cell_hex = {}
cell_rgb = {}
for row_i in range(len(categories)):
    for col_j in range(len(months)):
        t = max(0.0, min(1.0, (matrix[row_i][col_j] - vmin) / (vmax - vmin)))
        rc = int(_R0 + (_R1 - _R0) * t)
        gc = int(_G0 + (_G1 - _G0) * t)
        bc = int(_B0 + (_B1 - _B0) * t)
        cell_hex[(row_i, col_j)] = f"#{rc:02x}{gc:02x}{bc:02x}"
        cell_rgb[(row_i, col_j)] = (rc, gc, bc)

# Colorbar gradient (60 stops, top = vmax, bottom = vmin)
N_CB = 60
cb_hex = []
for s in range(N_CB):
    t = max(0.0, min(1.0, 1.0 - s / (N_CB - 1)))
    rc = int(_R0 + (_R1 - _R0) * t)
    gc = int(_G0 + (_G1 - _G0) * t)
    bc = int(_B0 + (_B1 - _B0) * t)
    cb_hex.append(f"#{rc:02x}{gc:02x}{bc:02x}")

# Plot
title = "heatmap-basic · python · pygal · anyplot.ai"
title_font_size = max(44, round(66 * 67 / len(title))) if len(title) > 67 else 66

IMPRINT_COLORS = (
    "#009E73",
    "#C475FD",
    "#4467A3",
    "#BD8233",
    "#AE3030",
    "#2ABCCD",
    "#954477",
    "#99B314",
    "#009E73",
    "#C475FD",
    "#4467A3",
    "#BD8233",
)

custom_style = _Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=IMPRINT_COLORS,
    title_font_size=title_font_size,
    label_font_size=56,
    major_label_font_size=44,
    legend_font_size=44,
    value_font_size=36,
    font_family="sans-serif",
)

chart = _pygal.HorizontalStackedBar(
    width=2400,
    height=2400,
    style=custom_style,
    title=title,
    show_legend=False,
    print_values=True,
    value_font_size=36,
    show_y_guides=False,
    show_x_guides=False,
    show_x_labels=False,
    margin_top=200,
    margin_bottom=100,
    margin_left=300,
    margin_right=450,
    range=(0, 12),
    spacing=0,
    rounded_bars=1,
)
chart.x_labels = categories

for col_j, month in enumerate(months):
    series_data = [
        {"value": 1, "color": cell_hex[(row_i, col_j)], "formatter": lambda x, v=matrix[row_i][col_j]: str(v)}
        for row_i in range(len(categories))
    ]
    chart.add(month, series_data)

# Render SVG via pygal
svg = chart.render(is_unicode=True)

# Post-process SVG: brightness-adaptive text colors for value annotations
overlay_match = re.search(
    r'(<g[^>]*class="plot text-overlay">)(.*?)(</g>\s*<g[^>]*class="plot tooltip-overlay")', svg, re.DOTALL
)
if overlay_match:
    overlay_content = overlay_match.group(2)
    for serie_match in re.finditer(r'(<g class="series serie-(\d+)[^"]*">)(.*?)(</g>)', overlay_content, re.DOTALL):
        col_j = int(serie_match.group(2))
        serie_content = serie_match.group(3)
        value_texts = sorted(
            [
                (float(m.group(2)), m)
                for m in re.finditer(
                    r'(<text\s+text-anchor="middle"\s+x="[^"]+"\s+y="([^"]+)"\s+class="value">)(\d+)(</text>)',
                    serie_content,
                )
            ],
            key=lambda x: x[0],
            reverse=True,
        )
        new_serie = serie_content
        for rank, (_, m) in enumerate(value_texts):
            rc, gc, bc = cell_rgb.get((rank, col_j), (200, 200, 200))
            brightness = (rc * 299 + gc * 587 + bc * 114) / 1000
            txt_color = "#ffffff" if brightness < 140 else "#1a1a1a"
            old_tag = m.group(0)
            new_tag = m.group(1).replace('class="value"', f'class="value" fill="{txt_color}"') + m.group(3) + m.group(4)
            new_serie = new_serie.replace(old_tag, new_tag, 1)
        overlay_content = overlay_content.replace(serie_match.group(3), new_serie, 1)
    svg = svg[: overlay_match.start(2)] + overlay_content + svg[overlay_match.end(2) :]

# Inject month headers, axis titles, colorbar, and peak annotations before </svg>
W, H = 2400, 2400

transform_match = re.search(r"translate\(([^,]+),\s*([^)]+)\)", svg)
plot_x = float(transform_match.group(1)) if transform_match else 400
plot_y = float(transform_match.group(2)) if transform_match else 200

bg_rects = re.findall(r'<rect[^>]*width="([^"]+)"[^>]*height="([^"]+)"[^>]*class="background"', svg)
if len(bg_rects) >= 2:
    plot_w, plot_h = float(bg_rects[1][0]), float(bg_rects[1][1])
else:
    plot_w, plot_h = 1720.0, 2100.0

grid_left = plot_x
grid_top = plot_y
grid_right = plot_x + plot_w
grid_bottom = plot_y + plot_h
cell_w = plot_w / len(months)
cell_h = plot_h / len(categories)

extra = []

# Subtitle
extra.append(
    f'<text x="{W / 2:.0f}" y="{grid_top - 55:.0f}" text-anchor="middle" fill="{INK_SOFT}" '
    f'style="font-size:30px;font-family:sans-serif">'
    f"Monthly website traffic (thousands of visits) by content category</text>"
)

# Month column headers
for col_j, mn in enumerate(months):
    mx = grid_left + col_j * cell_w + cell_w / 2
    extra.append(
        f'<text x="{mx:.0f}" y="{grid_top - 18:.0f}" text-anchor="middle" fill="{INK}" '
        f'style="font-size:32px;font-weight:600;font-family:sans-serif">{mn}</text>'
    )

# X-axis title
extra.append(
    f'<text x="{(grid_left + grid_right) / 2:.0f}" y="{grid_bottom + 52:.0f}" '
    f'text-anchor="middle" fill="{INK}" '
    f'style="font-size:40px;font-weight:bold;font-family:sans-serif">Month</text>'
)

# Y-axis title (rotated)
mid_y = (grid_top + grid_bottom) / 2
extra.append(
    f'<text x="{grid_left - 230:.0f}" y="{mid_y:.0f}" text-anchor="middle" fill="{INK}" '
    f'style="font-size:40px;font-weight:bold;font-family:sans-serif" '
    f'transform="rotate(-90,{grid_left - 230:.0f},{mid_y:.0f})">Content Category</text>'
)

# Colorbar
cb_x = grid_right + 50
cb_w = 36
cb_top = grid_top + 18
cb_bot = grid_bottom - 18
cb_h = cb_bot - cb_top
for s in range(N_CB):
    sy = cb_top + cb_h * s / N_CB
    extra.append(
        f'<rect x="{cb_x:.0f}" y="{sy:.1f}" width="{cb_w}" height="{cb_h / N_CB + 1:.1f}" fill="{cb_hex[s]}"/>'
    )
extra.append(
    f'<rect x="{cb_x:.0f}" y="{cb_top:.0f}" width="{cb_w}" height="{cb_h:.0f}" '
    f'fill="none" stroke="{INK_MUTED}" stroke-width="1.5"/>'
)
for frac, label_val in [(0.0, vmax), (0.5, (vmin + vmax) / 2), (1.0, vmin)]:
    ty = cb_top + cb_h * frac
    extra.append(
        f'<text x="{cb_x + cb_w + 10:.0f}" y="{ty + 10:.0f}" fill="{INK_SOFT}" '
        f'style="font-size:28px;font-family:sans-serif">{label_val:.0f}</text>'
    )
extra.append(
    f'<text x="{cb_x + cb_w / 2:.0f}" y="{cb_top - 16:.0f}" text-anchor="middle" '
    f'fill="{INK}" style="font-size:30px;font-weight:bold;font-family:sans-serif">Visits (k)</text>'
)

# Peak annotations — amber highlight for seasonal storytelling
for cat_name, col_j, label in [("Finance", 11, "Year-end peak"), ("Travel", 7, "Summer peak")]:
    row_i = categories.index(cat_name)
    svg_row = len(categories) - 1 - row_i
    ay = grid_top + svg_row * cell_h + cell_h * 0.82
    if col_j == len(months) - 1:
        ax = grid_right - 10
        anchor = "end"
    else:
        ax = grid_left + col_j * cell_w + cell_w / 2
        anchor = "middle"
    extra.append(
        f'<text x="{ax:.0f}" y="{ay:.0f}" text-anchor="{anchor}" fill="#DDCC77" '
        f'style="font-size:26px;font-weight:bold;font-family:sans-serif;font-style:italic">▼ {label}</text>'
    )

svg = svg.replace("</svg>", "\n".join(extra) + "\n</svg>")

# Save
_cairosvg.svg2png(bytestring=svg.encode("utf-8"), write_to=f"plot-{THEME}.png", output_width=2400, output_height=2400)

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

Part of Basic Heatmap on anyplot.ai.

Other implementations