Skip to content

Commit 558987f

Browse files
feat(bokeh): implement scatter-animated-controls (#3099)
## Implementation: `scatter-animated-controls` - bokeh Implements the **bokeh** version of `scatter-animated-controls`. **File:** `plots/scatter-animated-controls/implementations/bokeh.py` **Parent Issue:** #3067 --- :robot: *[impl-generate workflow](https://github.com/MarkusNeusinger/pyplots/actions/runs/20620303226)* --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 6173c84 commit 558987f

File tree

2 files changed

+333
-0
lines changed

2 files changed

+333
-0
lines changed
Lines changed: 305 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,305 @@
1+
""" pyplots.ai
2+
scatter-animated-controls: Animated Scatter Plot with Play Controls
3+
Library: bokeh 3.8.1 | Python 3.13.11
4+
Quality: 91/100 | Created: 2025-12-31
5+
"""
6+
7+
import numpy as np
8+
import pandas as pd
9+
from bokeh.io import export_png, save
10+
from bokeh.layouts import column, row
11+
from bokeh.models import Button, ColumnDataSource, CustomJS, Div, HoverTool, Label, Slider
12+
from bokeh.plotting import figure
13+
from bokeh.resources import CDN
14+
from bokeh.transform import factor_cmap
15+
16+
17+
# Data: Simulated country metrics over 20 years (Gapminder-style)
18+
np.random.seed(42)
19+
20+
n_countries = 15
21+
years = np.arange(2004, 2024)
22+
n_years = len(years)
23+
24+
countries = [
25+
"Country A",
26+
"Country B",
27+
"Country C",
28+
"Country D",
29+
"Country E",
30+
"Country F",
31+
"Country G",
32+
"Country H",
33+
"Country I",
34+
"Country J",
35+
"Country K",
36+
"Country L",
37+
"Country M",
38+
"Country N",
39+
"Country O",
40+
]
41+
42+
regions = ["North", "South", "East", "West", "Central"]
43+
country_regions = [regions[i % 5] for i in range(n_countries)]
44+
45+
# Generate time-series data for each country
46+
data_frames = []
47+
for i, country in enumerate(countries):
48+
base_gdp = np.random.uniform(5000, 40000)
49+
base_life = np.random.uniform(55, 75)
50+
base_pop = np.random.uniform(5, 200) # millions
51+
52+
gdp_growth = np.random.uniform(0.02, 0.06)
53+
life_improvement = np.random.uniform(0.2, 0.5)
54+
pop_growth = np.random.uniform(0.005, 0.02)
55+
56+
# Add some noise and variation
57+
gdp_noise = np.cumsum(np.random.randn(n_years) * 500)
58+
life_noise = np.cumsum(np.random.randn(n_years) * 0.3)
59+
pop_noise = np.cumsum(np.random.randn(n_years) * 0.5)
60+
61+
gdp = base_gdp * (1 + gdp_growth) ** np.arange(n_years) + gdp_noise
62+
life_exp = base_life + life_improvement * np.arange(n_years) + life_noise
63+
population = base_pop * (1 + pop_growth) ** np.arange(n_years) + pop_noise
64+
65+
# Ensure positive values
66+
gdp = np.maximum(gdp, 1000)
67+
life_exp = np.clip(life_exp, 40, 90)
68+
population = np.maximum(population, 1)
69+
70+
for j, year in enumerate(years):
71+
data_frames.append(
72+
{
73+
"country": country,
74+
"region": country_regions[i],
75+
"year": year,
76+
"gdp_per_capita": gdp[j],
77+
"life_expectancy": life_exp[j],
78+
"population": population[j],
79+
}
80+
)
81+
82+
df = pd.DataFrame(data_frames)
83+
84+
# Initial data (first year)
85+
initial_year = years[0]
86+
initial_data = df[df["year"] == initial_year].copy()
87+
88+
# Create ColumnDataSource (color is handled by factor_cmap based on region)
89+
source = ColumnDataSource(
90+
data={
91+
"x": initial_data["gdp_per_capita"].values,
92+
"y": initial_data["life_expectancy"].values,
93+
"size": (initial_data["population"].values ** 0.5) * 5, # Scale for visibility
94+
"country": initial_data["country"].values,
95+
"region": initial_data["region"].values,
96+
"population": initial_data["population"].values,
97+
}
98+
)
99+
100+
# Store all data for animation (color is handled by factor_cmap based on region)
101+
all_data = {}
102+
for year in years:
103+
year_data = df[df["year"] == year]
104+
all_data[str(year)] = {
105+
"x": year_data["gdp_per_capita"].tolist(),
106+
"y": year_data["life_expectancy"].tolist(),
107+
"size": [(p**0.5) * 5 for p in year_data["population"].values],
108+
"country": year_data["country"].tolist(),
109+
"region": year_data["region"].tolist(),
110+
"population": year_data["population"].tolist(),
111+
}
112+
113+
# Define regions list and color palette for factor_cmap
114+
regions_list = ["North", "South", "East", "West", "Central"]
115+
color_palette = ["#306998", "#FFD43B", "#E15759", "#76B7B2", "#59A14F"]
116+
117+
# Create figure
118+
p = figure(
119+
width=4800,
120+
height=2700,
121+
title="scatter-animated-controls · bokeh · pyplots.ai",
122+
x_axis_label="GDP per Capita (USD)",
123+
y_axis_label="Life Expectancy (Years)",
124+
x_range=(0, 80000),
125+
y_range=(40, 95),
126+
tools="pan,wheel_zoom,box_zoom,reset,save",
127+
)
128+
129+
# Style the figure - increased font sizes for better readability at 4800x2700
130+
p.title.text_font_size = "48pt"
131+
p.xaxis.axis_label_text_font_size = "36pt"
132+
p.yaxis.axis_label_text_font_size = "36pt"
133+
p.xaxis.major_label_text_font_size = "28pt"
134+
p.yaxis.major_label_text_font_size = "28pt"
135+
136+
# Grid styling
137+
p.grid.grid_line_alpha = 0.3
138+
p.grid.grid_line_dash = [6, 4]
139+
140+
# Background
141+
p.background_fill_color = "#fafafa"
142+
143+
# Add margins to prevent legend clipping
144+
p.min_border_left = 120
145+
p.min_border_right = 120
146+
p.min_border_top = 100
147+
p.min_border_bottom = 100
148+
149+
# Add scatter plot with legend_field for native legend in PNG export
150+
scatter = p.scatter(
151+
x="x",
152+
y="y",
153+
size="size",
154+
color=factor_cmap("region", palette=color_palette, factors=regions_list),
155+
alpha=0.7,
156+
line_color="white",
157+
line_width=2,
158+
source=source,
159+
legend_field="region",
160+
)
161+
162+
# Configure legend for visibility in PNG export - positioned inside plot with large fonts
163+
p.legend.location = "top_left"
164+
p.legend.title = "Region"
165+
p.legend.title_text_font_size = "36pt"
166+
p.legend.label_text_font_size = "32pt"
167+
p.legend.glyph_height = 60
168+
p.legend.glyph_width = 60
169+
p.legend.spacing = 15
170+
p.legend.padding = 30
171+
p.legend.margin = 40
172+
p.legend.background_fill_alpha = 0.9
173+
p.legend.border_line_color = "#aaaaaa"
174+
p.legend.border_line_width = 2
175+
176+
# Add hover tool
177+
hover = HoverTool(
178+
tooltips=[
179+
("Country", "@country"),
180+
("Region", "@region"),
181+
("GDP per Capita", "$@x{0,0}"),
182+
("Life Expectancy", "@y{0.1} years"),
183+
("Population", "@population{0.1} million"),
184+
],
185+
renderers=[scatter],
186+
)
187+
p.add_tools(hover)
188+
189+
# Add year label (large background text) - increased size for better visibility
190+
year_label = Label(
191+
x=70000,
192+
y=50,
193+
text=str(initial_year),
194+
text_font_size="150pt",
195+
text_color="#cccccc",
196+
text_alpha=0.5,
197+
text_align="right",
198+
)
199+
p.add_layout(year_label)
200+
201+
# Create slider
202+
slider = Slider(start=int(years[0]), end=int(years[-1]), value=int(years[0]), step=1, title="Year", width=600)
203+
204+
# Create play/pause button
205+
button = Button(label="▶ Play", button_type="success", width=150)
206+
207+
# Create legend info display
208+
legend_html = """
209+
<div style="font-size: 20pt; padding: 15px; background: #f5f5f5; border-radius: 8px;">
210+
<strong style="font-size: 24pt;">Regions:</strong><br>
211+
<span style="color: #306998;">●</span> North &nbsp;&nbsp;
212+
<span style="color: #FFD43B;">●</span> South &nbsp;&nbsp;
213+
<span style="color: #E15759;">●</span> East &nbsp;&nbsp;
214+
<span style="color: #76B7B2;">●</span> West &nbsp;&nbsp;
215+
<span style="color: #59A14F;">●</span> Central
216+
</div>
217+
"""
218+
legend_div = Div(text=legend_html, width=800)
219+
220+
# JavaScript callback for slider (region drives color via factor_cmap)
221+
slider_callback = CustomJS(
222+
args={"source": source, "all_data": all_data, "year_label": year_label},
223+
code="""
224+
const year = cb_obj.value.toString();
225+
const data = all_data[year];
226+
227+
source.data['x'] = data['x'];
228+
source.data['y'] = data['y'];
229+
source.data['size'] = data['size'];
230+
source.data['country'] = data['country'];
231+
source.data['region'] = data['region'];
232+
source.data['population'] = data['population'];
233+
source.change.emit();
234+
235+
year_label.text = year;
236+
""",
237+
)
238+
slider.js_on_change("value", slider_callback)
239+
240+
# JavaScript callback for play/pause button
241+
button_callback = CustomJS(
242+
args={"button": button, "slider": slider, "years_start": int(years[0]), "years_end": int(years[-1])},
243+
code="""
244+
if (button.label.includes('Play')) {
245+
button.label = '⏸ Pause';
246+
button.button_type = 'warning';
247+
248+
// Start animation
249+
window.animation_interval = setInterval(function() {
250+
if (slider.value >= slider.end) {
251+
slider.value = slider.start;
252+
} else {
253+
slider.value = slider.value + 1;
254+
}
255+
}, 500);
256+
} else {
257+
button.label = '▶ Play';
258+
button.button_type = 'success';
259+
260+
// Stop animation
261+
if (window.animation_interval) {
262+
clearInterval(window.animation_interval);
263+
}
264+
}
265+
""",
266+
)
267+
button.js_on_click(button_callback)
268+
269+
# Create title div
270+
title_div = Div(
271+
text="""
272+
<div style="font-size: 28pt; font-weight: bold; margin-bottom: 20px; color: #333;">
273+
Country Development Over Time (2004-2023)
274+
</div>
275+
<div style="font-size: 18pt; color: #666; margin-bottom: 10px;">
276+
Bubble size represents population. Click Play to animate or drag the slider.
277+
</div>
278+
""",
279+
width=1000,
280+
)
281+
282+
# Layout
283+
controls = row(button, slider, legend_div)
284+
layout = column(title_div, controls, p)
285+
286+
# Save HTML (interactive version with controls)
287+
save(layout, filename="plot.html", title="Animated Scatter Plot", resources=CDN)
288+
289+
# For PNG export, show the middle year frame as a representative snapshot
290+
middle_year = years[len(years) // 2]
291+
middle_data = df[df["year"] == middle_year]
292+
293+
# Update source for static export (color via factor_cmap based on region)
294+
source.data = {
295+
"x": middle_data["gdp_per_capita"].values,
296+
"y": middle_data["life_expectancy"].values,
297+
"size": (middle_data["population"].values ** 0.5) * 5,
298+
"country": middle_data["country"].values,
299+
"region": middle_data["region"].values,
300+
"population": middle_data["population"].values,
301+
}
302+
year_label.text = str(middle_year)
303+
304+
# Export PNG (static snapshot)
305+
export_png(p, filename="plot.png")
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
library: bokeh
2+
specification_id: scatter-animated-controls
3+
created: '2025-12-31T13:53:51Z'
4+
updated: '2025-12-31T14:49:39Z'
5+
generated_by: claude-opus-4-5-20251101
6+
workflow_run: 20620303226
7+
issue: 3067
8+
python_version: 3.13.11
9+
library_version: 3.8.1
10+
preview_url: https://storage.googleapis.com/pyplots-images/plots/scatter-animated-controls/bokeh/plot.png
11+
preview_thumb: https://storage.googleapis.com/pyplots-images/plots/scatter-animated-controls/bokeh/plot_thumb.png
12+
preview_html: https://storage.googleapis.com/pyplots-images/plots/scatter-animated-controls/bokeh/plot.html
13+
quality_score: 91
14+
review:
15+
strengths:
16+
- Excellent implementation of Gapminder-style animated visualization with all required
17+
interactive controls
18+
- Text sizing is well-calibrated for the 4800x2700 canvas with clear hierarchy
19+
- Color palette is distinctive and colorblind-friendly with good contrast
20+
- Year watermark provides clear temporal context without obscuring data
21+
- Smooth animation implementation using CustomJS with proper play/pause toggle
22+
- Hover tooltips provide detailed information for each country
23+
- PNG snapshot shows middle frame (2014) as representative static view
24+
weaknesses:
25+
- Legend glyph sizes (60px) appear small compared to actual bubble sizes in the
26+
plot, making it harder to associate legend entries with data points
27+
- Grid styling with dashed lines could be slightly more subtle (alpha 0.2 instead
28+
of 0.3)

0 commit comments

Comments
 (0)