Periodic Table Property Heatmap — Pygal

A periodic table of the elements rendered in its canonical layout — 18 groups across by 7 periods down, with the lanthanide and actinide series detached as two separate rows below — where each element tile is colored by a continuous property such as electronegativity, atomic radius, melting point, or first ionization energy. A colorbar maps tile color to property value, and every tile carries the element symbol and atomic number, optionally with the numeric property value. This visualization fuses an immediately recognizable fixed grid with heatmap encoding, turning a familiar reference chart into a tool for spotting periodic trends at a glance.

Periodic Table Property Heatmap rendered with Pygal

Python source (Pygal)

""" anyplot.ai
heatmap-periodic-table: Periodic Table Property Heatmap
Library: pygal 3.1.0 | Python 3.13.13
Quality: 87/100 | Created: 2026-06-15
"""

import os
import sys


sys.path.pop(0)  # prevent this file shadowing the installed pygal package

import pygal
from pygal.style import Style


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"
GREY_TILE = "#CCCBC3" if THEME == "light" else "#3A3A36"

FONT = "DejaVu Sans, Helvetica, Arial, sans-serif"

# Imprint sequential colormap: brand green → blue (single-polarity continuous data)
SEQ_START = "#009E73"
SEQ_END = "#4467A3"

# Element data: (symbol, atomic_number, grid_col[1-18], grid_row[1-9], en_or_None)
# grid_row 1-7 = periods 1-7 in the main 18-column table
# grid_row 8   = lanthanide f-block row (Ce-Lu at cols 4-17)
# grid_row 9   = actinide  f-block row (Th-Lr at cols 4-17)
# La sits at (col=3, row=6) and Ac at (col=3, row=7) in the main table;
# Ce-Lu and Th-Lr are detached below following the standard IUPAC layout.
ELEMENTS = [
    # Period 1
    ("H", 1, 1, 1, 2.20),
    ("He", 2, 18, 1, None),
    # Period 2
    ("Li", 3, 1, 2, 0.98),
    ("Be", 4, 2, 2, 1.57),
    ("B", 5, 13, 2, 2.04),
    ("C", 6, 14, 2, 2.55),
    ("N", 7, 15, 2, 3.04),
    ("O", 8, 16, 2, 3.44),
    ("F", 9, 17, 2, 3.98),
    ("Ne", 10, 18, 2, None),
    # Period 3
    ("Na", 11, 1, 3, 0.93),
    ("Mg", 12, 2, 3, 1.31),
    ("Al", 13, 13, 3, 1.61),
    ("Si", 14, 14, 3, 1.90),
    ("P", 15, 15, 3, 2.19),
    ("S", 16, 16, 3, 2.58),
    ("Cl", 17, 17, 3, 3.16),
    ("Ar", 18, 18, 3, None),
    # Period 4
    ("K", 19, 1, 4, 0.82),
    ("Ca", 20, 2, 4, 1.00),
    ("Sc", 21, 3, 4, 1.36),
    ("Ti", 22, 4, 4, 1.54),
    ("V", 23, 5, 4, 1.63),
    ("Cr", 24, 6, 4, 1.66),
    ("Mn", 25, 7, 4, 1.55),
    ("Fe", 26, 8, 4, 1.83),
    ("Co", 27, 9, 4, 1.88),
    ("Ni", 28, 10, 4, 1.91),
    ("Cu", 29, 11, 4, 1.90),
    ("Zn", 30, 12, 4, 1.65),
    ("Ga", 31, 13, 4, 1.81),
    ("Ge", 32, 14, 4, 2.01),
    ("As", 33, 15, 4, 2.18),
    ("Se", 34, 16, 4, 2.55),
    ("Br", 35, 17, 4, 2.96),
    ("Kr", 36, 18, 4, 3.00),
    # Period 5
    ("Rb", 37, 1, 5, 0.82),
    ("Sr", 38, 2, 5, 0.95),
    ("Y", 39, 3, 5, 1.22),
    ("Zr", 40, 4, 5, 1.33),
    ("Nb", 41, 5, 5, 1.60),
    ("Mo", 42, 6, 5, 2.16),
    ("Tc", 43, 7, 5, 1.90),
    ("Ru", 44, 8, 5, 2.20),
    ("Rh", 45, 9, 5, 2.28),
    ("Pd", 46, 10, 5, 2.20),
    ("Ag", 47, 11, 5, 1.93),
    ("Cd", 48, 12, 5, 1.69),
    ("In", 49, 13, 5, 1.78),
    ("Sn", 50, 14, 5, 1.96),
    ("Sb", 51, 15, 5, 2.05),
    ("Te", 52, 16, 5, 2.10),
    ("I", 53, 17, 5, 2.66),
    ("Xe", 54, 18, 5, 2.60),
    # Period 6 — La holds col 3; Hf-Rn are d/p block
    ("Cs", 55, 1, 6, 0.79),
    ("Ba", 56, 2, 6, 0.89),
    ("La", 57, 3, 6, 1.10),
    ("Hf", 72, 4, 6, 1.30),
    ("Ta", 73, 5, 6, 1.50),
    ("W", 74, 6, 6, 2.36),
    ("Re", 75, 7, 6, 1.90),
    ("Os", 76, 8, 6, 2.20),
    ("Ir", 77, 9, 6, 2.20),
    ("Pt", 78, 10, 6, 2.28),
    ("Au", 79, 11, 6, 2.54),
    ("Hg", 80, 12, 6, 2.00),
    ("Tl", 81, 13, 6, 1.62),
    ("Pb", 82, 14, 6, 2.33),
    ("Bi", 83, 15, 6, 2.02),
    ("Po", 84, 16, 6, 2.00),
    ("At", 85, 17, 6, 2.20),
    ("Rn", 86, 18, 6, 2.20),
    # Period 7 — Ac holds col 3; Rf-Og are transactinides (no EN)
    ("Fr", 87, 1, 7, 0.70),
    ("Ra", 88, 2, 7, 0.90),
    ("Ac", 89, 3, 7, 1.10),
    ("Rf", 104, 4, 7, None),
    ("Db", 105, 5, 7, None),
    ("Sg", 106, 6, 7, None),
    ("Bh", 107, 7, 7, None),
    ("Hs", 108, 8, 7, None),
    ("Mt", 109, 9, 7, None),
    ("Ds", 110, 10, 7, None),
    ("Rg", 111, 11, 7, None),
    ("Cn", 112, 12, 7, None),
    ("Nh", 113, 13, 7, None),
    ("Fl", 114, 14, 7, None),
    ("Mc", 115, 15, 7, None),
    ("Lv", 116, 16, 7, None),
    ("Ts", 117, 17, 7, None),
    ("Og", 118, 18, 7, None),
    # Lanthanide f-block row (row 8, cols 4-17 = Ce through Lu)
    ("Ce", 58, 4, 8, 1.12),
    ("Pr", 59, 5, 8, 1.13),
    ("Nd", 60, 6, 8, 1.14),
    ("Pm", 61, 7, 8, 1.13),
    ("Sm", 62, 8, 8, 1.17),
    ("Eu", 63, 9, 8, 1.20),
    ("Gd", 64, 10, 8, 1.20),
    ("Tb", 65, 11, 8, 1.10),
    ("Dy", 66, 12, 8, 1.22),
    ("Ho", 67, 13, 8, 1.23),
    ("Er", 68, 14, 8, 1.24),
    ("Tm", 69, 15, 8, 1.25),
    ("Yb", 70, 16, 8, 1.10),
    ("Lu", 71, 17, 8, 1.27),
    # Actinide f-block row (row 9, cols 4-17 = Th through Lr)
    ("Th", 90, 4, 9, 1.30),
    ("Pa", 91, 5, 9, 1.50),
    ("U", 92, 6, 9, 1.38),
    ("Np", 93, 7, 9, 1.36),
    ("Pu", 94, 8, 9, 1.28),
    ("Am", 95, 9, 9, 1.30),
    ("Cm", 96, 10, 9, 1.30),
    ("Bk", 97, 11, 9, 1.30),
    ("Cf", 98, 12, 9, 1.30),
    ("Es", 99, 13, 9, 1.30),
    ("Fm", 100, 14, 9, 1.30),
    ("Md", 101, 15, 9, 1.30),
    ("No", 102, 16, 9, 1.30),
    ("Lr", 103, 17, 9, 1.30),
]

en_values = [e[4] for e in ELEMENTS if e[4] is not None]
EN_MIN = min(en_values)  # 0.70  (Fr)
EN_MAX = max(en_values)  # 3.98  (F)


def lerp_hex(c0, c1, t):
    r0, g0, b0 = int(c0[1:3], 16), int(c0[3:5], 16), int(c0[5:7], 16)
    r1, g1, b1 = int(c1[1:3], 16), int(c1[3:5], 16), int(c1[5:7], 16)
    r = int(round(r0 + (r1 - r0) * t))
    g = int(round(g0 + (g1 - g0) * t))
    b = int(round(b0 + (b1 - b0) * t))
    return f"#{r:02X}{g:02X}{b:02X}"


def tile_color(en):
    if en is None:
        return GREY_TILE
    t = (en - EN_MIN) / (EN_MAX - EN_MIN)
    return lerp_hex(SEQ_START, SEQ_END, t)


def relative_luminance(hex_c):
    vals = [int(hex_c[i : i + 2], 16) / 255 for i in (1, 3, 5)]
    lin = [v / 12.92 if v <= 0.04045 else ((v + 0.055) / 1.055) ** 2.4 for v in vals]
    return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2]


def ink_for_tile(hex_c):
    return "#F0EFE8" if relative_luminance(hex_c) < 0.35 else "#1A1A17"


# Canvas and layout — landscape 3200×1800 (periodic table is inherently wider than tall)
CANVAS_W = 3200
CANVAS_H = 1800
TILE = 155  # tile side length (px)
GAP = 8  # gap between tiles (px)
STRIDE = TILE + GAP  # 163
GRID_W = 17 * STRIDE + TILE  # 2926 px
LEFT = (CANVAS_W - GRID_W) // 2  # 137 px left edge of column 1
TOP = 118  # y of the top edge of period-1 tiles
FBLOCK_EXTRA = 40  # extra gap between period-7 row and the f-block rows

TITLE_TEXT = "heatmap-periodic-table · python · pygal · anyplot.ai"

# Pygal Style — carries all theme tokens into the rendering engine
custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=("#009E73", "#C475FD", "#4467A3", "#BD8233", "#AE3030", "#2ABCCD", "#954477", "#99B314"),
    title_font_size=66,
    label_font_size=44,
    major_label_font_size=44,
    legend_font_size=44,
    value_font_size=36,
    stroke_width=2.5,
)


def tile_xy(col, row):
    """Return (x, y) top-left corner for a tile at (col, row), 1-indexed."""
    x = LEFT + (col - 1) * STRIDE
    if row <= 7:
        y = TOP + (row - 1) * STRIDE
    else:
        row7_bottom = TOP + 6 * STRIDE + TILE
        y = row7_bottom + FBLOCK_EXTRA + (row - 8) * STRIDE
    return x, y


class PeriodicHeatmap(pygal.Bar):
    """Custom pygal chart type for periodic table heatmaps.

    Subclasses pygal.Bar to leverage its SVG rendering engine, Style
    theming, and render_to_png() infrastructure. The _plot() override
    draws the canonical IUPAC 18×7 grid with f-block rows using
    pygal's svg.node() API instead of the default bar chart.
    """

    def _plot(self):
        """Draw the periodic table using pygal's SVG node API."""
        root = self.svg.root

        # Full-canvas background — covers any pygal chrome drawn by _decorate()
        self.svg.node(root, "rect", x=0, y=0, width=CANVAS_W, height=CANVAS_H, fill=PAGE_BG)

        # Title
        n = self.svg.node(
            root,
            "text",
            attrib={
                "x": str(CANVAS_W // 2),
                "y": "72",
                "text-anchor": "middle",
                "fill": INK,
                "style": f"font-size:66px;font-weight:500;font-family:{FONT}",
            },
        )
        n.text = TITLE_TEXT

        # Group numbers 1–18 above the tiles
        for g in range(1, 19):
            gx = LEFT + (g - 1) * STRIDE + TILE / 2
            n = self.svg.node(
                root,
                "text",
                attrib={
                    "x": f"{gx:.1f}",
                    "y": f"{TOP - 20:.1f}",
                    "text-anchor": "middle",
                    "fill": INK_MUTED,
                    "style": f"font-size:28px;font-family:{FONT}",
                },
            )
            n.text = str(g)

        # Period numbers 1–7 to the left of the main grid
        for p in range(1, 8):
            px = LEFT - 30
            py = TOP + (p - 1) * STRIDE + TILE / 2 + 11
            n = self.svg.node(
                root,
                "text",
                attrib={
                    "x": f"{px:.1f}",
                    "y": f"{py:.1f}",
                    "text-anchor": "end",
                    "fill": INK_MUTED,
                    "style": f"font-size:28px;font-family:{FONT}",
                },
            )
            n.text = str(p)

        # f-block row labels ("Ln" and "An") to the left of the f-block rows
        for fb_row, label in [(8, "Ln"), (9, "An")]:
            _, fy = tile_xy(4, fb_row)
            n = self.svg.node(
                root,
                "text",
                attrib={
                    "x": f"{LEFT - 30:.1f}",
                    "y": f"{fy + TILE / 2 + 11:.1f}",
                    "text-anchor": "end",
                    "fill": INK_MUTED,
                    "style": f"font-size:28px;font-style:italic;font-family:{FONT}",
                },
            )
            n.text = label

        # Thin dashed connector lines from period 6/7 group-3 tile to f-block rows
        for main_row, fb_row in [(6, 8), (7, 9)]:
            mx, my = tile_xy(3, main_row)
            _, fy = tile_xy(4, fb_row)
            self.svg.node(
                root,
                "line",
                attrib={
                    "x1": f"{mx + TILE / 2:.1f}",
                    "y1": f"{my + TILE:.1f}",
                    "x2": f"{LEFT + 3 * STRIDE + TILE / 2:.1f}",
                    "y2": f"{fy:.1f}",
                    "stroke": INK_MUTED,
                    "stroke-width": "1.5",
                    "stroke-dasharray": "6,5",
                    "stroke-opacity": "0.50",
                },
            )

        # Element tiles
        for sym, an, col, row, en in ELEMENTS:
            x, y = tile_xy(col, row)
            fill = tile_color(en)
            ink = ink_for_tile(fill)

            self.svg.node(root, "rect", x=x, y=y, width=TILE, height=TILE, fill=fill, rx=3, ry=3)

            # Atomic number — top-left corner, small
            n = self.svg.node(
                root, "text", x=x + 7, y=y + 22, fill=ink, style=f"font-size:20px;opacity:0.80;font-family:{FONT}"
            )
            n.text = str(an)

            # Element symbol — centred, prominent
            n = self.svg.node(
                root,
                "text",
                attrib={
                    "x": f"{x + TILE / 2:.1f}",
                    "y": f"{y + TILE / 2 + 22:.1f}",
                    "text-anchor": "middle",
                    "fill": ink,
                    "style": f"font-size:50px;font-weight:700;font-family:{FONT}",
                },
            )
            n.text = sym

            # EN value — bottom-centre, small (only when known)
            if en is not None:
                n = self.svg.node(
                    root,
                    "text",
                    attrib={
                        "x": f"{x + TILE / 2:.1f}",
                        "y": f"{y + TILE - 11:.1f}",
                        "text-anchor": "middle",
                        "fill": ink,
                        "style": f"font-size:20px;opacity:0.80;font-family:{FONT}",
                    },
                )
                n.text = f"{en:.2f}"

        # Colorbar — placed below the f-block rows
        _, row9_y = tile_xy(4, 9)
        cb_label_y = row9_y + TILE + 42
        cb_x = LEFT
        cb_w = GRID_W
        cb_h = 42
        cb_y = cb_label_y + 32

        # Gradient definition
        defs = self.svg.node(root, "defs")
        grad = self.svg.node(
            defs, "linearGradient", attrib={"id": "cbGrad", "x1": "0", "y1": "0", "x2": "1", "y2": "0"}
        )
        self.svg.node(grad, "stop", attrib={"offset": "0%", "stop-color": SEQ_START})
        self.svg.node(grad, "stop", attrib={"offset": "100%", "stop-color": SEQ_END})

        # Colorbar label
        n = self.svg.node(
            root,
            "text",
            attrib={
                "x": f"{cb_x + cb_w / 2:.1f}",
                "y": f"{cb_label_y:.1f}",
                "text-anchor": "middle",
                "fill": INK,
                "style": f"font-size:32px;font-family:{FONT}",
            },
        )
        n.text = "Pauling Electronegativity"

        # Colorbar gradient rectangle
        self.svg.node(
            root,
            "rect",
            attrib={
                "x": str(cb_x),
                "y": str(cb_y),
                "width": str(cb_w),
                "height": str(cb_h),
                "fill": "url(#cbGrad)",
                "rx": "4",
            },
        )

        # Colorbar tick marks and labels
        for val in [0.5, 1.0, 1.5, 2.0, 2.5, 3.0, 3.5]:
            if EN_MIN <= val <= EN_MAX:
                t = (val - EN_MIN) / (EN_MAX - EN_MIN)
                tx = cb_x + t * cb_w
                self.svg.node(
                    root,
                    "line",
                    attrib={
                        "x1": f"{tx:.1f}",
                        "y1": f"{cb_y + cb_h:.1f}",
                        "x2": f"{tx:.1f}",
                        "y2": f"{cb_y + cb_h + 10:.1f}",
                        "stroke": INK_SOFT,
                        "stroke-width": "1.5",
                    },
                )
                n = self.svg.node(
                    root,
                    "text",
                    attrib={
                        "x": f"{tx:.1f}",
                        "y": f"{cb_y + cb_h + 36:.1f}",
                        "text-anchor": "middle",
                        "fill": INK_SOFT,
                        "style": f"font-size:26px;font-family:{FONT}",
                    },
                )
                n.text = f"{val:.1f}"

        # Colorbar end labels (min / max values)
        for x_pos, val in [(cb_x, EN_MIN), (cb_x + cb_w, EN_MAX)]:
            n = self.svg.node(
                root,
                "text",
                attrib={
                    "x": f"{x_pos:.1f}",
                    "y": f"{cb_y + cb_h + 36:.1f}",
                    "text-anchor": "middle",
                    "fill": INK_SOFT,
                    "style": f"font-size:26px;font-family:{FONT}",
                },
            )
            n.text = f"{val:.2f}"


# Instantiate using pygal's Bar as base — provides SVG engine, Style theming,
# render_to_png(), and render() without needing manual cairosvg calls
chart = PeriodicHeatmap(
    width=CANVAS_W,
    height=CANVAS_H,
    title=None,
    show_legend=False,
    show_x_labels=False,
    show_y_labels=False,
    show_x_guides=False,
    show_y_guides=False,
    style=custom_style,
    margin=0,
)

# Add a minimal data point so pygal's _draw() calls _plot() (requires non-empty series)
chart.add("EN", [1.0])

# Render PNG using pygal's built-in render_to_png() (uses cairosvg internally)
chart.render_to_png(f"plot-{THEME}.png")

# Render interactive HTML — pygal's render() returns SVG with embedded JS tooltips
with open(f"plot-{THEME}.html", "wb") as fh:
    fh.write(chart.render())

Part of Periodic Table Property Heatmap on anyplot.ai.

Other implementations