BokehJS Support for Multiple Map Projections with Gridded Data

A custom BokehJS extension that visualizes gridded data on flat map projections. Supports several projections including Mercator, Robinson, Mollweide, Natural Earth, Winkel Tripel, and Albers Equal Area. Pan with drag, zoom with scroll, and rotate the map horizontally. Auto-loads Natural Earth coastlines and country borders. Great for climate data, ocean currents, atmospheric patterns, or any global dataset that works better on flat maps than globes.

Free and open source code is here:

Simply download the folder, install bokeh numpy pandas and xarray (no need for cartopy!), and run the several examples.

Simple example:

########### mollweide ###########
from gridded_projection_py import GriddedProjection
from bokeh.plotting import show
import numpy as np

lons = np.linspace(-180, 180, 120)
lats = np.linspace(-90, 90, 60)
lons_grid, lats_grid = np.meshgrid(lons, lats)
values = ( 10 * np.sin(np.radians(lats_grid)) + 6  * np.sin(2 * np.radians(lons_grid)) + 3  * np.sin(4 * np.radians(lats_grid + lons_grid)) )

proj = GriddedProjection(
    lons=lons_grid.flatten().tolist(),
    lats=lats_grid.flatten().tolist(),
    values=values.flatten().tolist(),
    n_lat=60,
    n_lon=120,
    projection='mollweide',
    palette='terrain',
)
show(proj)

Interactive Explorer with CustomJS:

Peek 2026-02-02 03-38


"""
Interactive GriddedProjection Dashboard
========================================
Pure-Bokeh, no server required.  All controls are wired through CustomJS
callbacks so every change updates the map instantly in the browser.

Requirements
------------
* bokeh
* numpy
* The GriddedProjection custom model (gridded_projection_py.py  +  the
  compiled gridded_projection.ts) must be importable from the same directory.

Run
---
    python dashboard.py
"""

import numpy as np
from bokeh.plotting import save, show, output_file
from bokeh.layouts import column, row
from bokeh.models import (
    Dropdown, Select, CheckboxGroup, ColorPicker,
    CustomJS, Div, Spacer, Label
)
from bokeh.core.serialization import Serializer   # only used for type hint; safe to skip

from gridded_projection_py import GriddedProjection

# ═══════════════════════════════════════════════════════════════════════════════
# 1.  DATA SAMPLES  –  four "natural-looking" global fields
# ═══════════════════════════════════════════════════════════════════════════════

N_LON, N_LAT = 180, 90          # grid resolution (lon × lat)

_lons1d = np.linspace(-180, 180, N_LON)
_lats1d = np.linspace(-90, 90, N_LAT)
_LON, _LAT = np.meshgrid(_lons1d, _lats1d)
_lon_r, _lat_r = np.radians(_LON), np.radians(_LAT)


def _sample_temperature():
    """
    Synthetic global surface-temperature-like field.
    Warm tropics, cold poles, slight land-sea contrast via a cosine bump.
    """
    base = 15.0 - 40.0 * (_lat_r ** 2) / (np.pi / 2) ** 2          # parabolic pole cooling
    land  = 5.0 * np.cos(2.5 * _lon_r) * np.cos(1.8 * _lat_r)      # zonal "continent" warmth
    noise = 2.0 * np.sin(7 * _lon_r) * np.sin(5 * _lat_r)           # small-scale texture
    return base + land + noise


def _sample_pressure():
    """
    Synthetic sea-level-pressure-like field.
    Alternating high/low centres reminiscent of the subtropical highs and
    polar lows, with a slight hemispheric asymmetry.
    """
    # Three subtropical high centres (roughly correct positions)
    highs = (
          1013 + 18 * np.exp(-(((_lon_r - np.radians(-30))**2) / 0.4 + ((_lat_r - np.radians(30))**2) / 0.25))
              + 15 * np.exp(-(((_lon_r - np.radians(130))**2) / 0.5 + ((_lat_r - np.radians(25))**2) / 0.22))
              + 14 * np.exp(-(((_lon_r - np.radians(-120))**2) / 0.45 + ((_lat_r - np.radians(28))**2) / 0.24))
    )
    # Polar low influence
    polar = -12 * np.exp(-((_lat_r - np.radians(55))**2) / 0.18)
    polar -= 10 * np.exp(-((_lat_r + np.radians(60))**2) / 0.20)
    # Equatorial low band (ITCZ-ish)
    itcz = -6 * np.exp(-(_lat_r**2) / 0.04)
    return highs + polar + itcz


def _sample_wind_speed():
    """
    Synthetic global wind-speed field.
    Jet streams at ~±35° lat, trade-wind bands in the tropics, calm subtropics.
    """
    # Jet streams (narrow bands of high wind)
    jets = (12 * np.exp(-((_lat_r - np.radians(38))**2) / 0.03)
            + 10 * np.exp(-((_lat_r + np.radians(42))**2) / 0.035))
    # Trade winds (broader, lower)
    trades = 7 * np.cos(_lon_r * 0.5) * np.exp(-((_lat_r)**2) / 0.12)
    # Calm subtropics (dip)
    calm = -4 * (np.exp(-((_lat_r - np.radians(25))**2) / 0.06)
                 + np.exp(-((_lat_r + np.radians(25))**2) / 0.06))
    # Polar westerlies
    westerlies = 6 * np.exp(-((_lat_r - np.radians(55))**2) / 0.08) * (1 + 0.3 * np.sin(2 * _lon_r))
    return np.clip(jets + trades + calm + westerlies + 3, 0, None)   # speed ≥ 0


def _sample_ocean_depth():
    """
    Synthetic bathymetry / elevation field.
    Deep ocean basins, mid-ocean ridges along ~0° and ~180° lon, shallow
    continental shelves near the "land" longitudes, and mountain ranges.
    """
    # Ocean basins (deep negative)
    pac  = -4500 * np.exp(-(((_lon_r - np.radians(170))**2) / 1.2 + ((_lat_r)**2) / 0.8))
    atl  = -3800 * np.exp(-(((_lon_r - np.radians(-35))**2) / 0.6 + ((_lat_r)**2) / 1.0))
    ind  = -3200 * np.exp(-(((_lon_r - np.radians(80))**2)  / 0.5 + ((_lat_r + np.radians(15))**2) / 0.4))
    # Mid-ocean ridges (shallower lines through basins)
    ridge = 1800 * np.exp(-((np.abs(_lon_r - np.radians(-30)) - 0.05)**2) / 0.01) * np.exp(-(_lat_r**2)/2.0)
    # Continental shelves / land masses (positive near "continents")
    land  = 2000 * np.exp(-(((_lon_r - np.radians(30))**2) / 0.3 + ((_lat_r - np.radians(40))**2) / 0.35))
    land += 1800 * np.exp(-(((_lon_r - np.radians(-100))**2) / 0.4 + ((_lat_r - np.radians(35))**2) / 0.3))
    land += 1500 * np.exp(-(((_lon_r - np.radians(110))**2) / 0.35 + ((_lat_r - np.radians(20))**2) / 0.25))
    # Mountain peaks
    mtn = 3500 * np.exp(-(((np.radians(_LON) - np.radians(75))**2) / 0.02 + ((_lat_r - np.radians(32))**2) / 0.01))  # Himalayas-ish
    return pac + atl + ind + ridge + land + mtn


SAMPLES = {
    "Sample 1 – Temperature (°C)":  _sample_temperature(),
    "Sample 2 – Pressure (hPa)":    _sample_pressure(),
    "Sample 3 – Wind Speed (m/s)":  _sample_wind_speed(),
    "Sample 4 – Bathymetry (m)":    _sample_ocean_depth(),
}

# Flatten lons/lats once; value arrays are built per-sample below.
FLAT_LONS   = _LON.flatten().tolist()
FLAT_LATS   = _LAT.flatten().tolist()

# ═══════════════════════════════════════════════════════════════════════════════
# 2.  WIDGET DEFINITIONS
# ═══════════════════════════════════════════════════════════════════════════════
PROJECTIONS = [ "natural_earth", "mollweide", "robinson", "plate_carree", "sinusoidal", "eckert4", "winkel_tripel", "miller", "mercator", "albers_equal_area", ]
PALETTES = [ "Turbo256", "Viridis256", "Plasma256", "Inferno256", "Magma256", "Cividis256", "terrain", "YlOrRd", "RdYlBu", "bwr", "Spectral", "RdYlGn", "PiYG", "BuPu", "nipy_spectral", "coolwarm","cool","seismic","winter","summer","autumn","spring", "rainbow","gist_earth" ]
projection_select = Select( title="Projection", value="robinson", options=PROJECTIONS, width=220, styles={"font-size": "13px"} )
palette_select = Select( title="Palette", value="Spectral", options=PALETTES, width=220, styles={"font-size": "13px"} )
data_select = Select( title="Data Sample", value="0", options=[(str(i), name) for i, name in enumerate(SAMPLES.keys())], width=220, styles={"font-size": "13px"} )
# Checkboxes:  0 = coastlines, 1 = countries
overlay_checkboxes = CheckboxGroup( labels=["Show Coastlines", "Show Countries"], active=[0,1], styles={"font-size": "13px"} )                         
coastline_picker = ColorPicker( title="Coastline Color", color="#000000", width=70, styles={"font-size": "13px"} )
country_picker = ColorPicker( title="Country Border Color", color="#000000", width=70, styles={"font-size": "13px"} )

# ═══════════════════════════════════════════════════════════════════════════════
# 3.  THE MAP MODEL  (created once; JS mutates its properties)
# ═══════════════════════════════════════════════════════════════════════════════

SAMPLE_NAMES  = list(SAMPLES.keys())                                   # ["Sample 1 – …", …]
SAMPLE_VALUES = [SAMPLES[k].flatten().tolist() for k in SAMPLE_NAMES]  # list of flat arrays

map_model = GriddedProjection(
    lons=FLAT_LONS,
    lats=FLAT_LATS,
    values=SAMPLE_VALUES[0],
    n_lat=N_LAT,
    n_lon=N_LON,
    projection="robinson",
    palette="Spectral",
    show_coastlines=True,
    coastline_color="#000000",
    coastline_width=0.6,
    show_countries=True,
    country_color="#000000",
    country_width=0.5,
    background_color="#f0f0f0",
    colorbar_text_color="#494949",
    colorbar_title="Value",
    width=900,
    height=520,
)

# Park all value arrays on .tags so JS can reach them as map_model.tags[i]
# (same technique the sphere example uses)
map_model.tags = SAMPLE_VALUES

# ═══════════════════════════════════════════════════════════════════════════════
# 4.  CUSTOM JS  –  one shared callback, attached to every widget
# ═══════════════════════════════════════════════════════════════════════════════

JS_CODE = """
(function() {
    // All names here are injected bare by Bokeh — no "args." prefix.
    var idx      = parseInt(data_select.value);
    var newVals  = gmap.tags[idx];

    gmap.projection     = projection_select.value;
    gmap.palette        = palette_select.value;
    gmap.values         = newVals;
    gmap.colorbar_title = sample_names[idx];

    gmap.show_coastlines = overlay_checkboxes.active.indexOf(0) !== -1;
    gmap.show_countries  = overlay_checkboxes.active.indexOf(1) !== -1;

    gmap.coastline_color = coastline_picker.color;
    gmap.country_color   = country_picker.color;
})();
"""

shared_cb = CustomJS(args=dict(
    gmap=map_model,
    projection_select=projection_select,
    palette_select=palette_select,
    data_select=data_select,
    overlay_checkboxes=overlay_checkboxes,
    coastline_picker=coastline_picker,
    country_picker=country_picker,
    sample_names=SAMPLE_NAMES,          # plain list of strings, easy to serialise
), code=JS_CODE)

# Attach to every widget that should trigger an update
projection_select.js_on_change("value",   shared_cb)
palette_select.js_on_change("value",      shared_cb)
data_select.js_on_change("value",         shared_cb)
overlay_checkboxes.js_on_change("active", shared_cb)
coastline_picker.js_on_change("color",    shared_cb)
country_picker.js_on_change("color",      shared_cb)

# ═══════════════════════════════════════════════════════════════════════════════
# 5.  LAYOUT  &  OUTPUT
# ═══════════════════════════════════════════════════════════════════════════════

title_div = Div(text="""
<div style="

    background: linear-gradient(135deg, #dbdbdb 0%, #faa4a4 100%);
    border-radius: 12px 12px 0 0;
    padding: 18px 28px;
    border-bottom: 1px solid #faa4a4;
">
    <h2 style="margin:0; color:#0f0f0f; font-weight:600; font-size:20px; letter-spacing:0.3px;">
        🌍 Global Projection Explorer
    </h2>
    <p style="margin:6px 0 0; color:#000000; font-size:13px; font-weight:400;">
        Switch projections, palettes and data fields instantly.
    </p>
</div>
""", width=400)

layout = row(
    column(
    title_div,
    projection_select,palette_select,data_select, overlay_checkboxes,coastline_picker,country_picker,),
    map_model, styles = {"border": "1px solid #faa4a4", "border-radius": "12px","background": "#f0f0f0", "width": "1500px"}
)

show(layout)

Interactive Animation with CustomJS (really fast animation):

Peek 2026-02-02 03-40

"""
Animated GriddedProjection - CustomJS Version
==============================================
12 years of gridded data with time slider and play button.
Pure client-side JavaScript - no server required.

Run with: python animated_gridded_customjs.py
"""

import numpy as np
from bokeh.plotting import show
from bokeh.layouts import column, row
from bokeh.models import Slider, Button, Div, CustomJS
from gridded_projection_py import GriddedProjection

# ═══════════════════════════════════════════════════════════════════════════════
# 1. GENERATE 12 YEARS OF DATA
# ═══════════════════════════════════════════════════════════════════════════════

N_YEARS = 12
lons = np.linspace(-180, 180, 120)
lats = np.linspace(-90, 90, 60)
lons_grid, lats_grid = np.meshgrid(lons, lats)

# Generate data for each year with evolving patterns
yearly_data = []
for year in range(N_YEARS):
    # Base pattern with time evolution
    time_factor = year / N_YEARS * 2 * np.pi
    
    values = (
        10 * np.sin(np.radians(lats_grid)) +
        6 * np.sin(2 * np.radians(lons_grid) + time_factor) +
        3 * np.sin(4 * np.radians(lats_grid + lons_grid) + time_factor * 0.5) +
        2 * np.cos(np.radians(lons_grid) * 3 - time_factor)
    )
    yearly_data.append(values.flatten().tolist())

# ═══════════════════════════════════════════════════════════════════════════════
# 2. CREATE MAP MODEL
# ═══════════════════════════════════════════════════════════════════════════════

proj = GriddedProjection(
    lons=lons_grid.flatten().tolist(),
    lats=lats_grid.flatten().tolist(),
    values=yearly_data[0],  # Start with year 0
    n_lat=60,
    n_lon=120,
    projection='robinson',
    palette='Spectral',
    colorbar_title='Year 2013',
    width=900,
    height=500,
        background_color="#f0f0f0",
    colorbar_text_color="#333333",
)

# Store all yearly data in the model's tags for JS access
proj.tags = yearly_data

# ═══════════════════════════════════════════════════════════════════════════════
# 3. CREATE WIDGETS
# ═══════════════════════════════════════════════════════════════════════════════

year_slider = Slider(
    start=0,
    end=N_YEARS - 1,
    value=0,
    step=1,
    title="Year",
    width=700,
    bar_color="#ff6b6b",
    
)

play_button = Button(
    label="▶ Play",
    button_type="success",
    width=100,
)

title_div = Div(text="""
<div style="
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    border-radius: 8px;
    padding: 16px 24px;
    margin-bottom: 15px;
">
    <h2 style="margin:0; color:white; font-weight:600; font-size:22px;">
        🌍 Climate Data Animation (2013-2024)
    </h2>
    <p style="margin:6px 0 0; color:#f0f0f0; font-size:14px;">
        Use the slider or play button to animate through 12 years of data
    </p>
</div>
""", width=900)

# ═══════════════════════════════════════════════════════════════════════════════
# 4. CUSTOMJS CALLBACKS
# ═══════════════════════════════════════════════════════════════════════════════

# Slider callback: update map when slider changes
slider_callback = CustomJS(args=dict(proj=proj, slider=year_slider), code="""
    const year_idx = Math.round(slider.value);
    const year_label = 2013 + year_idx;
    
    // Update map data from stored tags
    proj.values = proj.tags[year_idx];
    proj.colorbar_title = 'Year ' + year_label;

""")

year_slider.js_on_change('value', slider_callback)

# Play button callback: start/stop animation
play_callback = CustomJS(args=dict(
    slider=year_slider,
    button=play_button,
    proj=proj,
    n_years=N_YEARS
), code="""
    // Animation interval stored on button itself
    if (button.interval_id === undefined) {
        button.interval_id = null;
        button.is_playing = false;
    }
    
    if (button.is_playing) {
        // Stop animation
        if (button.interval_id !== null) {
            clearInterval(button.interval_id);
            button.interval_id = null;
        }
        button.label = "▶ Play";
        button.button_type = "success";
        button.is_playing = false;
    } else {
        // Start animation
        button.interval_id = setInterval(function() {
            let new_year = slider.value + 1;
            if (new_year >= n_years) {
                new_year = 0;
            }
            slider.value = new_year;
            
            // Update map directly (slider callback will also fire)
            const year_idx = Math.round(new_year);
            const year_label = 2013 + year_idx;
            proj.values = proj.tags[year_idx];
            proj.colorbar_title = 'Year ' + year_label;
            
        }, 500);  // 500ms = 0.5 sec per year
        
        button.label = "⏸ Pause";
        button.button_type = "warning";
        button.is_playing = true;
    }
""")

play_button.js_on_click(play_callback)

# ═══════════════════════════════════════════════════════════════════════════════
# 5. LAYOUT
# ═══════════════════════════════════════════════════════════════════════════════

controls = row(play_button, year_slider, spacing=10)

layout = column(
    title_div,
    controls,
    proj,
    sizing_mode='fixed',styles = {"border": "1px solid #faa4a4", "border-radius": "12px","background": "#f0f0f0", "width": "1100px", "height": "700px"}
)

show(layout)
1 Like