2025, Nov 04 21:00

How to Animate Monthly SST Images in Bokeh: avoid nested lists, use ColumnDataSource arrays with CustomJS

Troubleshooting Bokeh image animation: why SST frames don’t update with nested lists, and how to fix using ColumnDataSource arrays and a CustomJS callback.

Animating a monthly sea surface temperature (SST) image in Bokeh looks straightforward: compute a frame per month, wire a CustomJS callback, and flip images on a timer. Yet the browser shows a static plot even though the data differ month to month. The snag is subtle and sits on the JavaScript side.

Problem setup

The code below generates a grid, synthesizes 12 SST frames, and attempts to animate them in the browser by pushing nested lists to JavaScript.

import numpy as np
from bokeh.io import output_file, show
from bokeh.plotting import figure
from bokeh.models import ColumnDataSource, LinearColorMapper, ColorBar, Button, CustomJS, Title
from bokeh.layouts import column
# Grid
x_deg = np.linspace(-180, 180, 18)
y_deg = np.linspace(-90, 90, 9)
x2d, y2d = np.meshgrid(x_deg, y_deg)
# Generate 12 months of SST with variability
tiles = []
for m in range(12):
    field = (
        15
        + 10 * np.cos(y2d * np.pi / 18)
        + 10 * np.sin((x2d + m * 30) * np.pi / 9)
        + 5 * np.cos(y2d * np.pi / 45 + m)
        + np.random.normal(scale=1.5, size=y2d.shape)
    )
    tiles.append(field)
# Color scaling
cmin = np.min(tiles)
cmax = np.max(tiles)
cmap = LinearColorMapper(palette="Turbo256", low=cmin, high=cmax)
# Initial frame
src = ColumnDataSource(data=dict(image=[tiles[0]]))
# Figure
fig = figure(x_range=(-180, 180), y_range=(-90, 90), width=800, height=400)
fig.image(image="image", x=-180, y=-90, dw=360, dh=180, color_mapper=cmap, source=src)
cb = ColorBar(color_mapper=cmap)
fig.add_layout(cb, 'right')
# Title
hdr = Title(text="Monthly SST - Jan")
fig.add_layout(hdr, 'above')
# Button
play_btn = Button(label="▶ Play", width=100)
# Convert frames to nested lists for JavaScript
nested_tiles = [t.tolist() for t in tiles]
# JavaScript callback
cb_js = CustomJS(args=dict(source=src, button=play_btn, title=hdr), code=f"""
    if (!window._anim_sst2) {{
        var frames = {nested_tiles};
        var names = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
        var idx = 0;
        window._anim_sst2 = setInterval(function() {{
            idx = (idx + 1) % frames.length;
            source.data = Object.assign({{}}, source.data, {{image: [frames[idx]]}});
            title.text = "Monthly SST - " + names[idx];
        }}, 600);
        button.label = "⏸ Pause";
    }} else {{
        clearInterval(window._anim_sst2);
        window._anim_sst2 = null;
        button.label = "▶ Play";
    }}
""")
play_btn.js_on_click(cb_js)
output_file("sst_animation_working.html")
show(column(play_btn, fig))

What really goes wrong

The critical line is where frames are converted to nested lists for JavaScript. Bokeh / BokehJS has not accepted nested lists as “fake 2d” arrays for many years. The image glyph expects arrays that Bokeh serializes as typed arrays with shape information; plain nested lists lose that structure on the JS side. As a result, the browser receives data it can’t apply to the image glyph, and nothing appears to change during the animation.

Fix: keep arrays in the ColumnDataSource and copy per month

Load every month’s image into the ColumnDataSource up front so Bokeh serializes them correctly. Then, in the CustomJS callback, copy the selected month’s column into the image column that drives the glyph.

import numpy as np
from bokeh.io import output_file, show
from bokeh.plotting import figure
from bokeh.models import ColumnDataSource, LinearColorMapper, ColorBar, Button, CustomJS, Title
from bokeh.layouts import column
# Grid
x_deg = np.linspace(-180, 180, 18)
y_deg = np.linspace(-90, 90, 9)
x2d, y2d = np.meshgrid(x_deg, y_deg)
# Generate 12 months of SST with variability
tiles = []
for m in range(12):
    field = (
        15
        + 10 * np.cos(y2d * np.pi / 18)
        + 10 * np.sin((x2d + m * 30) * np.pi / 9)
        + 5 * np.cos(y2d * np.pi / 45 + m)
        + np.random.normal(scale=1.5, size=y2d.shape)
    )
    tiles.append(field)
# Color scaling
cmin = np.min(tiles)
cmax = np.max(tiles)
cmap = LinearColorMapper(palette="Turbo256", low=cmin, high=cmax)
# Put all months into the data source so they serialize as typed arrays
labels = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]
full_ds = {"image": [tiles[0]]}
for i, lab in enumerate(labels):
    full_ds[lab] = [tiles[i]]
src = ColumnDataSource(data=full_ds)
# Figure
fig = figure(x_range=(-180, 180), y_range=(-90, 90), width=800, height=400)
fig.image(image="image", x=-180, y=-90, dw=360, dh=180, color_mapper=cmap, source=src)
cb = ColorBar(color_mapper=cmap)
fig.add_layout(cb, 'right')
# Title
hdr = Title(text="Monthly SST - Jan")
fig.add_layout(hdr, 'above')
# Button
play_btn = Button(label="▶ Play", width=100)
# JavaScript: copy the month column into `image`
cb_js = CustomJS(args=dict(source=src, button=play_btn, title=hdr), code="""
    if (!window._anim_sst2) {
        var names = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
        var i = 0;
        window._anim_sst2 = setInterval(function() {
            i = (i + 1) % names.length;
            var data_copy = Object.assign({}, source.data);
            data_copy.image = source.data[names[i]];  // use correctly serialized data
            source.data = data_copy;                  // "kick" a re-render if needed
            title.text = "Monthly SST - " + names[i];
        }, 600);
        button.label = "⏸ Pause";
    } else {
        clearInterval(window._anim_sst2);
        window._anim_sst2 = null;
        button.label = "▶ Play";
    }
""")
play_btn.js_on_click(cb_js)
output_file("sst_animation_fixed.html")
show(column(play_btn, fig))

Why this is worth knowing

The failure lives in BokehJS, i.e., in the browser. Python-side print debugging won’t expose it. When something updates in Python but not on the canvas, switch to browser dev tools and debug JavaScript, including breaking on uncaught exceptions. Keeping data in the ColumnDataSource as arrays that Bokeh serializes into typed arrays avoids a whole class of client-side pitfalls and makes animation updates a simple matter of reassigning a column.

Takeaways

Don’t pass nested lists to image glyphs and expect them to behave like 2D arrays. Store frame arrays in the ColumnDataSource so they serialize with shape information, and then copy the selected month into the active image column inside a CustomJS callback. If an update looks ignored, trigger a redraw by reassigning source.data. For troubleshooting Bokeh runtime behavior, look to the browser first. Keeping a minimal, reproducible example on hand will make issues like this faster to spot and fix.

The article is based on a question from StackOverflow by Eric Sánchez and an answer by bigreddot.