Geographic Heatmap for Spatial Density — Pygal

A geographic heatmap visualizes spatial density or intensity values across a map using continuous color gradients. Unlike choropleth maps that color discrete regions, this plot shows smooth density variations computed from point data or gridded values. The color intensity at each location represents the concentration or magnitude of the underlying data, making it ideal for identifying hotspots, clusters, and spatial patterns in geographic data.

Geographic Heatmap for Spatial Density rendered with Pygal

Python source (Pygal)

""" anyplot.ai
heatmap-geographic: Geographic Heatmap for Spatial Density
Library: pygal 3.1.0 | Python 3.13.13
Quality: 73/100 | Updated: 2026-05-19
"""

import sys

import numpy as np


# Remove current directory from path to avoid shadowing the pygal package
_cwd = sys.path[0] if sys.path and sys.path[0] else "."
if _cwd in sys.path:
    sys.path.remove(_cwd)

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


sys.path.insert(0, _cwd)


class GeoHeatmap(Graph):
    """Custom Geographic Heatmap for pygal - displays spatial density as colored grid."""

    def __init__(self, *args, **kwargs):
        self.heatmap_data = kwargs.pop("heatmap_data", None)
        self.lat_range = kwargs.pop("lat_range", (-90, 90))
        self.lon_range = kwargs.pop("lon_range", (-180, 180))
        self.colormap = kwargs.pop("colormap", ["#ffffb2", "#fecc5c", "#fd8d3c", "#f03b20", "#bd0026"])
        self.coastlines = kwargs.pop("coastlines", [])
        self.point_data = kwargs.pop("point_data", None)
        super().__init__(*args, **kwargs)

    def _interpolate_color(self, value, min_val, max_val):
        """Interpolate color for smooth gradient."""
        if max_val == min_val:
            return self.colormap[-1]

        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 _plot(self):
        """Draw the geographic heatmap."""
        if self.heatmap_data is None:
            return

        heatmap = self.heatmap_data
        n_rows, n_cols = heatmap.shape

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

        # Layout margins
        label_margin_left = 180
        label_margin_right = 280
        label_margin_top = 60
        label_margin_bottom = 180

        available_width = plot_width - label_margin_left - label_margin_right
        available_height = plot_height - label_margin_top - label_margin_bottom

        # Calculate cell size
        cell_width = available_width / n_cols
        cell_height = available_height / n_rows

        x_offset = self.view.x(0) + label_margin_left
        y_offset = self.view.y(n_rows) + label_margin_top

        # Create group for the heatmap
        plot_node = self.nodes["plot"]
        heatmap_group = self.svg.node(plot_node, class_="geo-heatmap")

        # Draw background rectangle for plot area
        bg_rect = self.svg.node(
            heatmap_group, "rect", x=x_offset, y=y_offset, width=available_width, height=available_height
        )
        bg_rect.set("fill", "#e8f4f8")
        bg_rect.set("stroke", "#333333")
        bg_rect.set("stroke-width", "2")

        # Draw grid lines
        lat_min, lat_max = self.lat_range
        lon_min, lon_max = self.lon_range

        def lon_to_x(lon):
            return x_offset + (lon - lon_min) / (lon_max - lon_min) * available_width

        def lat_to_y(lat):
            return y_offset + (1 - (lat - lat_min) / (lat_max - lat_min)) * available_height

        # Vertical grid lines (longitude)
        n_lon_lines = 7
        for i in range(n_lon_lines):
            lon = lon_min + (lon_max - lon_min) * i / (n_lon_lines - 1)
            x = lon_to_x(lon)
            line = self.svg.node(heatmap_group, "line", x1=x, y1=y_offset, x2=x, y2=y_offset + available_height)
            line.set("stroke", "#cccccc")
            line.set("stroke-width", "1")
            line.set("stroke-opacity", "0.5")

        # Horizontal grid lines (latitude)
        n_lat_lines = 7
        for i in range(n_lat_lines):
            lat = lat_min + (lat_max - lat_min) * i / (n_lat_lines - 1)
            y = lat_to_y(lat)
            line = self.svg.node(heatmap_group, "line", x1=x_offset, y1=y, x2=x_offset + available_width, y2=y)
            line.set("stroke", "#cccccc")
            line.set("stroke-width", "1")
            line.set("stroke-opacity", "0.5")

        # Draw heatmap cells
        all_values = heatmap.flatten()
        positive_values = all_values[all_values > 0]
        if len(positive_values) > 0:
            min_val = positive_values.min()
            max_val = positive_values.max()
        else:
            min_val, max_val = 0, 1

        for i in range(n_rows):
            for j in range(n_cols):
                value = heatmap[i, j]
                if value <= 0:
                    continue

                color = self._interpolate_color(value, min_val, max_val)
                # Variable opacity based on value intensity for basemap visibility
                opacity = 0.4 + 0.45 * (value - min_val) / (max_val - min_val) if max_val > min_val else 0.65

                x = x_offset + j * cell_width
                y = y_offset + (n_rows - 1 - i) * cell_height

                rect = self.svg.node(heatmap_group, "rect", x=x, y=y, width=cell_width + 0.5, height=cell_height + 0.5)
                rect.set("fill", color)
                rect.set("fill-opacity", str(opacity))
                rect.set("stroke", "none")

        # Draw coastlines
        for coastline in self.coastlines:
            if len(coastline) < 2:
                continue
            points = " ".join([f"{lon_to_x(lon)},{lat_to_y(lat)}" for lon, lat in coastline])
            polyline = self.svg.node(heatmap_group, "polyline", points=points)
            polyline.set("fill", "none")
            polyline.set("stroke", "#333333")
            polyline.set("stroke-width", "3")
            polyline.set("stroke-opacity", "0.7")

        # Draw scatter points with improved visibility (larger radius for better visibility)
        if self.point_data is not None:
            for lon, lat in self.point_data:
                cx = lon_to_x(lon)
                cy = lat_to_y(lat)
                circle = self.svg.node(heatmap_group, "circle", cx=cx, cy=cy, r=12)
                circle.set("fill", "#306998")
                circle.set("fill-opacity", "0.7")
                circle.set("stroke", "#1a3a5c")
                circle.set("stroke-width", "1.5")
                circle.set("stroke-opacity", "0.9")

        # Draw axis labels
        axis_font_size = 48
        tick_font_size = 36

        # X-axis label
        text_node = self.svg.node(
            heatmap_group, "text", x=x_offset + available_width / 2, y=y_offset + available_height + 130
        )
        text_node.set("text-anchor", "middle")
        text_node.set("fill", "#333333")
        text_node.set("style", f"font-size:{axis_font_size}px;font-weight:bold;font-family:sans-serif")
        text_node.text = "Longitude (°)"

        # Y-axis label
        text_node = self.svg.node(heatmap_group, "text", x=x_offset - 100, y=y_offset + available_height / 2)
        text_node.set("text-anchor", "middle")
        text_node.set("fill", "#333333")
        text_node.set("style", f"font-size:{axis_font_size}px;font-weight:bold;font-family:sans-serif")
        text_node.set("transform", f"rotate(-90, {x_offset - 100}, {y_offset + available_height / 2})")
        text_node.text = "Latitude (°)"

        # X-axis ticks
        n_x_ticks = 6
        for i in range(n_x_ticks):
            lon = lon_min + (lon_max - lon_min) * i / (n_x_ticks - 1)
            x = lon_to_x(lon)
            text_node = self.svg.node(heatmap_group, "text", x=x, y=y_offset + available_height + 50)
            text_node.set("text-anchor", "middle")
            text_node.set("fill", "#333333")
            text_node.set("style", f"font-size:{tick_font_size}px;font-family:sans-serif")
            text_node.text = f"{lon:.0f}"

        # Y-axis ticks
        n_y_ticks = 6
        for i in range(n_y_ticks):
            lat = lat_min + (lat_max - lat_min) * i / (n_y_ticks - 1)
            y = lat_to_y(lat)
            text_node = self.svg.node(heatmap_group, "text", x=x_offset - 20, y=y + tick_font_size * 0.35)
            text_node.set("text-anchor", "end")
            text_node.set("fill", "#333333")
            text_node.set("style", f"font-size:{tick_font_size}px;font-family:sans-serif")
            text_node.text = f"{lat:.0f}"

        # Draw colorbar on the right
        colorbar_width = 50
        colorbar_height = available_height * 0.7
        colorbar_x = x_offset + available_width + 60
        colorbar_y = y_offset + (available_height - colorbar_height) / 2

        # Draw gradient colorbar
        n_segments = 50
        segment_height = colorbar_height / n_segments
        for i in range(n_segments):
            seg_value = min_val + (max_val - min_val) * (n_segments - 1 - i) / (n_segments - 1)
            seg_color = self._interpolate_color(seg_value, min_val, max_val)
            seg_y = colorbar_y + i * segment_height

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

        # Colorbar border
        self.svg.node(
            heatmap_group,
            "rect",
            x=colorbar_x,
            y=colorbar_y,
            width=colorbar_width,
            height=colorbar_height,
            fill="none",
            stroke="#333333",
        )

        # Colorbar labels with 5 tick values
        cb_label_size = 36
        n_cb_ticks = 5
        for i in range(n_cb_ticks):
            tick_value = max_val - (max_val - min_val) * i / (n_cb_ticks - 1)
            tick_y = colorbar_y + colorbar_height * i / (n_cb_ticks - 1)
            text_node = self.svg.node(
                heatmap_group, "text", x=colorbar_x + colorbar_width + 15, y=tick_y + cb_label_size * 0.35
            )
            text_node.set("fill", "#333333")
            text_node.set("style", f"font-size:{cb_label_size}px;font-family:sans-serif")
            text_node.text = f"{tick_value:.1f}"

        # Colorbar title
        cb_title_size = 38
        cb_title_x = colorbar_x + colorbar_width / 2
        cb_title_y = colorbar_y - 30
        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", "#333333")
        text_node.set("style", f"font-size:{cb_title_size}px;font-weight:bold;font-family:sans-serif")
        text_node.text = "Density"

        # Legend for scatter points (below colorbar)
        legend_y = colorbar_y + colorbar_height + 60
        legend_x = colorbar_x

        # Legend marker (circle matching scatter points)
        legend_circle = self.svg.node(heatmap_group, "circle", cx=legend_x + 12, cy=legend_y, r=12)
        legend_circle.set("fill", "#306998")
        legend_circle.set("fill-opacity", "0.7")
        legend_circle.set("stroke", "#1a3a5c")
        legend_circle.set("stroke-width", "1.5")

        # Legend text
        legend_text = self.svg.node(heatmap_group, "text", x=legend_x + 35, y=legend_y + 10)
        legend_text.set("fill", "#333333")
        legend_text.set("style", f"font-size:{cb_label_size}px;font-family:sans-serif")
        legend_text.text = "Station"

    def _compute(self):
        """Compute the box for rendering."""
        n_rows = self.heatmap_data.shape[0] if self.heatmap_data is not None else 1
        n_cols = self.heatmap_data.shape[1] if self.heatmap_data is not None else 1
        self._box.xmin = 0
        self._box.xmax = n_cols
        self._box.ymin = 0
        self._box.ymax = n_rows


# Data: Simulated environmental monitoring stations across California
np.random.seed(42)

n_points = 500

# Create clusters representing different monitoring regions
# Central California coast cluster
coast_lat = np.random.normal(36.5, 0.8, n_points // 3)
coast_lon = np.random.normal(-121.5, 0.5, n_points // 3)

# Southern California cluster
socal_lat = np.random.normal(34.0, 0.6, n_points // 3)
socal_lon = np.random.normal(-118.0, 0.7, n_points // 3)

# Northern California cluster
norcal_lat = np.random.normal(38.5, 0.5, n_points // 3 + n_points % 3)
norcal_lon = np.random.normal(-122.5, 0.4, n_points // 3 + n_points % 3)

# Combine all clusters
latitudes = np.concatenate([coast_lat, socal_lat, norcal_lat])
longitudes = np.concatenate([coast_lon, socal_lon, norcal_lon])

# Measurement values (air quality index readings)
values = np.random.exponential(scale=50, size=len(latitudes)) + 20

# Map boundaries for California
lat_min, lat_max = 32.5, 42.0
lon_min, lon_max = -125.0, -114.0

# Create 2D histogram for density estimation
grid_resolution = 80
lat_bins = np.linspace(lat_min, lat_max, grid_resolution)
lon_bins = np.linspace(lon_min, lon_max, grid_resolution)

heatmap, lat_edges, lon_edges = np.histogram2d(
    latitudes, longitudes, bins=[lat_bins, lon_bins], weights=values, density=False
)

# Apply Gaussian smoothing for continuous appearance
sigma = 2
kernel_size = int(6 * sigma + 1)
if kernel_size % 2 == 0:
    kernel_size += 1
kernel_x = np.arange(kernel_size) - kernel_size // 2
kernel_1d = np.exp(-(kernel_x**2) / (2 * sigma**2))
kernel_1d = kernel_1d / kernel_1d.sum()

heatmap_smooth = np.apply_along_axis(lambda row: np.convolve(row, kernel_1d, mode="same"), axis=0, arr=heatmap)
heatmap_smooth = np.apply_along_axis(lambda col: np.convolve(col, kernel_1d, mode="same"), axis=1, arr=heatmap_smooth)

# California coastline approximation
coast_lons = [
    -124.4,
    -124.2,
    -123.8,
    -122.4,
    -122.0,
    -121.5,
    -121.0,
    -120.5,
    -120.0,
    -119.5,
    -119.0,
    -118.5,
    -118.0,
    -117.5,
    -117.2,
    -117.0,
    -117.1,
    -117.3,
]
coast_lats = [
    42.0,
    40.5,
    39.0,
    37.8,
    37.5,
    36.8,
    36.5,
    35.5,
    35.0,
    34.5,
    34.2,
    34.0,
    33.8,
    33.2,
    33.0,
    32.7,
    32.5,
    32.5,
]
coastline_west = list(zip(coast_lons, coast_lats, strict=True))

east_lons = [-117.3, -117.0, -116.5, -115.5, -114.6, -114.6, -120.0, -120.0, -121.0, -122.0, -123.0, -124.2, -124.4]
east_lats = [32.5, 33.0, 33.5, 34.0, 34.8, 36.0, 39.0, 40.0, 41.0, 41.5, 42.0, 42.0, 42.0]
coastline_east = list(zip(east_lons, east_lats, strict=True))

coastlines = [coastline_west, coastline_east]

# Point data for scatter overlay
point_data = list(zip(longitudes, latitudes, strict=True))

# YlOrRd colormap
colormap = ["#ffffb2", "#fed976", "#feb24c", "#fd8d3c", "#fc4e2a", "#e31a1c", "#b10026"]

# Custom style
custom_style = Style(
    background="white",
    plot_background="#e8f4f8",
    foreground="#333333",
    foreground_strong="#333333",
    foreground_subtle="#666666",
    colors=("#306998",),
    title_font_size=64,
    legend_font_size=40,
    label_font_size=42,
    value_font_size=36,
    font_family="sans-serif",
)

# Create heatmap chart
chart = GeoHeatmap(
    width=4800,
    height=2700,
    style=custom_style,
    title="heatmap-geographic · pygal · pyplots.ai",
    heatmap_data=heatmap_smooth,
    lat_range=(lat_min, lat_max),
    lon_range=(lon_min, lon_max),
    colormap=colormap,
    coastlines=coastlines,
    point_data=point_data,
    show_legend=False,
    margin=100,
    margin_top=160,
    margin_bottom=80,
    show_x_labels=False,
    show_y_labels=False,
)

# Add a dummy series to trigger _plot
chart.add("", [0])

# Save PNG output only
chart.render_to_png("plot.png")

Part of Geographic Heatmap for Spatial Density on anyplot.ai.

Other implementations