Rotating the Sphere Automatically or Manually!

Hello :slight_smile:,
Here is a try for rotating sphere. Please uncomment and use the random xarray data, which is coarser (lower resolution) than the real temperature data that I use from the .nc file. This optimises a lot the update time.

Peek 2025-07-06 19-14

import xarray as xr 
import numpy as np
import cartopy.crs as ccrs
import cartopy.feature as cfeature
from bokeh.plotting import figure, curdoc
from bokeh.models import ColorBar, LinearColorMapper, BasicTicker, HoverTool, ColumnDataSource,Div, GlobalInlineStyleSheet

from bokeh.layouts import column
from shapely.geometry import LineString

from matplotlib import cm
from matplotlib.colors import to_hex
curdoc().theme = 'dark_minimal'
gstyle = GlobalInlineStyleSheet(css=""" html, body, .bk, .bk-root {background-color: #15191c; margin: 0; padding: 0; height: 100%; color: white; font-family: 'Consolas', 'Courier New', monospace; } .bk { color: white; } .bk-input, .bk-btn, .bk-select, .bk-slider-title, .bk-headers, .bk-label, .bk-title, .bk-legend, .bk-axis-label { color: white !important; } .bk-input::placeholder { color: #aaaaaa !important; } """)



# === Load and process data ===
ds = xr.open_dataset('/home/michael/Downloads/ee574e584b1f8351c52f63525a06f50d.nc')['t2m']

yearly = ds.groupby('time.year').mean('time')
anomyearmean = yearly.sel(year=slice(2024,2024)).mean('year')-yearly.mean('year')

lon = yearly.lon.values
lat = yearly.lat.values
LON, LAT = np.meshgrid(lon, lat)
temperature = anomyearmean.values

#### OR #####

# # use this data instead
# #>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
# # --- Set random seed for reproducibility
# np.random.seed(42)

# # --- Define grid dimensions
# n_lon = 144  # e.g. every 2.5 degrees: 360/2.5 = 144
# n_lat = 73   # e.g. every 2.5 degrees: 180/2.5 = 72 (+1 for pole)
# n_years = 46  # e.g. years 1979-2024

# # --- Generate lat/lon values
# lon = np.linspace(-180, 180, n_lon, endpoint=False)
# lat = np.linspace(-90, 90, n_lat)

# years = np.arange(1979, 1979 + n_years)

# # --- Random "temperature" data for each year
# # Simulate a warming trend + spatial pattern
# base_pattern = 0.2 * np.cos(np.radians(np.meshgrid(lat, lon, indexing='ij')[0])) + \
#                5 * np.sin(np.radians(2 * np.meshgrid(lat, lon, indexing='ij')[1]))
# data = np.empty((n_years, n_lat, n_lon))
# for i, yr in enumerate(years):
#     data[i] = base_pattern + 0.05*(yr-1979) + np.random.normal(0, 0.5, (n_lat, n_lon))

# # --- Build xarray DataArray
# ds = xr.DataArray(
#     data,
#     coords={'year': years, 'lat': lat, 'lon': lon},
#     dims=['year', 'lat', 'lon'],
#     name='temperature'
# )

# # --- Calculate annual anomaly for 2024
# yearly = ds
# anomyearmean = yearly.sel(year=2024) - yearly.mean('year')


# LON, LAT = np.meshgrid(lon, lat)
# temperature = anomyearmean.values  # shape (n_lat, n_lon)
# #<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<





# FILL THE EMPTY LATS AT LON=-180
if not np.isclose(LON[0,0], LON[0,-1]):
    # Add a wrapped column at the end
    LON = np.hstack([LON, LON[:,0:1]])
    LAT = np.hstack([LAT, LAT[:,0:1]])
    temperature = np.hstack([temperature, temperature[:,0:1]])

# My color palette
rdblue256 = [to_hex(cm.get_cmap('RdBu_r')(i/255)) for i in range(256)]


# === Globe projection ===

def visible_mask(lon, lat, center_lon, center_lat):
    lon = np.radians(lon)
    lat = np.radians(lat)
    clon = np.radians(center_lon)
    clat = np.radians(center_lat)
    x = np.cos(lat) * np.cos(lon)
    y = np.cos(lat) * np.sin(lon)
    z = np.sin(lat)
    cx = np.cos(clat) * np.cos(clon)
    cy = np.cos(clat) * np.sin(clon)
    cz = np.sin(clat)
    dot = x * cx + y * cy + z * cz
    return dot > 0

def compute_sphere_rects(center_lon, center_lat):
    projection = ccrs.Orthographic(central_longitude=center_lon, central_latitude=center_lat)
    mask = visible_mask(LON, LAT, center_lon, center_lat)
    transformed = projection.transform_points(ccrs.PlateCarree(), LON, LAT)
    x = transformed[..., 0]
    y = transformed[..., 1]

    rect_x, rect_y, rect_w, rect_h, temps = [], [], [], [], []
    for i in range(x.shape[0] - 1):
        for j in range(x.shape[1] - 1):
            corners_x = [x[i, j], x[i, j+1], x[i+1, j+1], x[i+1, j]]
            corners_y = [y[i, j], y[i, j+1], y[i+1, j+1], y[i+1, j]]
            corners_mask = [mask[i, j], mask[i+1, j], mask[i, j+1], mask[i+1, j+1]]
            if all(corners_mask) and not np.any(np.isnan(corners_x)) and not np.any(np.isnan(corners_y)):
                x_center = np.mean(corners_x)
                y_center = np.mean(corners_y)
                width = max(corners_x) - min(corners_x)
                height = max(corners_y) - min(corners_y)
                rect_x.append(x_center)
                rect_y.append(y_center)
                rect_w.append(width)
                rect_h.append(height)
                temps.append(temperature[i, j])
    return dict(x=rect_x, y=rect_y, width=rect_w, height=rect_h, temp=temps)

# Pre-process coastlines once at startup - keep original geometry processing
def preprocess_coastlines():
    """Extract coastline geometries once at startup"""
    coastlines = cfeature.NaturalEarthFeature('physical', 'coastline', '110m')
    return list(coastlines.geometries())

def get_coastline_segments_fast(center_lon, center_lat, coastline_geometries):
    """Fast coastline transformation using your original logic"""
    projection = ccrs.Orthographic(central_longitude=center_lon, central_latitude=center_lat)
    xs, ys = [], []
    
    for geom in coastline_geometries:
        lines = [geom] if isinstance(geom, LineString) else list(geom.geoms)
        for line in lines:
            coords = np.array(line.coords)
            if len(coords) > 1:
                normalized_coords = coords.copy()
                normalized_coords[:, 0] = np.mod(normalized_coords[:, 0] + 180, 360) - 180
                valid_indices = np.where(np.abs(np.diff(normalized_coords[:, 0])) < 180)[0]
                if valid_indices.size == 0:
                    continue
                valid_indices = np.concatenate([valid_indices, [valid_indices[-1] + 1]])
                segment = normalized_coords[valid_indices]
                tt = projection.transform_points(ccrs.PlateCarree(), segment[:, 0], segment[:, 1])
                x = tt[:, 0]
                y = tt[:, 1]
                if len(x) > 1 and not np.all(np.isnan(x)):
                    xs.append(x)
                    ys.append(y)
    return xs, ys

# Pre-process coastlines at startup
COASTLINE_GEOMETRIES = preprocess_coastlines()
# init_rects = compute_sphere_rects(-80, 0)

# Pre-compute all rotation positions
center_lon = -80
center_lat = 0
rotation_speed = 50  # Changed to 60 for easier pre-computation

# Calculate number of steps for full rotation
steps = 360 // rotation_speed  # 6 steps
print(f"Pre-computing {steps} rotation positions...")

# Pre-compute all coastline and rectangle data
precomputed_coastlines = {}
precomputed_rects = {}

for i in range(steps):
    current_lon = (center_lon + i * rotation_speed) % 360
    print(f"Computing position {i+1}/{steps}: longitude {current_lon}°")
    
    # Pre-compute coastlines
    xs, ys = get_coastline_segments_fast(current_lon, center_lat, COASTLINE_GEOMETRIES)
    precomputed_coastlines[current_lon] = dict(xs=xs, ys=ys)
    
    # Pre-compute rectangles
    rects = compute_sphere_rects(current_lon, center_lat)
    precomputed_rects[current_lon] = rects

print("Pre-computation complete!")

# Initialize with first position
current_step = 0
current_lon = (center_lon + current_step * rotation_speed) % 360

# Set up data sources with pre-computed data
source = ColumnDataSource(data=precomputed_rects[current_lon])
coast_source = ColumnDataSource(data=precomputed_coastlines[current_lon])

# Set up Bokeh plot
p_globe = figure(
    width=500, height=500,
    x_axis_type=None, y_axis_type=None,
    match_aspect=True,
    toolbar_location=None,
    background_fill_color='#15191c', output_backend='webgl'
)
p_globe.grid.visible = False
p_globe.axis.visible = False
p_globe.outline_line_color = '#15191c'
p_globe.background_fill_color = '#15191c'
p_globe.title.align = "center"
color_mapper = LinearColorMapper(palette=rdblue256,low=-3, high=3) #low=np.nanmin(temperature), high=-np.nanmin(temperature))
color_bar = ColorBar(color_mapper=color_mapper,
                     ticker=BasicTicker(),
                     label_standoff=12,
                     border_line_color=None,
                     background_fill_color="#15191c",
                     location=(0, 0),  
                     major_label_text_color="white"
                    )
p_globe.add_layout(color_bar, 'right')
patches = p_globe.rect(
    x='x', y='y', width='width', height='height',
    fill_color={'field': 'temp', 'transform': color_mapper},
    line_color={'field': 'temp', 'transform': color_mapper},
    source=source
)
hover = HoverTool(tooltips=[
    ('Temperature', '@temp{0.1f}°C'),
], renderers=[patches])
p_globe.add_tools(hover)
p_globe.title.text_font_size = '12pt'

# Add coastlines to plot
p_globe.multi_line(xs='xs', ys='ys', source=coast_source, line_color='black', line_width=1, line_alpha=0.5)

def update_globe():
    global current_step
    current_step = (current_step + 1) % steps
    current_lon = (center_lon + current_step * rotation_speed) % 360
    
    # Just update data sources with pre-computed data - no computation needed!
    source.data = precomputed_rects[current_lon]
    coast_source.data = precomputed_coastlines[current_lon]

# Reduce callback frequency for smoother performance
curdoc().add_periodic_callback(update_globe, 2000)  # Increased from 50ms to 100ms

gradient_text = """
<div style="
    font-size: 18px;
    font-weight: bold;
    background: linear-gradient(90deg, red, orange, yellow);
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
    background-clip: text;
    color: transparent;
">
  ERA5 Annual Mean Temperature Anomaly for 2024<br>compared to 1979-2024 (°C)
</div>
"""
divinfo = Div(text = gradient_text)

layout = column(divinfo, p_globe, stylesheets = [gstyle])
curdoc().add_root(layout)

Peek 2025-07-06 19-11

import xarray as xr 
import numpy as np
import cartopy.crs as ccrs
import cartopy.feature as cfeature
from bokeh.plotting import figure, curdoc
from bokeh.models import ColorBar, LinearColorMapper, Slider,HoverTool,BasicTicker, InlineStyleSheet, ColumnDataSource,Div, GlobalInlineStyleSheet

from bokeh.layouts import column, row
from shapely.geometry import LineString

from matplotlib import cm
from matplotlib.colors import to_hex
curdoc().theme = 'dark_minimal'
gstyle = GlobalInlineStyleSheet(css=""" html, body, .bk, .bk-root {background-color: #15191c; margin: 0; padding: 0; height: 100%; color: white; font-family: 'Consolas', 'Courier New', monospace; } .bk { color: white; } .bk-input, .bk-btn, .bk-select, .bk-slider-title, .bk-headers, .bk-label, .bk-title, .bk-legend, .bk-axis-label { color: white !important; } .bk-input::placeholder { color: #aaaaaa !important; } """)
slider_style = InlineStyleSheet(css=""" /* Host slider container */ :host { background: none !important; } /* Full track: set dark grey, but filled part will override with .noUi-connect */ :host .noUi-base, :host .noUi-target { background: #bfbfbf !important; } /* Highlighted portion of track */ :host .noUi-connect { background: #00ffe0; } /* Slider handle */ :host .noUi-handle { background: #343838; border: 2px solid #00ffe0; border-radius: 50%; width: 20px; height: 20px; } /* Handle hover/focus */ :host .noUi-handle:hover, :host .noUi-handle:focus { border-color: #ff2a68; box-shadow: 0 0 10px #ff2a6890; } /* Tooltip stepping value */ :host .noUi-tooltip { background: #343838; color: #00ffe0; font-family: 'Consolas', monospace; border-radius: 6px; border: 1px solid #00ffe0; } /* Filled (active) slider track */ :host .noUi-connect { background: linear-gradient(90deg, #ffdd30 20%, #fc3737 100%) !important; /* greenish-cyan fade */ box-shadow: 0 0 10px #00ffe099 !important; } """)

ds = xr.open_dataset('/home/michael/Downloads/ee574e584b1f8351c52f63525a06f50d.nc')['t2m']
yearly = ds.groupby('time.year').mean('time')
anomyearmean = yearly.sel(year=slice(2024,2024)).mean('year')-yearly.mean('year')

lon = yearly.lon.values
lat = yearly.lat.values
LON, LAT = np.meshgrid(lon, lat)
temperature = anomyearmean.values
#### OR #####

# # use this data instead
# #>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>
# # --- Set random seed for reproducibility
# np.random.seed(42)

# # --- Define grid dimensions
# n_lon = 144  # e.g. every 2.5 degrees: 360/2.5 = 144
# n_lat = 73   # e.g. every 2.5 degrees: 180/2.5 = 72 (+1 for pole)
# n_years = 46  # e.g. years 1979-2024

# # --- Generate lat/lon values
# lon = np.linspace(-180, 180, n_lon, endpoint=False)
# lat = np.linspace(-90, 90, n_lat)

# years = np.arange(1979, 1979 + n_years)

# # --- Random "temperature" data for each year
# # Simulate a warming trend + spatial pattern
# base_pattern = 0.2 * np.cos(np.radians(np.meshgrid(lat, lon, indexing='ij')[0])) + \
#                5 * np.sin(np.radians(2 * np.meshgrid(lat, lon, indexing='ij')[1]))
# data = np.empty((n_years, n_lat, n_lon))
# for i, yr in enumerate(years):
#     data[i] = base_pattern + 0.05*(yr-1979) + np.random.normal(0, 0.5, (n_lat, n_lon))

# # --- Build xarray DataArray
# ds = xr.DataArray(
#     data,
#     coords={'year': years, 'lat': lat, 'lon': lon},
#     dims=['year', 'lat', 'lon'],
#     name='temperature'
# )

# # --- Calculate annual anomaly for 2024
# yearly = ds
# anomyearmean = yearly.sel(year=2024) - yearly.mean('year')


# LON, LAT = np.meshgrid(lon, lat)
# temperature = anomyearmean.values  # shape (n_lat, n_lon)
# #<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<

# FILL THE EMPTY LATS AT LON=-180
if not np.isclose(LON[0,0], LON[0,-1]):
    # Add a wrapped column at the end
    LON = np.hstack([LON, LON[:,0:1]])
    LAT = np.hstack([LAT, LAT[:,0:1]])
    temperature = np.hstack([temperature, temperature[:,0:1]])

# My color palette
rdblue256 = [to_hex(cm.get_cmap('RdBu_r')(i/255)) for i in range(256)]


# === Globe projection ===

def visible_mask(lon, lat, center_lon, center_lat):
    lon = np.radians(lon)
    lat = np.radians(lat)
    clon = np.radians(center_lon)
    clat = np.radians(center_lat)
    x = np.cos(lat) * np.cos(lon)
    y = np.cos(lat) * np.sin(lon)
    z = np.sin(lat)
    cx = np.cos(clat) * np.cos(clon)
    cy = np.cos(clat) * np.sin(clon)
    cz = np.sin(clat)
    dot = x * cx + y * cy + z * cz
    return dot > 0

def compute_sphere_rects(center_lon, center_lat):
    projection = ccrs.Orthographic(central_longitude=center_lon, central_latitude=center_lat)
    mask = visible_mask(LON, LAT, center_lon, center_lat)
    transformed = projection.transform_points(ccrs.PlateCarree(), LON, LAT)
    x = transformed[..., 0]
    y = transformed[..., 1]

    rect_x, rect_y, rect_w, rect_h, temps = [], [], [], [], []
    for i in range(x.shape[0] - 1):
        for j in range(x.shape[1] - 1):
            corners_x = [x[i, j], x[i, j+1], x[i+1, j+1], x[i+1, j]]
            corners_y = [y[i, j], y[i, j+1], y[i+1, j+1], y[i+1, j]]
            corners_mask = [mask[i, j], mask[i+1, j], mask[i, j+1], mask[i+1, j+1]]
            if all(corners_mask) and not np.any(np.isnan(corners_x)) and not np.any(np.isnan(corners_y)):
                x_center = np.mean(corners_x)
                y_center = np.mean(corners_y)
                width = max(corners_x) - min(corners_x)
                height = max(corners_y) - min(corners_y)
                rect_x.append(x_center)
                rect_y.append(y_center)
                rect_w.append(width)
                rect_h.append(height)
                temps.append(temperature[i, j])
    return dict(x=rect_x, y=rect_y, width=rect_w, height=rect_h, temp=temps)

# Pre-process coastlines once at startup - keep original geometry processing
def preprocess_coastlines():
    """Extract coastline geometries once at startup"""
    coastlines = cfeature.NaturalEarthFeature('physical', 'coastline', '110m')
    return list(coastlines.geometries())

def get_coastline_segments_fast(center_lon, center_lat, coastline_geometries):
    """Fast coastline transformation using your original logic"""
    projection = ccrs.Orthographic(central_longitude=center_lon, central_latitude=center_lat)
    xs, ys = [], []
    
    for geom in coastline_geometries:
        lines = [geom] if isinstance(geom, LineString) else list(geom.geoms)
        for line in lines:
            coords = np.array(line.coords)
            if len(coords) > 1:
                normalized_coords = coords.copy()
                normalized_coords[:, 0] = np.mod(normalized_coords[:, 0] + 180, 360) - 180
                valid_indices = np.where(np.abs(np.diff(normalized_coords[:, 0])) < 180)[0]
                if valid_indices.size == 0:
                    continue
                valid_indices = np.concatenate([valid_indices, [valid_indices[-1] + 1]])
                segment = normalized_coords[valid_indices]
                tt = projection.transform_points(ccrs.PlateCarree(), segment[:, 0], segment[:, 1])
                x = tt[:, 0]
                y = tt[:, 1]
                if len(x) > 1 and not np.all(np.isnan(x)):
                    xs.append(x)
                    ys.append(y)
    return xs, ys




# Pre-process coastlines at startup
COASTLINE_GEOMETRIES = preprocess_coastlines()

# INITIAL globe orientation
center_lon = -80
center_lat = 0

# Create data sources
source = ColumnDataSource(data=compute_sphere_rects(center_lon, center_lat))
coast_source = ColumnDataSource(data=dict(
    xs=[], ys=[]
))
xs, ys = get_coastline_segments_fast(center_lon, center_lat, COASTLINE_GEOMETRIES)
coast_source.data = dict(xs=xs, ys=ys)

# Set up Bokeh plot
p_globe = figure(
    width=500, height=500,
    x_axis_type=None, y_axis_type=None,
    match_aspect=True,
    toolbar_location=None,
    background_fill_color='#15191c', output_backend='webgl'
)
p_globe.grid.visible = False
p_globe.axis.visible = False
p_globe.outline_line_color = '#15191c'
p_globe.background_fill_color = '#15191c'
p_globe.title.align = "center"
color_mapper = LinearColorMapper(palette=rdblue256,low=-3, high=3)
color_bar = ColorBar(color_mapper=color_mapper,
                     ticker=BasicTicker(),
                     label_standoff=12,
                     border_line_color=None,
                     background_fill_color="#15191c",
                     location=(0, 0),  
                     major_label_text_color="white"
                    )
p_globe.add_layout(color_bar, 'right')
patches = p_globe.rect(
    x='x', y='y', width='width', height='height',
    fill_color={'field': 'temp', 'transform': color_mapper},
    line_color={'field': 'temp', 'transform': color_mapper},
    source=source
)
p_globe.multi_line(xs='xs', ys='ys', source=coast_source, line_color='black', line_width=1, line_alpha=0.5)

# === SLIDERS ===
lon_slider = Slider(title="Longitude", start=-180, end=180, value=center_lon, step=1,  stylesheets = [slider_style])
lat_slider = Slider(title="Latitude", start=-90, end=90, value=center_lat, step=1, stylesheets = [slider_style])

def slider_update(attr, old, new):
    lon = lon_slider.value
    lat = lat_slider.value
    # Update rectangles and coastlines
    source.data = compute_sphere_rects(lon, lat)
    xs, ys = get_coastline_segments_fast(lon, lat, COASTLINE_GEOMETRIES)
    coast_source.data = dict(xs=xs, ys=ys)
    p_globe.title.text = f"Center: lon {lon}°, lat {lat}°"

lon_slider.on_change('value_throttled', slider_update)
lat_slider.on_change('value_throttled', slider_update)

# Gradient label
gradient_text = """
<div style="
    font-size: 18px;
    font-weight: bold;
    background: linear-gradient(90deg, red, orange, yellow);
    -webkit-background-clip: text;
    -webkit-text-fill-color: transparent;
    background-clip: text;
    color: transparent;
">
  ERA5 Annual Mean Temperature Anomaly for 2024<br>compared to 1979-2024 (°C)
</div>
"""
divinfo = Div(text = gradient_text)

# LAYOUT
controls = column(lon_slider, lat_slider)
layout = column(divinfo, p_globe, controls, stylesheets = [gstyle])

curdoc().add_root(layout)

Here is an enhanced and optimized version, even with high-resolution random data for both auto and manual rotation:

Peek 2025-07-07 02-09

import xarray as xr 
import numpy as np
import cartopy.crs as ccrs
from bokeh.plotting import figure, curdoc
from bokeh.models import ColorBar, LinearColorMapper, InlineStyleSheet, ColumnDataSource,Div, GlobalInlineStyleSheet
import cartopy.feature as cf
from bokeh.layouts import column
import pandas as pd
from matplotlib import cm
from matplotlib.colors import to_hex

curdoc().theme = 'dark_minimal'
gstyle = GlobalInlineStyleSheet(css=""" html, body, .bk, .bk-root {background-color: #15191c; margin: 0; padding: 0; height: 100%; color: white; font-family: 'Consolas', 'Courier New', monospace; } .bk { color: white; } .bk-input, .bk-btn, .bk-select, .bk-slider-title, .bk-headers, .bk-label, .bk-title, .bk-legend, .bk-axis-label { color: white !important; } .bk-input::placeholder { color: #aaaaaa !important; } """)
slider_style = InlineStyleSheet(css=""" /* Host slider container */ :host { background: none !important; } /* Full track: set dark grey, but filled part will override with .noUi-connect */ :host .noUi-base, :host .noUi-target { background: #bfbfbf !important; } /* Highlighted portion of track */ :host .noUi-connect { background: #00ffe0; } /* Slider handle */ :host .noUi-handle { background: #343838; border: 2px solid #00ffe0; border-radius: 50%; width: 20px; height: 20px; } /* Handle hover/focus */ :host .noUi-handle:hover, :host .noUi-handle:focus { border-color: #ff2a68; box-shadow: 0 0 10px #ff2a6890; } /* Tooltip stepping value */ :host .noUi-tooltip { background: #343838; color: #00ffe0; font-family: 'Consolas', monospace; border-radius: 6px; border: 1px solid #00ffe0; } /* Filled (active) slider track */ :host .noUi-connect { background: linear-gradient(90deg, #ffdd30 20%, #fc3737 100%) !important; /* greenish-cyan fade */ box-shadow: 0 0 10px #00ffe099 !important; } """)

# --- Define grid dimensions
n_lon = 576   ;   n_lat = 351   ;   n_years = 46  

# --- Generate lat/lon values
lon = np.linspace(-180, 180, n_lon, endpoint=False)
lat = np.linspace(-90, 90, n_lat)
years = np.arange(1979, 1979 + n_years)

base_pattern = 0.2 * np.cos(np.radians(np.meshgrid(lat, lon, indexing='ij')[0])) + 5 * np.sin(np.radians(2 * np.meshgrid(lat, lon, indexing='ij')[1]))
data = np.empty((n_years, n_lat, n_lon))

for i, yr in enumerate(years):
    data[i] = base_pattern + 0.05*(yr-1979) + np.random.normal(0, 0.5, (n_lat, n_lon))

# --- Build xarray DataArray
ds = xr.DataArray(
    data,
    coords={'year': years, 'lat': lat, 'lon': lon},
    dims=['year', 'lat', 'lon'],
    name='temperature'
)

# --- Calculate annual anomaly for 2024
yearly = ds
anomyearmean = yearly.sel(year=2024) - yearly.mean('year')

LON, LAT = np.meshgrid(lon, lat)
temperature = anomyearmean.values  


# FILL THE EMPTY LATS AT LON=-180
if not np.isclose(LON[0,0], LON[0,-1]):
    # Add a wrapped column at the end
    LON = np.hstack([LON, LON[:,0:1]])
    LAT = np.hstack([LAT, LAT[:,0:1]])
    temperature = np.hstack([temperature, temperature[:,0:1]])

# My color palette
rdblue256 = [to_hex(cm.get_cmap('RdBu_r')(i/255)) for i in range(256)]

projection = ccrs.Orthographic()
x, y = projection.transform_points(ccrs.PlateCarree(), LON, LAT)[:, :, :2].reshape(-1, 2).T
x_flat = x.flatten()
y_flat = y.flatten()
values_flat = temperature.flatten()
df = pd.DataFrame({'x': x_flat, 'y': y_flat, 'value': values_flat})
source = ColumnDataSource(df)
# Pre-compute all rotation positions
center_lon = 0
center_lat = 0
rotation_speed = 30  # Changed to 60 for easier pre-computation
current_step = 0
# Calculate number of steps for full rotation
steps = 360 // rotation_speed  # 6 steps
print(f"Pre-computing {steps} rotation positions...")

projection = ccrs.Orthographic(central_latitude=center_lat, central_longitude=center_lon)

# initialize coastlines
x_coords = []
y_coords = []
for coord_seq in cf.COASTLINE.geometries():
    # Convert coordinates to NumPy arrays
    lons = np.array([k[0] for k in coord_seq.coords])
    lats = np.array([k[1] for k in coord_seq.coords])
    
    # Transform coordinates
    transformed = projection.transform_points(ccrs.PlateCarree(), lons, lats)
    
    x_coords.extend(transformed[:, 0].tolist() + [np.nan])
    y_coords.extend(transformed[:, 1].tolist() + [np.nan])
coast_source = ColumnDataSource(data=dict(x=x_coords, y=y_coords))


precomputed_coastlines = []
precomputed_data = []
for angle in range(0, 360+1, rotation_speed):
    projection = ccrs.Orthographic(central_longitude=angle, central_latitude=center_lat)
    x_coords = []
    y_coords = []
    for coord_seq in cf.COASTLINE.geometries():
        # Convert coordinates to NumPy arrays
        lons = np.array([k[0] for k in coord_seq.coords])
        lats = np.array([k[1] for k in coord_seq.coords])
        
        # Transform coordinates
        transformed = projection.transform_points(ccrs.PlateCarree(), lons, lats)
        
        x_coords.extend(transformed[:, 0].tolist() + [np.nan])
        y_coords.extend(transformed[:, 1].tolist() + [np.nan])
    precomputed_coastlines.append({'x': x_coords, 'y': y_coords})

    # Convert to Robinson projection coordinates
    x, y = projection.transform_points(ccrs.PlateCarree(), LON, LAT)[:, :, :2].reshape(-1, 2).T

    # Flatten arrays for Bokeh
    x_flat = x.flatten()
    y_flat = y.flatten()
    values_flat = temperature.flatten()
    precomputed_data.append({'x': x_flat, 'y': y_flat, 'value': values_flat})  


minval = -3; maxval = 3
# Set up Bokeh plot
p_globe = figure(
    width=500, height=500,
    x_axis_type=None, y_axis_type=None,
    match_aspect=True,
    toolbar_location=None,
    background_fill_color='#15191c', output_backend='webgl'
)
p_globe.scatter(x='x', y='y', size=4, marker = 'square', color={'field': 'value', 'transform': LinearColorMapper(palette=rdblue256, low=minval, high=maxval)}, source=source)

p_globe.grid.visible = False
p_globe.axis.visible = False
p_globe.outline_line_color = '#15191c'
p_globe.background_fill_color = '#15191c'
color_mapper = LinearColorMapper(palette=rdblue256, low=minval, high=maxval)
color_bar = ColorBar(color_mapper=color_mapper, width=12, location=(0,0))
p_globe.add_layout(color_bar, 'right')

# COASTLINES
p_globe.line(x='x', y='y', source=coast_source, color="black", line_width=1, line_alpha=1)

def update_globe():
    global current_step
    # print(current_step)
    current_step = (current_step + 1) % steps
    source.data = precomputed_data[current_step]
    coast_source.data = precomputed_coastlines[current_step]

curdoc().add_periodic_callback(update_globe, 1000)  

gradient_text = """ <div style=" font-size: 18px; font-weight: bold; background: linear-gradient(90deg, red, orange, yellow); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; color: transparent; "> ERA5 Annual Mean Temperature Anomaly for 2024<br>compared to 1979-2024 (°C) </div> """
divinfo = Div(text = gradient_text)
layout = column(divinfo, p_globe, stylesheets = [gstyle])
curdoc().add_root(layout)

Peek 2025-07-07 02-08

import xarray as xr 
import numpy as np
import cartopy.crs as ccrs
from bokeh.plotting import figure, curdoc
from bokeh.models import ColorBar, LinearColorMapper, Slider, InlineStyleSheet, ColumnDataSource,Div, GlobalInlineStyleSheet

from bokeh.layouts import column
import pandas as pd
import cartopy.feature as cf
from matplotlib import cm
from matplotlib.colors import to_hex

curdoc().theme = 'dark_minimal'
gstyle = GlobalInlineStyleSheet(css=""" html, body, .bk, .bk-root {background-color: #15191c; margin: 0; padding: 0; height: 100%; color: white; font-family: 'Consolas', 'Courier New', monospace; } .bk { color: white; } .bk-input, .bk-btn, .bk-select, .bk-slider-title, .bk-headers, .bk-label, .bk-title, .bk-legend, .bk-axis-label { color: white !important; } .bk-input::placeholder { color: #aaaaaa !important; } """)
slider_style = InlineStyleSheet(css=""" /* Host slider container */ :host { background: none !important; } /* Full track: set dark grey, but filled part will override with .noUi-connect */ :host .noUi-base, :host .noUi-target { background: #bfbfbf !important; } /* Highlighted portion of track */ :host .noUi-connect { background: #00ffe0; } /* Slider handle */ :host .noUi-handle { background: #343838; border: 2px solid #00ffe0; border-radius: 50%; width: 20px; height: 20px; } /* Handle hover/focus */ :host .noUi-handle:hover, :host .noUi-handle:focus { border-color: #ff2a68; box-shadow: 0 0 10px #ff2a6890; } /* Tooltip stepping value */ :host .noUi-tooltip { background: #343838; color: #00ffe0; font-family: 'Consolas', monospace; border-radius: 6px; border: 1px solid #00ffe0; } /* Filled (active) slider track */ :host .noUi-connect { background: linear-gradient(90deg, #ffdd30 20%, #fc3737 100%) !important; /* greenish-cyan fade */ box-shadow: 0 0 10px #00ffe099 !important; } """)


# --- Define grid dimensions
n_lon = 576   ;   n_lat = 351   ;   n_years = 46  

# --- Generate lat/lon values
lon = np.linspace(-180, 180, n_lon, endpoint=False)
lat = np.linspace(-90, 90, n_lat)
years = np.arange(1979, 1979 + n_years)

base_pattern = 0.2 * np.cos(np.radians(np.meshgrid(lat, lon, indexing='ij')[0])) + 5 * np.sin(np.radians(2 * np.meshgrid(lat, lon, indexing='ij')[1]))
data = np.empty((n_years, n_lat, n_lon))

for i, yr in enumerate(years):
    data[i] = base_pattern + 0.05*(yr-1979) + np.random.normal(0, 0.5, (n_lat, n_lon))

# --- Build xarray DataArray
ds = xr.DataArray(
    data,
    coords={'year': years, 'lat': lat, 'lon': lon},
    dims=['year', 'lat', 'lon'],
    name='temperature'
)

# --- Calculate annual anomaly for 2024
yearly = ds
anomyearmean = yearly.sel(year=2024) - yearly.mean('year')
LON, LAT = np.meshgrid(lon, lat)
temperature = anomyearmean.values  # shape (n_lat, n_lon)
#<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<

# FILL THE EMPTY LATS AT LON=-180
if not np.isclose(LON[0,0], LON[0,-1]):
    # Add a wrapped column at the end
    LON = np.hstack([LON, LON[:,0:1]])
    LAT = np.hstack([LAT, LAT[:,0:1]])
    temperature = np.hstack([temperature, temperature[:,0:1]])

# My color palette
rdblue256 = [to_hex(cm.get_cmap('RdBu_r')(i/255)) for i in range(256)]


LON, LAT = np.meshgrid(lon, lat)
temperature = anomyearmean.values  


# FILL THE EMPTY LATS AT LON=-180
if not np.isclose(LON[0,0], LON[0,-1]):
    # Add a wrapped column at the end
    LON = np.hstack([LON, LON[:,0:1]])
    LAT = np.hstack([LAT, LAT[:,0:1]])
    temperature = np.hstack([temperature, temperature[:,0:1]])

# My color palette
rdblue256 = [to_hex(cm.get_cmap('RdBu_r')(i/255)) for i in range(256)]

projection = ccrs.Orthographic()
x, y = projection.transform_points(ccrs.PlateCarree(), LON, LAT)[:, :, :2].reshape(-1, 2).T
x_flat = x.flatten()
y_flat = y.flatten()
values_flat = temperature.flatten()
df = pd.DataFrame({'x': x_flat, 'y': y_flat, 'value': values_flat})
source = ColumnDataSource(df)
# Pre-compute all rotation positions
center_lon = 0
center_lat = 0
rotation_speed = 30  # Changed to 60 for easier pre-computation
current_step = 0
# Calculate number of steps for full rotation
steps = 360 // rotation_speed  # 6 steps
print(f"Pre-computing {steps} rotation positions...")

projection = ccrs.Orthographic(central_latitude=center_lat, central_longitude=center_lon)

# initialize coastlines
x_coords = []
y_coords = []
for coord_seq in cf.COASTLINE.geometries():
    # Convert coordinates to NumPy arrays
    lons = np.array([k[0] for k in coord_seq.coords])
    lats = np.array([k[1] for k in coord_seq.coords])
    
    # Transform coordinates
    transformed = projection.transform_points(ccrs.PlateCarree(), lons, lats)
    
    x_coords.extend(transformed[:, 0].tolist() + [np.nan])
    y_coords.extend(transformed[:, 1].tolist() + [np.nan])

coast_source = ColumnDataSource(data=dict(x=x_coords, y=y_coords))

minval = -3; maxval = 3

# Set up Bokeh plot
p_globe = figure(
    width=500, height=500,
    x_axis_type=None, y_axis_type=None,
    match_aspect=True,
    toolbar_location=None,
    background_fill_color='#15191c', output_backend='webgl'
)
p_globe.scatter(x='x', y='y', size=4, marker = 'square', color={'field': 'value', 'transform': LinearColorMapper(palette=rdblue256, low=minval, high=maxval)}, source=source)

p_globe.grid.visible = False
p_globe.axis.visible = False
p_globe.outline_line_color = '#15191c'
p_globe.background_fill_color = '#15191c'
color_mapper = LinearColorMapper(palette=rdblue256, low=minval, high=maxval)
color_bar = ColorBar(color_mapper=color_mapper, width=12, location=(0,0))
p_globe.add_layout(color_bar, 'right')

# COASTLINES
p_globe.line(x='x', y='y', source=coast_source, color="black", line_width=1, line_alpha=1)

# === SLIDERS ===
lon_slider = Slider(title="Longitude", start=-180, end=180, value=center_lon, step=1,  stylesheets = [slider_style])
lat_slider = Slider(title="Latitude", start=-90, end=90, value=center_lat, step=1, stylesheets = [slider_style])


def dataonsli(LATq,LONq):
    projection = ccrs.Orthographic(central_longitude=LONq, central_latitude=LATq)
    x_coords = []
    y_coords = []
    for coord_seq in cf.COASTLINE.geometries():
        # Convert coordinates to NumPy arrays
        lons = np.array([k[0] for k in coord_seq.coords])
        lats = np.array([k[1] for k in coord_seq.coords])
        
        # Transform coordinates
        transformed = projection.transform_points(ccrs.PlateCarree(), lons, lats)
        
        x_coords.extend(transformed[:, 0].tolist() + [np.nan])
        y_coords.extend(transformed[:, 1].tolist() + [np.nan])
    precomputed_coastlines = {'x': x_coords, 'y': y_coords}

    # Convert to Robinson projection coordinates
    x, y = projection.transform_points(ccrs.PlateCarree(), LON, LAT)[:, :, :2].reshape(-1, 2).T

    # Flatten arrays for Bokeh
    x_flat = x.flatten()
    y_flat = y.flatten()
    values_flat = temperature.flatten()
    precomputed_data = {'x': x_flat, 'y': y_flat, 'value': values_flat}
    return precomputed_data,precomputed_coastlines


def slider_update(attr, old, new):
    lon = lon_slider.value
    lat = lat_slider.value
    # Update rectangles and coastlines
    source.data = dataonsli(lat,lon)[0]
    coast_source.data = dataonsli(lat,lon)[1]

lon_slider.on_change('value_throttled', slider_update)
lat_slider.on_change('value_throttled', slider_update)

# Gradient label
gradient_text = """ <div style=" font-size: 18px; font-weight: bold; background: linear-gradient(90deg, red, orange, yellow); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; color: transparent; "> ERA5 Annual Mean Temperature Anomaly for 2024<br>compared to 1979-2024 (°C) </div> """
divinfo = Div(text = gradient_text)

controls = column(lon_slider, lat_slider)
layout = column(divinfo, p_globe, controls, stylesheets = [gstyle])

curdoc().add_root(layout)