Hello ,
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.
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)
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)