A radial chart with an animated spiral


Peek 2025-08-06 19-24

import numpy as np
import pandas as pd
from bokeh.plotting import figure, curdoc
from bokeh.models import ColumnDataSource, Div, GlobalInlineStyleSheet
from bokeh.layouts import column
from bokeh.transform import linear_cmap
from matplotlib import cm
from matplotlib.colors import to_hex
import math
rdblue = [to_hex(cm.get_cmap('RdYlBu_r')(i/255)) for i in range(256)]
gstyle = GlobalInlineStyleSheet(css=""" html, body, .bk, .bk-root {background-color: #343838; 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; } """)

# Generate random temperature data with smooth interpolation instead of ERA5 data !!!
np.random.seed(42)
years = list(range(1979, 2025))
months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 
          'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']

# Create seasonal temperature pattern with random variation
base_temps = [12,15, 12, 18, 24, 18, 30, 29, 15, 18, 10, 4]  # Seasonal pattern
temp_data = []

for year in years:
    year_temps = []
    for i, month in enumerate(months):
        # Add random variation and slight warming trend over years
        temp_variation = np.random.normal(0, 3)
        warming_trend = (year - 1979) * 0.2  # Slight warming over time
        temp = base_temps[i] + temp_variation + warming_trend
        year_temps.append(temp)
    temp_data.append(year_temps)

# Create smooth spiral data with more points per year
points_per_year = 120  
df_data = []

for i, year in enumerate(years):
    year_temps = temp_data[i]
    # Create smooth interpolation within the year
    for j in range(points_per_year):
        progress = j / points_per_year  # 0 to 1 progress through the year
        angle = progress * 2 * math.pi  # Full circle
        
        # Smooth temperature interpolation
        month_float = progress * 12
        month_index = int(month_float) % 12
        month_fraction = month_float - int(month_float)
        
        # Interpolate between current and next month's temperature
        current_temp = year_temps[month_index]
        next_temp = year_temps[(month_index + 1) % 12]
        temperature = current_temp + (next_temp - current_temp) * month_fraction
        
        df_data.append({
            'year': year,
            'temperature': temperature,
            'angle': angle,
            'year_progress': i + progress
        })

df = pd.DataFrame(df_data)

# Normalize temperature for radius and create color mapping based on radius
min_temp, max_temp = df['temperature'].min(), df['temperature'].max()
inner_radius, outer_radius = 20, 100
df['radius'] = inner_radius + (df['temperature'] - min_temp) / (max_temp - min_temp) * (outer_radius - inner_radius)

# Convert polar to cartesian coordinates
df['x'] = df['radius'] * np.cos(df['angle'])
df['y'] = df['radius'] * np.sin(df['angle'])

# Create figure centered at origin
p = figure(width=800, height=800,
           toolbar_location=None, match_aspect=True,
           x_range=(-140, 140), y_range=(-140, 140),           background_fill_color="#343838",
           border_fill_color="#343838", outline_line_color="#343838",)

# Remove axes and grid
p.axis.visible = False
p.grid.visible = False
p.grid.grid_line_color = "gray"
p.grid.grid_line_alpha = 0.3
p.xaxis.visible = False
p.yaxis.visible = False
# Create month labels around the circle
month_angles = [i * (2 * math.pi / 12) for i in range(12)]
month_radius = outer_radius + 19
month_x = [month_radius * math.cos(angle) for angle in month_angles]
month_y = [month_radius * math.sin(angle) for angle in month_angles]

# Add month labels
for i, month in enumerate(months):
    p.text([month_x[i]], [month_y[i]], text=[month], 
           text_align="center", text_baseline="middle", text_font_size="12pt",text_color="silver")

# Create concentric circles for temperature reference
temp_circles = [inner_radius, (inner_radius + outer_radius)/2, outer_radius]
temp_labels = [f"{min_temp:.0f}°C", f"{(min_temp + max_temp)/2:.0f}°C", f"{max_temp:.0f}°C"]

for i, radius in enumerate(temp_circles):
    circle_x = [radius * math.cos(angle) for angle in np.linspace(0, 2*math.pi, 100)]
    circle_y = [radius * math.sin(angle) for angle in np.linspace(0, 2*math.pi, 100)]
    p.line(circle_x, circle_y, line_color="lightgray", line_alpha=0.5, line_dash="dashed")
    
    # Add temperature labels
    p.text([radius + 5], [0], text=[temp_labels[i]], 
           text_font_size="12pt", text_color="lime")
    p.text([-radius - 15], [0], text=[temp_labels[i]], 
           text_font_size="12pt", text_color="lime")


# Create data sources
current_point_source = ColumnDataSource(data=dict(x=[], y=[], temperature=[]))
year_source = ColumnDataSource(data=dict(year_text=["1979"]))

# Add spiral line - we'll use segments for color mapping
from bokeh.models import LinearColorMapper
from bokeh.transform import transform

# Create color mapper based on radius (blue inside, red outside)
color_mapper = LinearColorMapper(palette=rdblue, low=inner_radius, high=outer_radius)

# We'll create line segments instead of a continuous line for color mapping
segment_source = ColumnDataSource(data=dict(x0=[], y0=[], x1=[], y1=[], radius=[]))
spiral_segments = p.segment('x0', 'y0', 'x1', 'y1', source=segment_source,
                           line_width=3, line_alpha=0.8,
                           line_color=transform('radius', color_mapper))

# Add current position marker
current_marker = p.circle('x', 'y', source=current_point_source, size=12, 
                         color='red', alpha=0.9, line_color='darkred', line_width=2)


# Animation variables
current_index = 0
animation_speed = 1 
from bokeh.models import Label

# Create a Label for the year, initially at the center (0,0)
year_label = Label(x=0, y=0, text="1979",
                   text_font_size="20pt", text_color="#FF4F4F",
                   text_align="center", text_baseline="middle",
                    background_fill_color=None)

p.add_layout(year_label)
def animate():
    global current_index
    
    if current_index < len(df):
        # Get data up to current point
        current_data = df.iloc[:current_index + 1]
        
        # Create line segments for color mapping
        if len(current_data) > 1:
            x0_vals = current_data['x'].iloc[:-1].tolist()
            y0_vals = current_data['y'].iloc[:-1].tolist()
            x1_vals = current_data['x'].iloc[1:].tolist()
            y1_vals = current_data['y'].iloc[1:].tolist()
            radius_vals = current_data['radius'].iloc[1:].tolist()  # Use radius for coloring
            
            # Update spiral segments
            segment_source.data = {
                'x0': x0_vals,
                'y0': y0_vals,
                'x1': x1_vals,
                'y1': y1_vals,
                'radius': radius_vals
            }
        
        # Update current position marker
        current_point = df.iloc[current_index]
        current_point_source.data = {
            'x': [current_point['x']],
            'y': [current_point['y']],
            'temperature': [current_point['temperature']]
        }
        
        # Update year display
        current_year = current_point['year']
        year_label.text = f"{int(current_year)}"
        current_index += 1
        
        # Reset animation when complete
        if current_index >= len(df):
            current_index = 0

# Add animation callback
curdoc().add_periodic_callback(animate, animation_speed)
gradient_text = """
<div style="
    font-size: 28px;
    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 Monthly Mean Temperature Anomaly 1979-2024 (°C)
</div>
"""
divinfo = Div(text = gradient_text)
# Create layout
layout = column(divinfo, p, sizing_mode="scale_width",stylesheets = [gstyle])
curdoc().add_root(layout)
curdoc().title = "ERA5 Temperature Anomaly at 2 m"

# Initial call to set up the plot
animate()

bokeh serve --show app.py
1 Like

@mixstam1453 Many of your examples are really cool. They are usually just a little bit more involved than we usually have for examples in the docs. But they might make for nice blog posts where they could get a little more explanation. Blog posts would also be great for us to tweet out on Bluesky where we only just got started. Would you be interested in this at all? @pavithraes has indicated she would be happy to help with and reviewing and publishing, etc! FYI our blog is currently at https://blog.bokeh.org/

2 Likes

Hi. Yes, for sure, I would be interested.
The next few days, I am going to start posting (i.e. transferring some of the nice showcases in the blog with more explanation).

2 Likes