Smooth Animation and Interactive 3D-like Sphere

Hi :dizzy: ,

A smooth animation and interaction of a sphere overlayed with custom gridded data.

The user can adjust the rotation speed, elevation, azimuth angle, and the colour palette. Heavy work has been done to handle the interactions with CustomJS and also to set the animated, fancy background (starry night and shooting stars!) with custom CSS.

To render the globe, just change the colour palette, and the sphere will come up.

Otherwise, enable this line at the end, and the Globe will automatically start in the beginning.

curdoc().js_on_event('document_ready', combined_callback)

Peek2026-01-2303-14-ezgif.com-optimize

sdswd1


import numpy as np
from bokeh.plotting import figure, show, output_file
from bokeh.models import (ColumnDataSource, CustomJS, Button, Slider, Select, Div)
from bokeh.layouts import column, row
from bokeh.palettes import Turbo256, Viridis256, Plasma256, Inferno256, Cividis256
import cartopy.feature as cfeature
from shapely.geometry import LineString, MultiLineString
import json
from bokeh.models import GlobalInlineStyleSheet
from bokeh.io import curdoc
from bokeh.io import show, curdoc
from bokeh.models import Slider, InlineStyleSheet
from bokeh.layouts import column
from matplotlib import cm
from matplotlib.colors import to_hex
#'RdBu_r'
def mbpal(sMpl):
    return [to_hex(cm.get_cmap(sMpl)(i/255)) for i in range(256)]

cool = mbpal('cool')
hot = mbpal('hot')
bwr = mbpal('bwr')
terrain = mbpal('terrain')
curdoc().theme = 'dark_minimal'

slider_style = InlineStyleSheet(css="""
/* Host: set the widget's container background */
:host {
  background: transparent !important;   /* even darker than black for modern dark UI */
  border-radius: 12px !important;
  padding: 12px !important;
  box-shadow: 0 4px 12px #0006 !important;
}
/* Slider title */
:host .bk-slider-title {
  color: #00ffe0 !important;     /* bright cyan for the title */
  font-size: 1.2em !important;
  font-weight: bold !important;
  letter-spacing: 1px !important;
  font-family: 'Fira Code', 'Consolas', 'Menlo', monospace !important;
  margin-bottom: 14px !important;
  text-shadow: 0 2px 12px #00ffe099;
}
/* Track (background) */
:host .noUi-base, :host .noUi-target {
  background: transparent !important;
    border: 1px solid transparent !important;

}
/* Filled portion */
:host .noUi-connect {
  background: linear-gradient(90deg, #00ffe0 10%, #d810f7 90%) !important;
  box-shadow: 0 0 12px #00ffe099;
  border-radius: 12px !important;
}
/* Handle */
:host .noUi-handle {
  background: #343838 !important;
  border: 2px solid #00ffe0 !important;
  border-radius: 50%;
  width: 20px;
  height: 20px;
}
/* Handle hover/focus */
:host .noUi-handle:hover, :host .noUi-handle:focus {
  border-color: #ff2a68 !important;
  box-shadow: 0 0 10px #ff2a6890;
}
/* Tooltip */
:host .noUi-tooltip {
  background: #343838 !important;
  color: #00ffe0 !important;
  font-family: 'Consolas', monospace;
  border-radius: 6px;
  border: 1px solid #00ffe0;
}
""")

select_css = InlineStyleSheet(css="""
/* Widget container */
:host {
    background:transparent !important;
    border-radius: 14px !important;
    padding: 16px !important;
    box-shadow: 0 4px 24px #0007 !important;
}
/* Title styling */
:host .bk-input-group label, :host .bk-select-title {
    color: #06f0ff !important;
    font-size: 1.18em !important;
    font-family: 'Fira Code', monospace;
    font-weight: bold !important;
    margin-bottom: 12px !important;
    letter-spacing: 1px !important;
    text-shadow: 0 2px 12px #06f0ff88, 0 1px 6px #111b;
}
/* Dropdown select */
:host select {
    background-color: transparent !important;
    color: #ffdb39 !important;
    border: 2px solid #06b6d4 !important;
    appearance: none;
    -webkit-appearance: none;
    -moz-appearance: none;
}

:host select option {
    background-color: #16161e !important; /* fallback */
    color: #ffdb39 !important;
}
/* Glow effect on hover/focus */
:host select:hover, :host select:focus {
    border-color: #ff3049 !important;
    box-shadow: 0 0 0 2px #ff304999, 0 0 18px #ff3049cc !important;
    outline: none !important;
}

""")


from bokeh.models import GlobalInlineStyleSheet

def get_starfield_stylesheet():
    return GlobalInlineStyleSheet(css="""
    /* RESET & BASE */
    * { margin: 0; padding:0; box-sizing: border-box; }
    html, body {
        width: 100%;
        height: 100%;
        overflow: hidden;
        position: relative;
        background: radial-gradient(ellipse at bottom, #343838 0%, #130d0d 100%);
    }
    body { background: transparent !important; }

    /* ANIMATED BACKGROUND IMAGE */
    section{
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        background: url("https://images.unsplash.com/photo-1528818955841-a7f1425131b5?fm=jpg&q=60&w=3000&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxzZWFyY2h8NHx8c3RhcnJ5JTIwc2t5fGVufDB8fDB8fHww");
        background-size: cover;
        animation: animateBg 50s linear infinite;
        z-index: -3;
    }
    @keyframes animateBg{
        0%,100%{ transform: scale(1); }
        50%{ transform: scale(1.2); }
    }

    /* SMALL STARS (MANY) */
    html::before {
        content: "";
        position: fixed;
        inset: 0;
        background-image:
            radial-gradient(1px 1px at 5% 10%, white, transparent),
            radial-gradient(1px 1px at 15% 80%, white, transparent),
            radial-gradient(1px 1px at 25% 30%, white, transparent),
            radial-gradient(1px 1px at 40% 60%, white, transparent),
            radial-gradient(1px 1px at 55% 20%, white, transparent),
            radial-gradient(1px 1px at 70% 90%, white, transparent),
            radial-gradient(1px 1px at 85% 40%, white, transparent),
            radial-gradient(1px 1px at 95% 70%, white, transparent);
        background-repeat: repeat;
        background-size: 100px 100px;
        animation: twinkleSmall 6s infinite alternate;
        pointer-events: none;
        z-index: -2;
    }

    /* BIGGER STARS (FEWER) */
    html::after {
        content: "";
        position: fixed;
        inset: 0;
        background-image:
            radial-gradient(2px 2px at 20% 40%, white, transparent),
            radial-gradient(2.5px 2.5px at 60% 70%, white, transparent),
            radial-gradient(3px 3px at 80% 20%, white, transparent);
        background-repeat: repeat;
        background-size: 200px 200px;
        animation: twinkleBig 10s infinite alternate;
        pointer-events: none;
        z-index: -2;
    }

    /* SHOOTING STAR */
    span {
        position: absolute;
        top:50%;
        left:50%;
        width: 4px;
        height: 4px;
        background: yellow;
        border-radius: 50%;
        box-shadow: 0 0 0 4px rgba(242, 255, 59, 0.1).1), 0 0 0 8px rgba(251, 255, 25, 0.1), 0 0 20px rgba(255,255,255,0.1);
        animation: shooting 45s linear infinite;
        pointer-events: none;
        z-index: -2;
    }
                                  
                                  
    span::before{
        content:'';
        position: absolute;
        top: 50%;
        transform: translateY(-50%);
        width: 300px;
        height: 1px;
        background: linear-gradient(90deg,yellow,transparent);
    }
@keyframes shooting {
    0% {
        transform: rotate(315deg) translateX(0);
        opacity: 0;
    }

    1% {
        opacity: 1;
    }

    /* FAST shooting (first ~1 second) */
    3% {
        transform: rotate(315deg) translateX(-1000px);
        opacity: 0;
    }

    /* WAIT (invisible) */
    100% {
        transform: rotate(315deg) translateX(-1000px);
        opacity: 0;
    }
}


span:nth-child(1){
    top: 300px; background: aqua;
    right: 900px;
    left: initial;
   animation: shooting 60s linear infinite;
}
                                  
span:nth-of-type(1)::before {
    background: linear-gradient(90deg, aqua, transparent);}
                                  
span:nth-child(2){
    top: 0px; background: red;
    right: 800px;
    left: initial;animation-duration: 11.75s;
animation: shooting 211s linear infinite;
}

span:nth-of-type(2)::before {
    background: linear-gradient(90deg, red, transparent);}
                                  
span:nth-child(3){
    top: 0px; background: lime;
    right: 1450px;
    left: initial;
                                  animation: shooting 130s linear infinite;
}
span:nth-of-type(3)::before {
    background: linear-gradient(90deg, lime, transparent);}
    /* SMALL STAR TWINKLE */
    @keyframes twinkleSmall { from { opacity: 0.3; } to { opacity: 0.9; } }
    @keyframes twinkleBig   { from { opacity: 0.2; } to { opacity: 0.7; } }

    /* Bokeh above all stars */
    .bk-root {
        position: relative;
        z-index: 1;
        background: transparent !important;
    }

    /* UI text */
    .bk, .bk-input, .bk-btn, .bk-select, .bk-slider-title,
    .bk-title, .bk-label, .bk-legend, .bk-axis-label {
        color: white !important;
    }
    """)

# ============================================================================
# 1. GENERATE SPHERE GEOMETRY
# ============================================================================

def generate_sphere_mesh(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)
    
    return {
        'lons': lon_grid.flatten().tolist(),
        'lats': lat_grid.flatten().tolist(),
        'n_lat': n_lat,
        'n_lon': n_lon
    }

# ============================================================================
# 2. GENERATE TEMPERATURE DATA
# ============================================================================

def generate_temperature_field(lats, lons):
    lats = np.array(lats)
    lons = np.array(lons)
    
    base_temp = 30 - 50 * np.abs(lats) / 90
    wave1 = 10 * np.sin(np.radians(lons) * 3) * np.cos(np.radians(lats) * 2)
    wave2 = 8 * np.cos(np.radians(lons) * 2 + np.radians(lats))
    
    np.random.seed(42)
    noise = np.random.randn(len(lats)) * 3
    
    temps = base_temp + wave1 + wave2 + noise
    return temps.tolist()

# ============================================================================
# 3. EXTRACT COASTLINES
# ============================================================================

def extract_coastlines():
    coastlines = cfeature.NaturalEarthFeature('physical', 'coastline', '110m')
    
    coast_lons = []
    coast_lats = []
    
    for geom in coastlines.geometries():
        if isinstance(geom, LineString):
            coords = np.array(geom.coords)
            coast_lons.extend(coords[:, 0].tolist() + [None])
            coast_lats.extend(coords[:, 1].tolist() + [None])
        elif isinstance(geom, MultiLineString):
            for line in geom.geoms:
                coords = np.array(line.coords)
                coast_lons.extend(coords[:, 0].tolist() + [None])
                coast_lats.extend(coords[:, 1].tolist() + [None])
    
    return coast_lons, coast_lats

# ============================================================================
# 4. PREPARE DATA
# ============================================================================

sphere_data = generate_sphere_mesh(n_lat=30, n_lon=60)
temps = generate_temperature_field(sphere_data['lats'], sphere_data['lons'])
coast_lons, coast_lats = extract_coastlines()


raw_sphere_source = ColumnDataSource(data={
    'lons': sphere_data['lons'],
    'lats': sphere_data['lats'],
    'temps': temps
})

quad_source = ColumnDataSource(data={
    'xs': [[]],
    'ys': [[]],
    'fill_color': ['#440154']
})

coast_raw_source = ColumnDataSource(data={
    'lons': coast_lons,
    'lats': coast_lats
})

coast_render_source = ColumnDataSource(data={
    'x': [],
    'y': []
})

# ============================================================================
# 5. CREATE FIGURE
# ============================================================================

plot = figure(
    width=800, height=800,
    # title="🌍 Auto-Rotating Temperature Globe",
    toolbar_location=None,
    match_aspect=True,
    x_range=(-1.5, 1.5),
    y_range=(-1.5, 1.5)
)

plot.patches(
    xs='xs', ys='ys',
    source=quad_source,
    fill_color='fill_color',
    fill_alpha=1,
    line_color='fill_color',
    line_alpha = 1,
    line_width=1.1,


)

plot.multi_line(
    xs='x', ys='y',
    source=coast_render_source,
    line_color='black',
    line_width=1.2,
    line_alpha=1
)

plot.xaxis.visible = False
plot.yaxis.visible = False
plot.grid.visible = False
plot.background_fill_color = "#0a0a0a"
plot.border_fill_color = "#0a0a0a"
plot.background_fill_alpha = 0
plot.border_fill_alpha = 0
plot.border_line_alpha = 0
plot.outline_line_color = None

# ============================================================================
# 6. CONTROLS
# ============================================================================

play_button = Button(label="⏸ Pause", button_type="warning", width=100)
reset_button = Button(label="🔄 Reset", button_type="primary", width=100)

speed_slider = Slider(
    start=0, end=3, value=1, step=0.1,
    title="Rotation Speed",
    width=250, stylesheets=[slider_style],height=80, styles={"margin-top": "-70px"}
)

tilt_slider = Slider(
    start=-90, end=90, value=0, step=5,
    title="Elevation (°)",
    width=250, stylesheets=[slider_style],height=80, styles={"margin-top": "10px"}
)

azimuth_slider = Slider(
    start=-180, end=180, value=0, step=10,
    title="Azimuth (°)",
    width=250, stylesheets=[slider_style],height=80, styles={"margin-top": "10px"}
)

palette_select = Select(
    title="Color Palette:",
    value="Turbo256",
    width=180,
    options=["Turbo256", "Viridis256", "Plasma256", "Inferno256", "Cividis256","cool", "hot", "bwr", "terrain"], stylesheets=[select_css],height=80, styles={"margin-top": "-70px"}
)

angle_display = Div(
    text="<div style='padding:10px; background:#1a1a1a; color:#00ff00; "
         "font-family:monospace; border-radius:5px; text-align:center;'>"
         "<b>Rotation:</b> 180° | <b>Tilt:</b> 0° | <b>Az:</b> 0° | "
         "<span style='color:#ff6b35;'>⚡ PLAYING</span></div>",
    width=350
)


# ============================================================================
# 7. MAIN RENDERING AND ANIMATION CALLBACK
# ============================================================================

combined_callback = CustomJS(
    args=dict(
        raw_sphere=raw_sphere_source,
        quad_source=quad_source,
        coast_raw=coast_raw_source,
        coast_render=coast_render_source,
        n_lat=sphere_data['n_lat'],
        n_lon=sphere_data['n_lon'],
        palette_turbo=json.dumps(Turbo256),
        palette_viridis=json.dumps(Viridis256),
        palette_plasma=json.dumps(Plasma256),
        palette_inferno=json.dumps(Inferno256),
        palette_cividis=json.dumps(Cividis256),
        palette_cool=json.dumps(cool),
        palette_hot=json.dumps(hot),
        palette_bwr=json.dumps(bwr),
        palette_terrain=json.dumps(terrain),

        palette_select=palette_select,
        angle_display=angle_display,
        speed_slider=speed_slider,
        tilt_slider=tilt_slider,
        azimuth_slider=azimuth_slider,
        play_button=play_button,
        reset_button=reset_button
    ),
    code="""
    // ========================================================================
    // INITIALIZE ANIMATION STATE (runs once)
    // ========================================================================
    if (!window.globe_animation) {
        window.globe_animation = {
            is_playing: true,
            current_angle: 180,
            animation_id: null,
            initialized: false
        };
    }
    
    const anim = window.globe_animation;
    
    // ========================================================================
    // RENDER FUNCTION - Projects and draws the globe
    // ========================================================================
    function renderGlobe(angle_deg) {
        const angle_rad = -angle_deg * Math.PI / 180;  // REVERSED ROTATION!
        const tilt_rad = tilt_slider.value * Math.PI / 180;
        const azimuth_rad = azimuth_slider.value * Math.PI / 180;
        
        // Get palette
        const palettes = {
            'Turbo256': JSON.parse(palette_turbo),
            'Viridis256': JSON.parse(palette_viridis),
            'Plasma256': JSON.parse(palette_plasma),
            'Inferno256': JSON.parse(palette_inferno),
            'Cividis256': JSON.parse(palette_cividis),
            'cool': JSON.parse(palette_cool),
            'hot': JSON.parse(palette_hot),
            'bwr': JSON.parse(palette_bwr),

            'terrain': JSON.parse(palette_terrain)
        };
        const palette = palettes[palette_select.value];
        
        // Update angle display with status
        const status = anim.is_playing ? 
            '<span style="color:#ff6b35;">⚡ PLAYING</span>' : 
            '<span style="color:#888;">⏸ PAUSED</span>';
        angle_display.text = `<div style='padding:10px; background:#1a1a1a; color:#00ff00; 
                              font-family:monospace; border-radius:5px; text-align:center;'>
                              <b>Rot:</b> ${Math.round(angle_deg)}° | <b>Tilt:</b> ${tilt_slider.value}° | 
                              <b>Az:</b> ${azimuth_slider.value}° | ${status}</div>`;
        
        // Get data
        const lons = raw_sphere.data['lons'];
        const lats = raw_sphere.data['lats'];
        const temps = raw_sphere.data['temps'];
        
        // Project sphere points with full 3D rotation
        const x2d = new Array(lons.length);
        const y2d = new Array(lons.length);
        const y3d = new Array(lons.length);
        const visible = new Array(lons.length);
        
        const cos_angle = Math.cos(angle_rad);
        const sin_angle = Math.sin(angle_rad);
        const cos_tilt = Math.cos(tilt_rad);
        const sin_tilt = Math.sin(tilt_rad);
        const cos_azimuth = Math.cos(azimuth_rad);
        const sin_azimuth = Math.sin(azimuth_rad);
        
        for (let i = 0; i < lons.length; i++) {
            const lat_rad = lats[i] * Math.PI / 180;
            const lon_rad = lons[i] * Math.PI / 180;
            
            const x = Math.cos(lat_rad) * Math.cos(-lon_rad);
            const y = Math.cos(lat_rad) * Math.sin(-lon_rad);
            const z = Math.sin(lat_rad);
            
            // Rotation around Z-axis (spin) - REVERSED
            let x_rot = x * cos_angle - y * sin_angle;
            let y_rot = x * sin_angle + y * cos_angle;
            let z_rot = z;
            
            // Tilt around X-axis (elevation)
            const y_tilt = y_rot * cos_tilt - z_rot * sin_tilt;
            const z_tilt = y_rot * sin_tilt + z_rot * cos_tilt;
            
            // Azimuth rotation around Z-axis (viewing angle)
            const x_final = x_rot * cos_azimuth - y_tilt * sin_azimuth;
            const y_final = x_rot * sin_azimuth + y_tilt * cos_azimuth;
            const z_final = z_tilt;
            
            x2d[i] = x_final;
            y2d[i] = z_final;
            y3d[i] = y_final;
            visible[i] = y_final > -0.15;
        }
        
        // Create quads
        const temp_min = Math.min(...temps);
        const temp_max = Math.max(...temps);
        const quads = [];
        
        for (let i = 0; i < n_lat - 1; i++) {
            for (let j = 0; j < n_lon - 1; j++) {
                const idx0 = i * n_lon + j;
                const idx1 = i * n_lon + (j + 1);
                const idx2 = (i + 1) * n_lon + (j + 1);
                const idx3 = (i + 1) * n_lon + j;
                
                if (visible[idx0] || visible[idx1] || visible[idx2] || visible[idx3]) {
                    const xs = [x2d[idx0], x2d[idx1], x2d[idx2], x2d[idx3]];
                    const ys = [y2d[idx0], y2d[idx1], y2d[idx2], y2d[idx3]];
                    
                    const avg_temp = (temps[idx0] + temps[idx1] + temps[idx2] + temps[idx3]) / 4;
                    const depth = (y3d[idx0] + y3d[idx1] + y3d[idx2] + y3d[idx3]) / 4;
                    
                    let color_idx = 0;
                    if (temp_max > temp_min) {
                        color_idx = Math.floor((avg_temp - temp_min) / (temp_max - temp_min) * 255);
                        color_idx = Math.max(0, Math.min(255, color_idx));
                    }
                    
                    quads.push({
                        depth: depth,
                        xs: xs,
                        ys: ys,
                        color: palette[color_idx]
                    });
                }
            }
        }
        
        quads.sort((a, b) => a.depth - b.depth);
        
        quad_source.data['xs'] = quads.map(q => q.xs);
        quad_source.data['ys'] = quads.map(q => q.ys);
        quad_source.data['fill_color'] = quads.map(q => q.color);
        
        // Render coastlines - HIDE THOSE BEHIND THE SPHERE!
        const coast_lons = coast_raw.data['lons'];
        const coast_lats = coast_raw.data['lats'];
        
        const coast_x_all = [];
        const coast_y_all = [];
        let current_line_x = [];
        let current_line_y = [];
        
        for (let i = 0; i < coast_lons.length; i++) {
            if (coast_lons[i] === null || coast_lats[i] === null) {
                if (current_line_x.length > 0) {
                    coast_x_all.push(current_line_x);
                    coast_y_all.push(current_line_y);
                    current_line_x = [];
                    current_line_y = [];
                }
            } else {
                const lat_rad = coast_lats[i] * Math.PI / 180;
                const lon_rad = coast_lons[i] * Math.PI / 180;
                
                const x = Math.cos(lat_rad) * Math.cos(-lon_rad);
                const y = Math.cos(lat_rad) * Math.sin(-lon_rad);
                const z = Math.sin(lat_rad);
                
                // Rotation around Z-axis - REVERSED
                let x_rot = x * cos_angle - y * sin_angle;
                let y_rot = x * sin_angle + y * cos_angle;
                let z_rot = z;
                
                // Tilt around X-axis
                const y_tilt = y_rot * cos_tilt - z_rot * sin_tilt;
                const z_tilt = y_rot * sin_tilt + z_rot * cos_tilt;
                
                // Azimuth rotation
                const x_final = x_rot * cos_azimuth - y_tilt * sin_azimuth;
                const y_final = x_rot * sin_azimuth + y_tilt * cos_azimuth;
                const z_final = z_tilt;
                
                // ONLY draw visible coastlines (front of sphere)
                if (y_final > -0.05) {
                    current_line_x.push(x_final);
                    current_line_y.push(z_final);
                } else if (current_line_x.length > 0) {
                    // Break line when going behind
                    coast_x_all.push(current_line_x);
                    coast_y_all.push(current_line_y);
                    current_line_x = [];
                    current_line_y = [];
                }
            }
        }
        
        if (current_line_x.length > 0) {
            coast_x_all.push(current_line_x);
            coast_y_all.push(current_line_y);
        }
        
        coast_render.data['x'] = coast_x_all;
        coast_render.data['y'] = coast_y_all;
        
        quad_source.change.emit();
        coast_render.change.emit();
    }
    
    // ========================================================================
    // ANIMATION LOOP - Called every frame
    // ========================================================================
    function animate() {
        if (!anim.is_playing) return;
        
        anim.current_angle += speed_slider.value * 0.5;
        if (anim.current_angle >= 360) {
            anim.current_angle -= 360;
        }
        
        renderGlobe(anim.current_angle);
        anim.animation_id = requestAnimationFrame(animate);
    }
    
    // ========================================================================
    // HANDLE INTERACTIONS - SIMPLIFIED LOGIC
    // ========================================================================
    
    // Check which button was clicked
    const is_play_click = (cb_obj.id === play_button.id);
    const is_reset_click = (cb_obj.id === reset_button.id);
    
    if (is_play_click) {
        // Toggle play/pause
        anim.is_playing = !anim.is_playing;
        
        if (anim.is_playing) {
            play_button.label = '⏸ Pause';
            play_button.button_type = 'warning';
            animate();
        } else {
            if (anim.animation_id) {
                cancelAnimationFrame(anim.animation_id);
            }
            play_button.label = '▶ Play';
            play_button.button_type = 'success';
        }
        renderGlobe(anim.current_angle);
    } 
    
    if (is_reset_click) {
        anim.current_angle = 180;
        tilt_slider.value = 0;
        azimuth_slider.value = 0;
        renderGlobe(180);
    }
    
    // Handle slider changes
    if (cb_obj === palette_select || cb_obj === tilt_slider || cb_obj === azimuth_slider) {
        renderGlobe(anim.current_angle);
    }
    
    // ========================================================================
    // INITIAL RENDER AND AUTO-START
    // ========================================================================
    if (!anim.initialized) {
        anim.initialized = true;
        renderGlobe(180);
        
        // Auto-start animation after initial render
        setTimeout(function() {
            anim.is_playing = true;
            animate();
        }, 100);
    }
    """
)


# Attach callbacks - CHANGED TO ON_EVENT FOR BUTTONS
play_button.js_on_event('button_click', combined_callback)
reset_button.js_on_event('button_click', combined_callback)
palette_select.js_on_change('value', combined_callback)
tilt_slider.js_on_change('value', combined_callback)
azimuth_slider.js_on_change('value', combined_callback)



# ============================================================================
# 8. LAYOUT
# ============================================================================

controls_row1 = row(   
    # play_button,
    # reset_button,
    speed_slider,
    palette_select,
)

controls_row2 = row(
    tilt_slider,
    azimuth_slider
)

layout = column(
    plot,
    controls_row1,
    controls_row2, stylesheets=[get_starfield_stylesheet()]
)

shooting_stars_js = """
(function() {
    if (window.starsLoaded) return;
    window.starsLoaded = true;

    const section = document.createElement("section");
    document.body.appendChild(section);

    // Create 10 shooting stars
    for (let i = 0; i < 10; i++) {
        const star = document.createElement("span");
        section.appendChild(star);
    }
})();
"""

from bokeh.models import CustomJS
from bokeh.events import DocumentReady
curdoc().js_on_event(DocumentReady, CustomJS(code=shooting_stars_js))
# curdoc().js_on_event('document_ready', combined_callback)

output_file("interactive_globe.html")
show(layout)

2 Likes

Honestly blown away by how quckly you are generating all these completely novel and exciting demonstrations. You obviously have a very high level of proficiency dealing with geo projections and transformations. I’d say this is an area where the project and team is a bit lacking.

I’d really love to hear your thoughts about what the most important “geo” features that could be added are, and how best to expose them? I’d say in the ideal case transformations happen in BokehJS, otherwise you end up with either requiring a Bokeh server, or not supporting interactive changes much.

1 Like

Thanks @Bryan !

The biggest pain point: I’m stuck choosing between Cartopy (static) or writing projection math in CustomJS (my renderGlobe() function with manual spherical transforms, rotation matrices, and depth sorting).

What would fix this:

1. Projection system in BokehJS:

proj = OrthographicProjection(central_longitude=0, central_latitude=20)
p = figure(projection=proj)
p.geo_patches('lons', 'lats', source=geo_source)  # accepts lat/lon directly

The projection handles forward transforms, visibility culling, and depth sorting.

2. Built-in geodata accessible in JS:

Right now I extract coastlines from Cartopy, serialize to JSON, pass to CustomJS. Instead:

p.add_coastlines()  # or p.add_countries(), p.add_rivers()

Load from CDN or bundle Natural Earth data client-side so it’s available without Python preprocessing.

3. Interactive projection parameters:

lon_slider.js_link('value', proj, 'central_longitude')
azimuth_slider.js_link('value', proj, 'azimuth')
tilt_slider.js_link('value', proj, 'tilt')

My globe has rotation/tilt/azimuth sliders - these should be first-class projection properties, not manual CustomJS.

4. Dynamic color mapping:

ColorMapper should work with geo glyphs:

color_mapper = LinearColorMapper(palette=Viridis256, low=0, high=100)
p.geo_patches(..., fill_color={'field': 'population', 'transform': color_mapper})

Right now for my globe I’m manually mapping temperature values to palette indices in JavaScript - should be automatic.

d3-geo already has some implementations - coordinate transforms, adaptive resampling, clipping at projection boundaries. Wrapping it avoids reimplementing complex spherical math. Priority: Orthographic (interactive globes), Mercator (web maps), Robinson/Equal Earth (choropleth).

Adding 3D-lines, points and bars - all inside customJS.

Peek2026-01-2800-27-ezgif.com-optimize

The code is huge, so better find it here. Remember to change the color palette to render the globe.

@mixstam1453

I am not sure if you are aware of the implementation which are very similar to your suggested functions

p.add_coastlines() # or p.add_countries(), p.add_rivers()

named

p.coastlines() # p.borders(), p.rivers()

or available in the geo feature module

p = add_coastlines(p) # add_borders(p), add_rivers(p)

In PR 14706 I added some functions to create world projections using Cartopy. This is very much inspired by your suggested changes from PR 14258. The code is available in the 3.9 branch and also documented for the upcoming release.
For example see World Projections in the documentation for bokeh 3.9.

Maybe this will already simplify the coding for you. I also want to thank you for your great showcases and posted code.

1 Like

Thanks for pointing that out! Now I remember, but I forgot about it when building these extensions. :sweat_smile:
The p.add_coastlines() API is definitely cleaner than what I’m doing with the manual data loading. I should have revisited the 3.9 docs before diving in.

Great job!!

1 Like