BokehJS Support for Interactive 3D Globe with choropleth data

A custom BokehJS extension for interactive 3D choropleth maps on a rotating globe. Visualize country-level data (population, GDP, emissions, etc.) with GeoJSON support, customizable discrete color bins, and multi-layer overlays. Features drag-to-rotate, auto-rotation, hover highlighting with tooltips, optional Phong lighting, and support for scatter points, 3D bars, flight routes, and satellite trajectories.

:white_check_mark: GeoJSON choropleth rendering on 3D sphere
:white_check_mark: Custom discrete bins with user-defined colors & labels
:white_check_mark: Separate ocean/land/background colors
:white_check_mark: Hover highlighting with country name & value tooltips
:white_check_mark: Drag rotation/tilt, scroll zoom, auto-rotation
:white_check_mark: Multi-layer overlays: scatter, 3D bars, lines, trajectories
:white_check_mark: Optional Phong lighting for realistic shading
:white_check_mark: Coastlines & country borders (optional)

Free and open source code is here:

Simply download the folder, install bokeh numpy pandas requests and xarray, and run the several examples.

Peek 2026-02-02 22-04

from choropleth_sphere_py import ChoroplethSphere
from bokeh.plotting import show, output_file
from bokeh.plotting import show, output_file
import requests
import json
# Load world countries GeoJSON
url = "https://raw.githubusercontent.com/python-visualization/folium/master/examples/data/world-countries.json"
world_geo = requests.get(url).json()
from population_data import population_data

# Name mapping for countries with different names in GeoJSON
name_mapping = {
    "United States of America": "United States of America",
    # "Russia": "Russian Federation",
    # "South Korea": "Korea, Republic of",
    # "North Korea": "Dem. Rep. Korea",
    "Serbia": "Republic of Serbia",
    "Dem. Rep. Congo": "Democratic Republic of the Congo",
    "Congo": "Republic of the Congo",
    "Côte d'Ivoire": "Ivory Coast",
    "Tanzania": "United Republic of Tanzania",
}

# Apply name mapping
mapped_data = {}
for key, value in population_data.items():
    mapped_key = name_mapping.get(key, key)
    mapped_data[mapped_key] = value



geojson = {
    "type": "FeatureCollection",
    "features": []
}

for feature in world_geo["features"]:
    name = feature["properties"].get("name")

    # match population
    if name in mapped_data:
        new_feature = {
            "type": "Feature",
            "geometry": feature["geometry"],   # KEEP LAND SHAPE
            "properties": {
                "name": name,
                "value": mapped_data[name]     # ADD DATA
            }
        }
        geojson["features"].append(new_feature)

sphere3 = ChoroplethSphere(
    width=800,
    height=600,
    choropleth_data=geojson,
    choropleth_value_field="value",
    show_colorbar=True,
    colorbar_title="GDP per Capita ($)",
    custom_bins=[0.0, 5_000_000.0, 100_000_000.0, 200_000_000.0, 500_000_000.0, 1_500_000_000.0],
    custom_bin_colors=[
        "#d73027",  # Dark red - lowest GDP (0-5000)
        "#fc8d59",  # Orange-red (5000-15000)
        "#fee090",  # Light yellow (15000-30000)
        "#91cf60",  # Light green (30000-50000)
        "#1a9850",  # Dark green - highest GDP (50000-100000)
    ],
)
show(sphere3)


import random

def random_color():
    return "#{:02x}{:02x}{:02x}".format(
        random.randint(0, 255),
        random.randint(0, 255),
        random.randint(0, 255),
    )

scatter_data = [
    {
        "lon": random.uniform(-180, 180),
        "lat": random.uniform(-75, 75),   # avoid poles (better visuals)
        "size": random.randint(1, 20),
        "color": random_color(),
        "label": f"Point {i+1}"
    }
    for i in range(50)
]



sphere3 = ChoroplethSphere(
    width=800,
    height=600,
    choropleth_data=geojson,
    choropleth_value_field="value",
    show_colorbar=False,
    colorbar_title="GDP per Capita ($)",
    scatter_data=scatter_data,

    # Define custom bins for GDP categories
    custom_bins=[0.0,],
    enable_hover=False

)

show(sphere3)

CustomJS interaction:

Peek 2026-02-01 04-22

import json
import random
from bokeh.plotting import output_file, show
from bokeh.layouts import column
from bokeh.models import CustomJS, Slider, Button
from bokeh.models import Div

from choropleth_sphere_py import ChoroplethSphere

def example():
    """Animate data over time with a time slider"""
    
    with open('countries_tiny.geojson', 'r') as f:
        geojson = json.load(f)
    
    # Create time series data (e.g., CO2 emissions 2000-2023)
    years = list(range(2000, 2024))
    
    # Initialize with first year
    for feature in geojson['features']:
        feature['properties']['emissions'] = random.uniform(0, 10000)
    
    # Create sphere
    sphere = ChoroplethSphere(
        choropleth_data=geojson,
        choropleth_value_field="emissions",
        choropleth_palette="RdYlGn",  # Red = high, Green = low
        background_color="#0a0a0a",
        ocean_color="#1e3a5f",
        land_color="#2d2d2d",
        width=1000,
        height=600,
        show_colorbar=True,
        colorbar_title="CO2 Emissions (Mt)",
        enable_hover=True,
        autorotate=True,
        rotation_speed=0.3
    )
    
    # Year slider
    year_slider = Slider(start=2000, end=2023, value=2000, step=1, title="Year")
    
    # Store all years data in CustomJS
    year_callback = CustomJS(args=dict(sphere=sphere), code="""
        // Simulate time series data
        const year = cb_obj.value;
        const features = sphere.choropleth_data.features;
        
        // Update each country's value based on year
        for (let i = 0; i < features.length; i++) {
            const base = Math.random() * 10000;
            const growth = (year - 2000) * 100;
            const noise = (Math.random() - 0.5) * 1000;
            features[i].properties.emissions = base + growth + noise;
        }
        
        // Trigger update
        sphere.choropleth_data = {...sphere.choropleth_data};
    """)
    
    year_slider.js_on_change('value', year_callback)
    
    # Play button for animation
    play_button = Button(label="▶ Play Animation", button_type="success")
    
    play_callback = CustomJS(args=dict(slider=year_slider, button=play_button), code="""
        if (button.label.startsWith("▶")) {
            button.label = "⏸ Pause";
            button.button_type = "warning";
            
            const interval = setInterval(function() {
                if (slider.value >= slider.end) {
                    slider.value = slider.start;
                } else {
                    slider.value += 1;
                }
                
                if (button.label.startsWith("▶")) {
                    clearInterval(interval);
                }
            }, 500);  // Update every 500ms
            
            // Store interval ID
            window.animationInterval = interval;
        } else {
            button.label = "▶ Play Animation";
            button.button_type = "success";
            clearInterval(window.animationInterval);
        }
    """)
    
    play_button.js_on_click(play_callback)
    
    # Info display
    year_display = Div(text=f"<h2 style='text-align: center;'>Year: 2000</h2>")
    
    display_callback = CustomJS(args=dict(div=year_display), code="""
        div.text = "<h2 style='text-align: center;'>Year: " + cb_obj.value + "</h2>";
    """)
    year_slider.js_on_change('value', display_callback)
    
    # Layout
    controls = column(
        Div(text="<h3>Time Series Animation</h3>"),
        year_display,
        year_slider,
        play_button
    )
    
    layout = column(sphere, controls)
    
    output_file("example.html")
    show(layout)
example()
1 Like