Climate Warming Stripes — Bokeh

Climate warming stripes (also known as "warming stripes") display temperature anomaly data as a sequence of vertical colored bars, one per year, using a blue-to-red diverging colormap. Created by climate scientist Ed Hawkins, this minimalist visualization strips away axes, labels, and gridlines to communicate long-term warming trends through pure color encoding. The progression from cool blues to warm reds makes temperature change immediately visible at a glance.

Climate Warming Stripes rendered with Bokeh

Python source (Bokeh)

""" anyplot.ai
heatmap-stripes-climate: Climate Warming Stripes
Library: bokeh 3.9.0 | Python 3.13.13
Quality: 88/100 | Updated: 2026-06-02
"""

import os
import sys
import time
from pathlib import Path


# Remove script's own directory from sys.path so 'bokeh' resolves to the installed package
_this_dir = os.path.dirname(os.path.abspath(__file__))
if _this_dir in sys.path:
    sys.path.remove(_this_dir)

import numpy as np
from bokeh.io import output_file, save
from bokeh.models import HoverTool, LinearColorMapper, Range1d
from bokeh.plotting import figure
from selenium import webdriver
from selenium.webdriver.chrome.options import Options


THEME = os.getenv("ANYPLOT_THEME", "light")

# Theme-adaptive chrome — Imprint palette
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"

# Data: synthetic global temperature anomalies (1850–2024) relative to 1961–1990 baseline
np.random.seed(42)
years = np.arange(1850, 2025)
n_years = len(years)

base_trend = np.concatenate(
    [
        np.linspace(-0.3, -0.2, 60),
        np.linspace(-0.2, -0.1, 40),
        np.linspace(-0.1, 0.0, 25),
        np.linspace(0.0, 0.4, 25),
        np.linspace(0.4, 1.2, 25),
    ]
)
noise = np.random.normal(0, 0.1, n_years)
anomalies = base_trend + noise

# Symmetric color range centered at 0
vmax = np.ceil(max(abs(anomalies.min()), abs(anomalies.max())) * 10) / 10


def _lerp_hex(c0, c1, t):
    r0, g0, b0 = (int(c0[i : i + 2], 16) for i in (1, 3, 5))
    r1, g1, b1 = (int(c1[i : i + 2], 16) for i in (1, 3, 5))
    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}"


# Imprint diverging palette: #4467A3 (blue/cold) → neutral midpoint → #AE3030 (red/warm)
# Negative anomalies = cold = blue; positive anomalies = warm = red
# Dark midpoint uses #3A3A37 (not #1A1A17) so near-zero stripes remain visible against dark bg
_midpoint = "#FAF8F1" if THEME == "light" else "#3A3A37"
ANYPLOT_DIV256 = [_lerp_hex("#4467A3", _midpoint, t / 127.0) for t in range(128)] + [
    _lerp_hex(_midpoint, "#AE3030", t / 127.0) for t in range(128)
]

color_mapper = LinearColorMapper(palette=ANYPLOT_DIV256, low=-vmax, high=vmax)

# Reshape anomalies as 2D image (1 row × n_years columns) for seamless stripe rendering
img_data = anomalies.reshape(1, -1)

# Canvas: 3200×1800 px landscape (hard contract — no deviation)
W, H = 3200, 1800

p = figure(
    width=W,
    height=H,
    title="heatmap-stripes-climate · python · bokeh · anyplot.ai",
    tools="",
    toolbar_location=None,
    x_range=Range1d(years[0], years[-1] + 1),
    y_range=Range1d(0, 1),
    min_border_left=0,
    min_border_right=0,
    min_border_top=110,  # room for 50pt title
    min_border_bottom=0,  # no x-axis — extend stripes to bottom edge
)

p.image(image=[img_data], x=years[0], y=0, dw=n_years, dh=1, color_mapper=color_mapper)

# Minimalist style: no axes, no labels, no gridlines, no ticks — pure color encoding
p.title.text_font_size = "50pt"
p.title.text_font_style = "normal"
p.title.align = "center"
p.title.text_color = INK

p.xaxis.visible = False
p.yaxis.visible = False
p.xgrid.grid_line_color = None
p.ygrid.grid_line_color = None
p.outline_line_color = None
p.background_fill_color = PAGE_BG
p.border_fill_color = PAGE_BG

# Interactive hover for HTML version — shows year and anomaly on hover
hover = HoverTool(tooltips=[("Year", "$x{0}"), ("Anomaly", "@image{+0.00}°C")])
p.add_tools(hover)

# Save interactive HTML (required artifact for interactive libraries)
output_file(f"plot-{THEME}.html")
save(p, title="Climate Warming Stripes")

# Screenshot with headless Chrome via Selenium (do NOT use export_png — chromedriver snap issues)
# Use a taller window to accommodate browser overhead, then use CDP to override viewport exactly.
opts = Options()
for arg in (
    "--headless=new",
    "--no-sandbox",
    "--disable-dev-shm-usage",
    "--disable-gpu",
    f"--window-size={W},{H + 200}",
    "--hide-scrollbars",
    "--force-device-scale-factor=1",
):
    opts.add_argument(arg)
driver = webdriver.Chrome(options=opts)
# Override viewport to exactly W×H so the full Bokeh canvas is in-view
driver.execute_cdp_cmd(
    "Emulation.setDeviceMetricsOverride", {"width": W, "height": H, "deviceScaleFactor": 1, "mobile": False}
)
driver.get(f"file://{Path(f'plot-{THEME}.html').resolve()}")
time.sleep(3)  # let Bokeh JS render the canvas
driver.save_screenshot(f"plot-{THEME}.png")
driver.quit()

Part of Climate Warming Stripes on anyplot.ai.

Other implementations