BokehJS Support for Interactive 3D Globe with gridded data

A custom BokehJS extension that brings interactive 3D globe visualization to Bokeh. Plot gridded data (temperature, precipitation, etc.) on a rotating sphere with coastlines, country borders, and multi-layer overlays including scatter points, 3D bars, flight routes, and satellite trajectories. Features drag-to-rotate, auto-rotation, optional Phong lighting for realistic shading, integrated colorbar, and smart tooltips that work across all data layers.

:white_check_mark: Spherical projection with depth sorting
:white_check_mark: :fire: Drag rotation/tilt, scroll zoom, auto-rotation
:white_check_mark: Auto-loaded Natural Earth coastlines & borders
:white_check_mark: :fire: Scatter points, 3D bars, lines, 3D trajectories
:white_check_mark: :fire: Optional lighting (azimuth/elevation/intensity)
:white_check_mark: 30+ color palettes with integrated colorbar
:white_check_mark: Multi-layer tooltips (grid values, labels, heights)

Free and open source code is here:

Simply download the folder, install bokeh numpy pandas and xarray, and run the several examples, includingd in bokeh_gridded_sphere_examples.py.

Simple example:

from gridded_sphere_py import GriddedSphere
from bokeh.plotting import show
import numpy as np
# Create grid
n_lat = 60; n_lon = 120;lons = np.linspace(-180, 180, n_lon);lats = np.linspace(-90, 90, n_lat)
lons_grid, lats_grid = np.meshgrid(lons, lats)
# Temperature pattern
values = 30 - 50 * np.abs(lats_grid) / 90;values += 10 * np.sin(np.radians(lons_grid) * 3) * np.cos(np.radians(lats_grid) * 2)
# Create sphere
sphere1 = GriddedSphere(
    lons=lons_grid.flatten().tolist(),
    lats=lats_grid.flatten().tolist(),
    values=values.flatten().tolist(),
    n_lat=n_lat,
    n_lon=n_lon,
    palette='Spectral',
    autorotate = True
)
show(sphere1)

Peek 2026-02-01 03-48

from points import cities
from bokeh.plotting import show
from gridded_sphere_py import GriddedSphere
import numpy as np

# Create realistic Earth-like gridded data
n_lat = 40
n_lon = 80

lats = np.linspace(-90, 90, n_lat)
lons = np.linspace(-180, 180, n_lon)
lon_grid, lat_grid = np.meshgrid(lons, lats)

# Temperature-like pattern
temperature = 25 - 35 * np.abs(np.sin(np.radians(lat_grid))) + 5 * np.cos(np.radians(lon_grid) * 2)

earthquakes = [
    # Pacific Ring of Fire
    {'lon': 142.3, 'lat': 38.3, 'size': 16, 'color': '#ff0000', 
     'border_color': '#000000', 'border_width': 2, 'label': 'Japan M7.3 (2022)'},
    {'lon': 143.9, 'lat': 37.0, 'size': 18, 'color': '#ff0000',
     'border_color': '#000000', 'border_width': 2, 'label': 'Fukushima M7.4 (2022)'},
    {'lon': -103.3, 'lat': 19.4, 'size': 15, 'color': '#ff3300',
     'border_color': '#000000', 'border_width': 2, 'label': 'Mexico M7.1 (2022)'},
    {'lon': -72.2, 'lat': -33.0, 'size': 14, 'color': '#ff6600',
     'border_color': '#000000', 'border_width': 2, 'label': 'Chile M6.9 (2023)'},
    {'lon': 174.0, 'lat': -41.7, 'size': 15, 'color': '#ff9900',
     'border_color': '#000000', 'border_width': 2, 'label': 'New Zealand M7.2 (2021)'},
    {'lon': -155.5, 'lat': 19.4, 'size': 13, 'color': '#ffcc00',
     'border_color': '#000000', 'border_width': 2, 'label': 'Hawaii M6.8 (2022)'},
    
    # Turkey-Syria
    {'lon': 37.2, 'lat': 37.2, 'size': 20, 'color': '#ff0000',
     'border_color': '#000000', 'border_width': 3, 'label': 'Turkey M7.8 (2023)'},
    {'lon': 37.0, 'lat': 38.0, 'size': 18, 'color': '#ff0000',
     'border_color': '#000000', 'border_width': 3, 'label': 'Turkey M7.5 (2023)'},
    
    # Indonesia & Philippines
    {'lon': 119.8, 'lat': -1.3, 'size': 14, 'color': '#ff6600',
     'border_color': '#000000', 'border_width': 2, 'label': 'Indonesia M7.0 (2023)'},
    {'lon': 126.4, 'lat': 10.7, 'size': 13, 'color': '#ff9900',
     'border_color': '#000000', 'border_width': 2, 'label': 'Philippines M6.9 (2023)'},
    
    # South Pacific
    {'lon': -178.3, 'lat': -18.2, 'size': 17, 'color': '#ff3300',
     'border_color': '#000000', 'border_width': 2, 'label': 'Fiji M7.6 (2024)'},
    {'lon': 166.9, 'lat': -14.9, 'size': 16, 'color': '#ff6600',
     'border_color': '#000000', 'border_width': 2, 'label': 'Vanuatu M7.3 (2023)'},
    
    # Alaska
    {'lon': -149.0, 'lat': 61.3, 'size': 14, 'color': '#ff9900',
     'border_color': '#000000', 'border_width': 2, 'label': 'Alaska M7.0 (2023)'},
    
    # Afghanistan
    {'lon': 70.0, 'lat': 35.0, 'size': 13, 'color': '#ffcc00',
     'border_color': '#000000', 'border_width': 2, 'label': 'Afghanistan M6.8 (2023)'},
    
    # Morocco
    {'lon': -8.5, 'lat': 31.1, 'size': 15, 'color': '#ff6600',
     'border_color': '#000000', 'border_width': 2, 'label': 'Morocco M6.8 (2023)'},
]

sphere_scatter = GriddedSphere(
    lons=lon_grid.flatten().tolist(),
    lats=lat_grid.flatten().tolist(),
    values=temperature.flatten().tolist(),
    n_lat=n_lat,
    n_lon=n_lon,
    palette='terrain',
    width=900,
    height=900,
    rotation=145,
    tilt=20,
    zoom=0.8,
    autorotate=False,
    scatter_data=earthquakes,
    show_colorbar=True,
    background_color='#000000',
    show_coastlines=True,
    coastline_color='#666666',
    coastline_width=0.6,
    show_countries=True,
    country_color='#444444',
    country_width=0.3
)

show(sphere_scatter)

np.random.seed(42)
n_bars = 30
random_bars = []

for i in range(n_bars):
    lon = np.random.uniform(-180, 180)
    lat = np.random.uniform(-60, 60)
    height = np.random.uniform(50, 800)
    
    # Color based on hemisphere
    if lat > 0:
        color = '#ff6b6b'  # Northern hemisphere - red
    else:
        color = '#4ecdc4'  # Southern hemisphere - cyan
    
    random_bars.append({
        'lon': lon,
        'lat': lat,
        'height': height,
        'color': color,
        'label': f'Lat: {lat:.1f}, Height: {height:.0f}'
    })

sphere2 = GriddedSphere(
    lons=lon_grid.flatten().tolist(),
    lats=lat_grid.flatten().tolist(),
    values=temperature.flatten().tolist(),
    n_lat=n_lat,
    n_lon=n_lon,
    palette='inferno',
    bar_data=random_bars,
)
show(sphere2)



from bokeh.plotting import show
from bokeh.layouts import column
from gridded_sphere_py import GriddedSphere
import numpy as np

# Create some sample gridded data
n_lat = 100
n_lon = 100

lats = np.linspace(-90, 90, n_lat)
lons = np.linspace(-180, 180, n_lon)
lon_grid, lat_grid = np.meshgrid(lons, lats)

# Create realistic Earth-like data
# Temperature gradient: hot at equator, cold at poles
temperature = 30 - 40 * np.abs(np.sin(np.radians(lat_grid)))

# === Example 1: Classic day-night terminator ===
# Light from the right side (like afternoon sun)
sphere1 = GriddedSphere(
    lons=lon_grid.flatten().tolist(),
    lats=lat_grid.flatten().tolist(),
    values=temperature.flatten().tolist(),
    n_lat=n_lat,
    n_lon=n_lon,
    palette='Spectral',  
    width=800,
    height=800,
    rotation=0,
    tilt=-23.5,  # Earth's axial tilt
    zoom=0.8,
    autorotate=False,
    enable_lighting=True,
    light_azimuth=135,    # Light from upper right
    light_elevation=25,    # 45 degrees above horizon
    light_intensity=1.2,   # Strong directional light
    ambient_light=0,    # Some ambient illumination
    show_colorbar=True,
    colorbar_title='Temperature (°C)',
    background_color='#000000',
    show_coastlines=True,
    coastline_color='#414141',
    coastline_width=0.9
)

show(sphere1)



import numpy as np
from gridded_sphere_py import GriddedSphere
from bokeh.io import show

def make_ring(inclination_deg, raan_deg, altitude, n=300):
    inc  = np.radians(inclination_deg)
    raan = np.radians(raan_deg)
    t    = np.linspace(0, 2 * np.pi, n, endpoint=False)

    x = np.cos(t)
    y = np.sin(t) * np.cos(inc)
    z = np.sin(t) * np.sin(inc)

    x2 = x * np.cos(raan) - y * np.sin(raan)
    y2 = x * np.sin(raan) + y * np.cos(raan)

    lon = np.degrees(np.arctan2(y2, x2))
    lat = np.degrees(np.arcsin(np.clip(z, -1, 1)))

    # Split into two continuous segments at the lon wraparound
    # Find where lon jumps (the discontinuity from +180 to -180)
    dlon = np.diff(lon)
    wrap_idx = np.where(np.abs(dlon) > 180)[0]

    coords = [{'lon': float(lon[i]), 'lat': float(lat[i]), 'altitude': altitude} for i in range(n)]

    if len(wrap_idx) == 0:
        # No wrap, single segment
        return [coords]
    else:
        # Split at each wrap point into separate segments
        segments = []
        prev = 0
        for idx in wrap_idx:
            segments.append(coords[prev:idx + 1])
            prev = idx + 1
        segments.append(coords[prev:])
        # Close the ring: connect last segment back to first point
        segments[-1].append(coords[0])
        return segments
    

def add_ring(trajectories, inclination_deg, raan_deg, altitude, color):
    segments = make_ring(inclination_deg, raan_deg, altitude)
    for seg in segments:
        trajectories.append({
            'coords': seg,
            'color': color,
            'width': 2.5,
            'show_points': False,
        })

trajectories = []
add_ring(trajectories, inclination_deg=0,    raan_deg=0,  altitude=472, color='#cc44ff')
add_ring(trajectories, inclination_deg=51.6, raan_deg=0,  altitude=140, color='#00ffff')
add_ring(trajectories, inclination_deg=98,   raan_deg=60, altitude=225, color='#ffdd00')

# Research stations (scatter)
research_stations = [
    # Arctic
    {'lon': -51.7, 'lat': 64.2, 'size': 10, 'color': '#00ffff', 
     'border_color': '#000000', 'border_width': 2, 'label': 'Greenland Station'},
    {'lon': 11.9, 'lat': 78.9, 'size': 10, 'color': '#00ffff',
     'border_color': '#000000', 'border_width': 2, 'label': 'Svalbard Station'},
    {'lon': -133.5, 'lat': 68.3, 'size': 10, 'color': '#00ffff',
     'border_color': '#000000', 'border_width': 2, 'label': 'Alaska Station'},
    
    # Tropical
    {'lon': -3.0, 'lat': 0.0, 'size': 10, 'color': '#ffff00',
     'border_color': '#000000', 'border_width': 2, 'label': 'Atlantic Equator'},
    {'lon': 160.0, 'lat': 0.0, 'size': 10, 'color': '#ffff00',
     'border_color': '#000000', 'border_width': 2, 'label': 'Pacific Equator'},
    {'lon': 102.0, 'lat': 3.0, 'size': 10, 'color': '#ffff00',
     'border_color': '#000000', 'border_width': 2, 'label': 'Borneo Station'},
    
    # Antarctic
    {'lon': 0.0, 'lat': -75.0, 'size': 10, 'color': '#ffffff',
     'border_color': '#000000', 'border_width': 2, 'label': 'Antarctic Station 1'},
    {'lon': 166.7, 'lat': -77.8, 'size': 10, 'color': '#ffffff',
     'border_color': '#000000', 'border_width': 2, 'label': 'McMurdo Station'},
]

# Communication networks (lines)
data_links = [
    {
        'coords': [
            [-51.7, 64.2],    # Greenland
            [11.9, 78.9],     # Svalbard
            [-133.5, 68.3],   # Alaska
            [-51.7, 64.2],    # Back to Greenland
        ],
        'color': '#00ffff',
        'width': 2,
        'label': 'Arctic Network'
    },
    {
        'coords': [
            [-3.0, 0.0],      # Atlantic
            [102.0, 3.0],     # Borneo
            [160.0, 0.0],     # Pacific
        ],
        'color': '#ffff00',
        'width': 2,
        'label': 'Tropical Network'
    },
    {
        'coords': [
            [0.0, -75.0],     # Antarctic 1
            [166.7, -77.8],   # McMurdo
        ],
        'color': '#ffffff',
        'width': 2,
        'label': 'Antarctic Network'
    },
]

# Temperature anomaly bars
temperature_anomalies = [
    {'lon': -51.7, 'lat': 64.2, 'height': 220, 'width': 2, 'color': '#ff4444',
     'border_color': '#880000',  'label': 'Greenland: +2.2°C'},
    {'lon': 11.9, 'lat': 78.9, 'height': 260, 'width': 2, 'color': '#ff2222',
     'border_color': '#880000', 'label': 'Svalbard: +2.6°C'},
    {'lon': -133.5, 'lat': 68.3, 'height': 630, 'width': 2, 'color': '#ff3333',
     'border_color': '#880000',  'label': 'Alaska: +2.3°C'},
    
    {'lon': -3.0, 'lat': 0.0, 'height': 410, 'width': 2, 'color': '#ffaa00',
     'border_color': '#884400',  'label': 'Atlantic: +1.1°C'},
    {'lon': 160.0, 'lat': 0.0, 'height': 595, 'width': 2, 'color': '#ffcc00',
     'border_color': '#884400',  'label': 'Pacific: +0.95°C'},
    {'lon': 102.0, 'lat': 3.0, 'height': 430, 'width': 2, 'color': '#ff9900',
     'border_color': '#884400',  'label': 'Borneo: +1.3°C'},
    
    {'lon': 0.0, 'lat': -75.0, 'height': 190, 'width': 2, 'color': '#4444ff',
     'border_color': '#000088',  'label': 'Antarctic: +1.9°C'},
    {'lon': 166.7, 'lat': -77.8, 'height': 510, 'width': 2, 'color': '#3333ff',
     'border_color': '#000088', 'label': 'McMurdo: +2.1°C'},
]

sphere_combined = GriddedSphere(
    lons=lon_grid.flatten().tolist(),
    lats=lat_grid.flatten().tolist(),
    values=temperature.flatten().tolist(),
    n_lat=n_lat,
    n_lon=n_lon,
    palette='RdYlBu_r',
    width=950,
    height=950,
    rotation=-135,
    tilt=-35,
    zoom=0.7,
    autorotate=False,
    scatter_data=research_stations,
    line_data=data_links,
    bar_data=temperature_anomalies,
    show_colorbar=True,
    colorbar_title='Temperature (°C)',
    background_color='#0a0a1e',
    show_coastlines=True,
    coastline_color='#000000',
    coastline_width=0.5,
    show_countries=True,
    country_color='#000000',
    country_width=0.3,
    enable_lighting=True,
    light_azimuth=135,    # Light from upper right
    light_elevation=25,    # 45 degrees above horizon
    light_intensity=1.2,   # Strong directional light
    ambient_light=0,    
    trajectory_data=[trajectories[0],trajectories[1]], 
)

show(sphere_combined)

Interactive with CustomJS:


########## CUSTOMJS INTERACTIONS ##############



import numpy as np
from bokeh.plotting import figure, show
from bokeh.layouts import column, row
from bokeh.models import (
    Slider, Button, Div, CustomJS, ColumnDataSource, Range1d
)
from gridded_sphere_py import GriddedSphere

# ---------------------------------------------------------------------------
# Grid setup
# ---------------------------------------------------------------------------
n_lat = 100
n_lon = 100
lats = np.linspace(-90, 90, n_lat)
lons = np.linspace(-180, 180, n_lon)
lon_grid, lat_grid = np.meshgrid(lons, lats)

# ---------------------------------------------------------------------------
# 12 years of temperature data (2013–2024)
# ---------------------------------------------------------------------------
years = list(range(2013, 2025))
n_years = len(years)

all_values = []
yearly_mean = []

for i in range(n_years):
    base  = 30 - 40 * np.abs(np.sin(np.radians(lat_grid)))
    trend = 0.15 * i
    noise = 1.5 * i**2 *np.sin(np.radians(lat_grid * 1.3 + i * 37)) \
          * np.cos(np.radians(lon_grid * 0.8 + i * 53))
    temp  = base + trend + noise
    all_values.append(temp.flatten().tolist())
    yearly_mean.append(float(np.mean(temp)))

# ---------------------------------------------------------------------------
# Sphere with initial data
# ---------------------------------------------------------------------------
sphere = GriddedSphere(
    lons=lon_grid.flatten().tolist(),
    lats=lat_grid.flatten().tolist(),
    values=all_values[0],
    n_lat=n_lat,
    n_lon=n_lon,
    palette='Spectral',
    width=700,
    height=700,
    rotation=0,
    tilt=-23.5,
    zoom=0.8,
    autorotate=False,
    enable_lighting=True,
    light_azimuth=135,
    light_elevation=25,
    light_intensity=1.2,
    ambient_light=0.0,
    show_colorbar=True,
    colorbar_title=f'Temperature (°C) — {years[0]}',
    background_color='#0a0a0a',
    show_coastlines=True,
    coastline_color='#414141',
    coastline_width=0.9,
)

# Store all years of data in sphere.tags (THIS IS THE KEY!)
sphere.tags = all_values

# ---------------------------------------------------------------------------
# Timeseries plot
# ---------------------------------------------------------------------------
ts_source = ColumnDataSource(data={
    'x': [years[0]],
    'y': [yearly_mean[0]],
})

# Ghost dots (all years, faint)
ghost_source = ColumnDataSource(data={
    'x': years,
    'y': yearly_mean,
})

ts_fig = figure(
    width=700, height=200,
    x_range=Range1d(2012.5, 2024.5),
    y_range=Range1d(min(yearly_mean) - 0.6, max(yearly_mean) + 0.6),
    tools='',
    background_fill_color='#0a0a0a',
    border_fill_color='#0a0a0a',
)
ts_fig.xgrid.visible = False
ts_fig.ygrid.grid_line_color = '#222222'
ts_fig.axis.axis_label_text_color = '#aaaaaa'
ts_fig.axis.major_label_text_color = '#aaaaaa'
ts_fig.axis.axis_line_color = '#333333'
ts_fig.axis.major_tick_line_color = '#333333'
ts_fig.axis.minor_tick_line_color = None
ts_fig.axis.axis_label_text_font_size = '11px'
ts_fig.axis.major_label_text_font_size = '10px'

# Ghost points
ts_fig.circle('x', 'y', source=ghost_source, size=5, color='#2a2a2a', line_color=None)

# Animated line + active dots
ts_fig.line('x', 'y', source=ts_source, color='#ff6b6b', line_width=2.5)
ts_fig.circle('x', 'y', source=ts_source, size=7, color='#ff6b6b', line_color='#ff6b6b')

# ---------------------------------------------------------------------------
# Year label
# ---------------------------------------------------------------------------
year_label = Div(text=f"""
<div style="
    position: relative; 
    top: -400px;
    left: 50px;

    background: transparent !important;
">
    <div style="font-size: 2.2em; font-weight: 900; color: #fcf18d; margin-bottom:0.28em;">{years[0]}</div>
</div>
""")

# ---------------------------------------------------------------------------
# Slider
# ---------------------------------------------------------------------------
slider = Slider(
    start=0, end=n_years - 1, value=0, step=1,
    width=700, title='',
    bar_color='#ff6b6b',
)

# ---------------------------------------------------------------------------
# Play / Stop
# ---------------------------------------------------------------------------
play_btn = Button(label='▶  Play', button_type='default', width=110, height=36)
stop_btn = Button(label='⏹  Stop', button_type='default', width=110, height=36)

# ---------------------------------------------------------------------------
# CustomJS: slider change -> update sphere + timeseries + label
# FORCE UPDATE by changing colorbar_title which triggers re-render
# ---------------------------------------------------------------------------
update_cb = CustomJS(args=dict(
    sphere=sphere,
    ts_source=ts_source,
    year_label=year_label,
    slider=slider,
    yearly_mean=yearly_mean,
    years=years,
), code="""
    const idx = slider.value;
    
    // Access the data stored in sphere.tags
    const newValues = sphere.tags[idx];
    
    // Update sphere values
    sphere.values = newValues;
    
    // FORCE UPDATE: Change colorbar_title to trigger re-render
    sphere.colorbar_title = 'Temperature (°C) — ' + years[idx];
    
    // Alternative force update methods (try if above doesn't work):
    // Method 1: Toggle palette
    //const currentPalette = sphere.palette;
    //sphere.palette = 'Viridis256';
    //sphere.palette = currentPalette;
    
    // Method 2: Slightly adjust rotation
    sphere.rotation = sphere.rotation + 0.0000001;
    
    // Update timeseries: reveal points 0..idx
    const x = [];
    const y = [];
    for (let i = 0; i <= idx; i++) {
        x.push(years[i]);
        y.push(yearly_mean[i]);
    }
    ts_source.data = { x: x, y: y };
    
    // Update year label
    year_label.text = `
<div style="
    position: relative; 
    top: -400px;
    left: 50px;
    background: transparent !important;
">
    <div style="font-size: 2.2em; font-weight: 900; color: #fcf18d; margin-bottom:0.28em;">`+years[idx]+`</div>
</div>`
""")

slider.js_on_change('value', update_cb)

# ---------------------------------------------------------------------------
# Play: setInterval that increments slider.value (which fires the CB above)
# ---------------------------------------------------------------------------
play_cb = CustomJS(args=dict(slider=slider, n_years=n_years), code="""
    if (window._playInterval) clearInterval(window._playInterval);
    window._playInterval = setInterval(() => {
        let v = slider.value + 1;
        if (v >= """ + str(n_years) + """) v = 0;
        slider.value = v;
    }, 1500);
""")

stop_cb = CustomJS(code="""
    if (window._playInterval) {
        clearInterval(window._playInterval);
        window._playInterval = null;
    }
""")

play_btn.js_on_click(play_cb)
stop_btn.js_on_click(stop_cb)

# ---------------------------------------------------------------------------
# Button styling wrapper
# ---------------------------------------------------------------------------
btn_style = Div(text="""
<style>
  .bk-Button { background: #1a1a2e !important; border: 1px solid #444 !important;
    color: #fff !important; font-family: monospace !important; font-size: 14px !important;
    border-radius: 6px !important; cursor: pointer !important; transition: background 0.2s !important; }
  .bk-Button:hover { background: #2a2a4e !important; }
</style>
""", width=1, height=1)

# ---------------------------------------------------------------------------
# Layout
# ---------------------------------------------------------------------------
btn_row = row(play_btn, stop_btn, sizing_mode='fixed')

layout = column(
    btn_style,
    sphere,
    ts_fig,
    slider,
    btn_row,
    year_label,
    width=700,
)

show(layout)

Peek 2026-02-01 03-34

1 Like