|
| 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 |
| 212 | + <span style="color: #FFD43B;">●</span> South |
| 213 | + <span style="color: #E15759;">●</span> East |
| 214 | + <span style="color: #76B7B2;">●</span> West |
| 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") |
0 commit comments