diff --git a/plots/scatter-animated-controls/implementations/bokeh.py b/plots/scatter-animated-controls/implementations/bokeh.py new file mode 100644 index 0000000000..f19047f70c --- /dev/null +++ b/plots/scatter-animated-controls/implementations/bokeh.py @@ -0,0 +1,305 @@ +""" pyplots.ai +scatter-animated-controls: Animated Scatter Plot with Play Controls +Library: bokeh 3.8.1 | Python 3.13.11 +Quality: 91/100 | Created: 2025-12-31 +""" + +import numpy as np +import pandas as pd +from bokeh.io import export_png, save +from bokeh.layouts import column, row +from bokeh.models import Button, ColumnDataSource, CustomJS, Div, HoverTool, Label, Slider +from bokeh.plotting import figure +from bokeh.resources import CDN +from bokeh.transform import factor_cmap + + +# Data: Simulated country metrics over 20 years (Gapminder-style) +np.random.seed(42) + +n_countries = 15 +years = np.arange(2004, 2024) +n_years = len(years) + +countries = [ + "Country A", + "Country B", + "Country C", + "Country D", + "Country E", + "Country F", + "Country G", + "Country H", + "Country I", + "Country J", + "Country K", + "Country L", + "Country M", + "Country N", + "Country O", +] + +regions = ["North", "South", "East", "West", "Central"] +country_regions = [regions[i % 5] for i in range(n_countries)] + +# Generate time-series data for each country +data_frames = [] +for i, country in enumerate(countries): + base_gdp = np.random.uniform(5000, 40000) + base_life = np.random.uniform(55, 75) + base_pop = np.random.uniform(5, 200) # millions + + gdp_growth = np.random.uniform(0.02, 0.06) + life_improvement = np.random.uniform(0.2, 0.5) + pop_growth = np.random.uniform(0.005, 0.02) + + # Add some noise and variation + gdp_noise = np.cumsum(np.random.randn(n_years) * 500) + life_noise = np.cumsum(np.random.randn(n_years) * 0.3) + pop_noise = np.cumsum(np.random.randn(n_years) * 0.5) + + gdp = base_gdp * (1 + gdp_growth) ** np.arange(n_years) + gdp_noise + life_exp = base_life + life_improvement * np.arange(n_years) + life_noise + population = base_pop * (1 + pop_growth) ** np.arange(n_years) + pop_noise + + # Ensure positive values + gdp = np.maximum(gdp, 1000) + life_exp = np.clip(life_exp, 40, 90) + population = np.maximum(population, 1) + + for j, year in enumerate(years): + data_frames.append( + { + "country": country, + "region": country_regions[i], + "year": year, + "gdp_per_capita": gdp[j], + "life_expectancy": life_exp[j], + "population": population[j], + } + ) + +df = pd.DataFrame(data_frames) + +# Initial data (first year) +initial_year = years[0] +initial_data = df[df["year"] == initial_year].copy() + +# Create ColumnDataSource (color is handled by factor_cmap based on region) +source = ColumnDataSource( + data={ + "x": initial_data["gdp_per_capita"].values, + "y": initial_data["life_expectancy"].values, + "size": (initial_data["population"].values ** 0.5) * 5, # Scale for visibility + "country": initial_data["country"].values, + "region": initial_data["region"].values, + "population": initial_data["population"].values, + } +) + +# Store all data for animation (color is handled by factor_cmap based on region) +all_data = {} +for year in years: + year_data = df[df["year"] == year] + all_data[str(year)] = { + "x": year_data["gdp_per_capita"].tolist(), + "y": year_data["life_expectancy"].tolist(), + "size": [(p**0.5) * 5 for p in year_data["population"].values], + "country": year_data["country"].tolist(), + "region": year_data["region"].tolist(), + "population": year_data["population"].tolist(), + } + +# Define regions list and color palette for factor_cmap +regions_list = ["North", "South", "East", "West", "Central"] +color_palette = ["#306998", "#FFD43B", "#E15759", "#76B7B2", "#59A14F"] + +# Create figure +p = figure( + width=4800, + height=2700, + title="scatter-animated-controls · bokeh · pyplots.ai", + x_axis_label="GDP per Capita (USD)", + y_axis_label="Life Expectancy (Years)", + x_range=(0, 80000), + y_range=(40, 95), + tools="pan,wheel_zoom,box_zoom,reset,save", +) + +# Style the figure - increased font sizes for better readability at 4800x2700 +p.title.text_font_size = "48pt" +p.xaxis.axis_label_text_font_size = "36pt" +p.yaxis.axis_label_text_font_size = "36pt" +p.xaxis.major_label_text_font_size = "28pt" +p.yaxis.major_label_text_font_size = "28pt" + +# Grid styling +p.grid.grid_line_alpha = 0.3 +p.grid.grid_line_dash = [6, 4] + +# Background +p.background_fill_color = "#fafafa" + +# Add margins to prevent legend clipping +p.min_border_left = 120 +p.min_border_right = 120 +p.min_border_top = 100 +p.min_border_bottom = 100 + +# Add scatter plot with legend_field for native legend in PNG export +scatter = p.scatter( + x="x", + y="y", + size="size", + color=factor_cmap("region", palette=color_palette, factors=regions_list), + alpha=0.7, + line_color="white", + line_width=2, + source=source, + legend_field="region", +) + +# Configure legend for visibility in PNG export - positioned inside plot with large fonts +p.legend.location = "top_left" +p.legend.title = "Region" +p.legend.title_text_font_size = "36pt" +p.legend.label_text_font_size = "32pt" +p.legend.glyph_height = 60 +p.legend.glyph_width = 60 +p.legend.spacing = 15 +p.legend.padding = 30 +p.legend.margin = 40 +p.legend.background_fill_alpha = 0.9 +p.legend.border_line_color = "#aaaaaa" +p.legend.border_line_width = 2 + +# Add hover tool +hover = HoverTool( + tooltips=[ + ("Country", "@country"), + ("Region", "@region"), + ("GDP per Capita", "$@x{0,0}"), + ("Life Expectancy", "@y{0.1} years"), + ("Population", "@population{0.1} million"), + ], + renderers=[scatter], +) +p.add_tools(hover) + +# Add year label (large background text) - increased size for better visibility +year_label = Label( + x=70000, + y=50, + text=str(initial_year), + text_font_size="150pt", + text_color="#cccccc", + text_alpha=0.5, + text_align="right", +) +p.add_layout(year_label) + +# Create slider +slider = Slider(start=int(years[0]), end=int(years[-1]), value=int(years[0]), step=1, title="Year", width=600) + +# Create play/pause button +button = Button(label="▶ Play", button_type="success", width=150) + +# Create legend info display +legend_html = """ +
+ Regions:
+ North    + South    + East    + West    + Central +
+""" +legend_div = Div(text=legend_html, width=800) + +# JavaScript callback for slider (region drives color via factor_cmap) +slider_callback = CustomJS( + args={"source": source, "all_data": all_data, "year_label": year_label}, + code=""" + const year = cb_obj.value.toString(); + const data = all_data[year]; + + source.data['x'] = data['x']; + source.data['y'] = data['y']; + source.data['size'] = data['size']; + source.data['country'] = data['country']; + source.data['region'] = data['region']; + source.data['population'] = data['population']; + source.change.emit(); + + year_label.text = year; +""", +) +slider.js_on_change("value", slider_callback) + +# JavaScript callback for play/pause button +button_callback = CustomJS( + args={"button": button, "slider": slider, "years_start": int(years[0]), "years_end": int(years[-1])}, + code=""" + if (button.label.includes('Play')) { + button.label = '⏸ Pause'; + button.button_type = 'warning'; + + // Start animation + window.animation_interval = setInterval(function() { + if (slider.value >= slider.end) { + slider.value = slider.start; + } else { + slider.value = slider.value + 1; + } + }, 500); + } else { + button.label = '▶ Play'; + button.button_type = 'success'; + + // Stop animation + if (window.animation_interval) { + clearInterval(window.animation_interval); + } + } +""", +) +button.js_on_click(button_callback) + +# Create title div +title_div = Div( + text=""" +
+ Country Development Over Time (2004-2023) +
+
+ Bubble size represents population. Click Play to animate or drag the slider. +
+""", + width=1000, +) + +# Layout +controls = row(button, slider, legend_div) +layout = column(title_div, controls, p) + +# Save HTML (interactive version with controls) +save(layout, filename="plot.html", title="Animated Scatter Plot", resources=CDN) + +# For PNG export, show the middle year frame as a representative snapshot +middle_year = years[len(years) // 2] +middle_data = df[df["year"] == middle_year] + +# Update source for static export (color via factor_cmap based on region) +source.data = { + "x": middle_data["gdp_per_capita"].values, + "y": middle_data["life_expectancy"].values, + "size": (middle_data["population"].values ** 0.5) * 5, + "country": middle_data["country"].values, + "region": middle_data["region"].values, + "population": middle_data["population"].values, +} +year_label.text = str(middle_year) + +# Export PNG (static snapshot) +export_png(p, filename="plot.png") diff --git a/plots/scatter-animated-controls/metadata/bokeh.yaml b/plots/scatter-animated-controls/metadata/bokeh.yaml new file mode 100644 index 0000000000..7a5f014b08 --- /dev/null +++ b/plots/scatter-animated-controls/metadata/bokeh.yaml @@ -0,0 +1,28 @@ +library: bokeh +specification_id: scatter-animated-controls +created: '2025-12-31T13:53:51Z' +updated: '2025-12-31T14:49:39Z' +generated_by: claude-opus-4-5-20251101 +workflow_run: 20620303226 +issue: 3067 +python_version: 3.13.11 +library_version: 3.8.1 +preview_url: https://storage.googleapis.com/pyplots-images/plots/scatter-animated-controls/bokeh/plot.png +preview_thumb: https://storage.googleapis.com/pyplots-images/plots/scatter-animated-controls/bokeh/plot_thumb.png +preview_html: https://storage.googleapis.com/pyplots-images/plots/scatter-animated-controls/bokeh/plot.html +quality_score: 91 +review: + strengths: + - Excellent implementation of Gapminder-style animated visualization with all required + interactive controls + - Text sizing is well-calibrated for the 4800x2700 canvas with clear hierarchy + - Color palette is distinctive and colorblind-friendly with good contrast + - Year watermark provides clear temporal context without obscuring data + - Smooth animation implementation using CustomJS with proper play/pause toggle + - Hover tooltips provide detailed information for each country + - PNG snapshot shows middle frame (2014) as representative static view + weaknesses: + - Legend glyph sizes (60px) appear small compared to actual bubble sizes in the + plot, making it harder to associate legend entries with data points + - Grid styling with dashed lines could be slightly more subtle (alpha 0.2 instead + of 0.3)