Plotting Correctly a Heatmap on a Web Mercator Map

When overlaying a gridded heatmap (like global temperature) onto a map, simply plotting the values at their (lat, lon) centers can lead to misalignment—especially with Web Mercator maps, which are the standard for most interactive web mapping.

To ensure each heatmap cell is accurately projected and aligns with real-world locations, I first convert the latitude and longitude centers of my data to Web Mercator coordinates. Then, I use scipy.interpolate.RegularGridInterpolator to resample the original grid onto a regular Mercator grid, so that each pixel matches the map’s projection.

This step is essential for climate, weather, or any geospatial raster data: without it, your heatmap will be visibly shifted, stretched, or skewed—especially at high latitudes.

import numpy as np
import pandas as pd
from bokeh.plotting import figure, show
from bokeh.models import LinearColorMapper, ColorBar, Range1d
from bokeh.palettes import Viridis256
from bokeh.io import curdoc
import xyzservices.providers as xyz
from scipy.interpolate import RegularGridInterpolator

# Example: Create sample global gridded data with centered coordinates
# Global grid with 0.5° latitude and 0.625° longitude resolution
lat_centers = np.arange(-89.75, 90, 0.5)      # -89.75 to 89.75 with 0.5° spacing (360 points)
lon_centers = np.arange(-179.6875, 180, 0.625) # -179.6875 to 179.6875 with 0.625° spacing (576 points)
lat_pixel_size = lat_centers[1]-lat_centers[0]
lon_pixel_size = lon_centers[1]-lon_centers[0]
# Create a sample global 2D data array (replace with your actual data)
# Example: create some interesting global pattern (temperature-like)
LON_2D, LAT_2D = np.meshgrid(lon_centers, lat_centers)
data_2d = (
    20 * np.cos(np.radians(LAT_2D * 2)) +  # Temperature gradient by latitude
    10 * np.sin(np.radians(LON_2D / 2)) +  # Longitudinal variation
    5 * np.random.randn(*LAT_2D.shape) +   # Some noise
    15  # Base temperature
)

# Web Mercator projection transformer
def lat_lon_to_web_mercator(lon, lat):
    """
    Convert latitude/longitude to Web Mercator coordinates
    """
    # Earth's radius in meters
    R = 6378137.0
    
    # Convert to radians
    lat_rad = np.radians(lat)
    lon_rad = np.radians(lon)
    
    # Web Mercator formulas
    x = R * lon_rad
    y = R * np.log(np.tan(np.pi/4 + lat_rad/2))
    
    return x, y

def web_mercator_to_lat_lon(x, y):
    """
    Convert Web Mercator coordinates back to latitude/longitude
    """
    # Earth's radius in meters
    R = 6378137.0
    
    # Web Mercator inverse formulas
    lon = np.degrees(x / R)
    lat = np.degrees(2 * np.arctan(np.exp(y / R)) - np.pi/2)
    
    return lon, lat

def create_heatmap_from_centers(lat_centers, lon_centers, data_2d, 
                               lat_pixel_size, lon_pixel_size):
    """
    Convert centered grid coordinates to corner coordinates for Bokeh heatmap
    """
    # Calculate corner coordinates from center coordinates
    lat_edges = lat_centers - lat_pixel_size / 2
    lon_edges = lon_centers - lon_pixel_size / 2
    
    # Create coordinate arrays for each pixel
    lats = []
    lons = []
    values = []
    
    for i, lat_center in enumerate(lat_centers):
        for j, lon_center in enumerate(lon_centers):
            # Calculate the four corners of each pixel
            lat_bottom = lat_center - lat_pixel_size / 2
            lat_top = lat_center + lat_pixel_size / 2
            lon_left = lon_center - lon_pixel_size / 2
            lon_right = lon_center + lon_pixel_size / 2
            
            # For rect glyph, we need center coordinates and dimensions
            lats.append(lat_center)
            lons.append(lon_center)
            values.append(data_2d[i, j])
    
    return lats, lons, values, lat_pixel_size, lon_pixel_size

# Method 1: Using rect glyph (recommended for regular grids)
def plot_heatmap_with_rect(lat_centers, lon_centers, data_2d, 
                          lat_pixel_size, lon_pixel_size):
    """
    Plot heatmap using rect glyph - keeps original center coordinates
    """
    lats, lons, values, lat_size, lon_size = create_heatmap_from_centers(
        lat_centers, lon_centers, data_2d, lat_pixel_size, lon_pixel_size
    )
    
    # Create DataFrame
    df = pd.DataFrame({
        'lat': lats,
        'lon': lons,
        'value': values
    })
    
    # Create color mapper
    color_mapper = LinearColorMapper(palette=Viridis256, 
                                   low=min(values), 
                                   high=max(values))
    
    # Create figure
    p = figure(title="Heatmap from Centered Grid Points (Rect Method)",
               x_axis_label="Longitude", 
               y_axis_label="Latitude",
               width=600, height=500,
               tools="pan,wheel_zoom,box_zoom,reset,save")
    
    # Add rectangles
    p.rect(x='lon', y='lat', 
           width=lon_size, height=lat_size,
           source=df,
           fill_color={'field': 'value', 'transform': color_mapper},
           line_color='white', line_width=0.5)
    
    # Add colorbar
    color_bar = ColorBar(color_mapper=color_mapper, width=8, location=(0,0))
    p.add_layout(color_bar, 'right')
    
    return p

# Method 2: Using image glyph with map tiles and proper Web Mercator interpolation (PREFERRED)
def plot_heatmap_with_image_and_tiles(lat_centers, lon_centers, data_2d, 
                                     lat_pixel_size, lon_pixel_size, 
                                     lat_range=None, lon_range=None):
    """
    Plot heatmap using image glyph with map tiles background and proper Web Mercator interpolation
    
    Parameters:
    -----------
    lat_range, lon_range : tuple, optional
        (min, max) ranges to plot. If None, uses full global extent.
    """
    # If ranges are specified, subset the data
    if lat_range is not None or lon_range is not None:
        if lat_range is None:
            lat_range = (lat_centers.min(), lat_centers.max())
        if lon_range is None:
            lon_range = (lon_centers.min(), lon_centers.max())
        
        # Find indices for the specified ranges
        lat_mask = (lat_centers >= lat_range[0]) & (lat_centers <= lat_range[1])
        lon_mask = (lon_centers >= lon_range[0]) & (lon_centers <= lon_range[1])
        
        lat_subset = lat_centers[lat_mask]
        lon_subset = lon_centers[lon_mask]
        data_subset = data_2d[np.ix_(lat_mask, lon_mask)]
    else:
        # Use full global data
        lat_subset = lat_centers
        lon_subset = lon_centers
        data_subset = data_2d
    
    # Calculate the bounds of the grid
    lat_min = lat_subset[0] - lat_pixel_size / 2
    lat_max = lat_subset[-1] + lat_pixel_size / 2
    lon_min = lon_subset[0] - lon_pixel_size / 2
    lon_max = lon_subset[-1] + lon_pixel_size / 2
    
    # Clamp latitude to Web Mercator limits (-85.0511 to 85.0511)
    lat_min = max(lat_min, -85.0511)
    lat_max = min(lat_max, 85.0511)
    
    # Transform coordinates to Web Mercator
    x_min, y_min = lat_lon_to_web_mercator(lon_min, lat_min)
    x_max, y_max = lat_lon_to_web_mercator(lon_max, lat_max)
    
    # Create interpolator for the subset grid data
    interpolator = RegularGridInterpolator(
        (lat_subset, lon_subset), 
        data_subset, 
        method='linear', 
        bounds_error=False, 
        fill_value=np.nan
    )
    
    # Define target resolution in Web Mercator space
    # Adjust resolution based on the area being plotted
    lat_span = lat_max - lat_min
    lon_span = lon_max - lon_min
    
    if lat_span > 60:  # Global or large region
        target_width = 1024
        target_height = 512
    elif lat_span > 20:  # Continental scale
        target_width = 800
        target_height = 600
    else:  # Regional scale
        target_width = 600
        target_height = 400
    
    # Create uniform grid in Web Mercator space
    x_mercator = np.linspace(x_min, x_max, target_width)
    y_mercator = np.linspace(y_min, y_max, target_height)
    X_mercator, Y_mercator = np.meshgrid(x_mercator, y_mercator)
    
    # Convert Web Mercator grid back to lat/lon for interpolation
    LON_interp, LAT_interp = web_mercator_to_lat_lon(X_mercator, Y_mercator)
    
    # Create points for interpolation
    points = np.column_stack([LAT_interp.ravel(), LON_interp.ravel()])
    
    # Interpolate data to the new grid
    interpolated_data = interpolator(points)
    interpolated_grid = interpolated_data.reshape(target_height, target_width)
    
    # Handle NaN values (areas outside original grid)
    interpolated_grid = np.ma.masked_invalid(interpolated_grid)
    
    # Create figure with Web Mercator coordinates
    p = figure(title=f"Global Heatmap ({lat_pixel_size}° lat × {lon_pixel_size}° lon)",
               x_axis_type="mercator", y_axis_type="mercator",
               width=1000, height=600,
               x_range=Range1d(x_min, x_max),
               y_range=Range1d(y_min, y_max),
               tools="pan,wheel_zoom,box_zoom,reset,save")
    
    # Add map tiles (CartoDB Dark Matter)
    p.add_tile(xyz.CartoDB.DarkMatter)
    
    # Flip data vertically for image display (image origin is top-left)
    data_flipped = np.flipud(interpolated_grid)
    
    # Create color mapper
    valid_data = interpolated_grid[~np.isnan(interpolated_grid)]
    if len(valid_data) > 0:
        color_mapper = LinearColorMapper(palette=Viridis256, 
                                       low=np.percentile(valid_data, 2), 
                                       high=np.percentile(valid_data, 98))
    else:
        color_mapper = LinearColorMapper(palette=Viridis256, low=0, high=1)
    
    # Add semi-transparent image overlay
    p.image(image=[data_flipped], 
            x=x_min, y=y_min, 
            dw=x_max-x_min, 
            dh=y_max-y_min,
            palette=Viridis256,
            alpha=0.7)  # Make heatmap semi-transparent to see map underneath
    
    # Add colorbar
    color_bar = ColorBar(color_mapper=color_mapper, width=8, location=(0,0))
    p.add_layout(color_bar, 'right')
    
    print(f"Original grid: {data_subset.shape}")
    print(f"Interpolated grid: {interpolated_grid.shape}")
    print(f"Latitude range: {lat_min:.3f} to {lat_max:.3f}")
    print(f"Longitude range: {lon_min:.3f} to {lon_max:.3f}")
    print(f"Web Mercator X range: {x_min:.0f} to {x_max:.0f}")
    print(f"Web Mercator Y range: {y_min:.0f} to {y_max:.0f}")
    
    return p

# Example usage with global grid
if __name__ == "__main__":
    # Global grid with your specified resolution
    print("Creating global heatmap with 0.5° lat × 0.625° lon resolution...")
    
    # Plot full global heatmap
    p_global = plot_heatmap_with_image_and_tiles(lat_centers, lon_centers, data_2d, 
                                                lat_pixel_size, lon_pixel_size)
    
    # Show global plot
    show(p_global)
    
    # Example: Plot a regional subset (Europe)
    print("\nCreating regional heatmap for Europe...")
    p_europe = plot_heatmap_with_image_and_tiles(lat_centers, lon_centers, data_2d, 
                                                lat_pixel_size, lon_pixel_size,
                                                lat_range=(35, 70), lon_range=(-10, 40))
    
    # Show European plot
    # show(p_europe)  # Uncomment to show
    
    print("Global heatmap plotted successfully!")
    print(f"Global grid info:")
    print(f"  Latitude points: {len(lat_centers)} (from {lat_centers[0]}° to {lat_centers[-1]}°)")
    print(f"  Longitude points: {len(lon_centers)} (from {lon_centers[0]}° to {lon_centers[-1]}°)")
    print(f"  Pixel size: {lat_pixel_size}° lat × {lon_pixel_size}° lon")
    print(f"  Total grid cells: {len(lat_centers) * len(lon_centers):,}")
    
    # Verification: Print corner coordinates for a sample point
    sample_lat = 45.25  # Example center
    sample_lon = 12.1875  # Example center
    lat_corners = [sample_lat - lat_pixel_size/2, sample_lat + lat_pixel_size/2]
    lon_corners = [sample_lon - lon_pixel_size/2, sample_lon + lon_pixel_size/2]
    print(f"\nSample pixel at ({sample_lat}°, {sample_lon}°):")
    print(f"  Latitude corners: {lat_corners}")
    print(f"  Longitude corners: {lon_corners}")
    
    print(f"\nNote: Web Mercator projection handles latitude distortion automatically")
    print(f"      Pixels appear larger at higher latitudes as expected")

1 Like