{"spec_id":"bubble-basic","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nbubble-basic: Basic Bubble Chart\nLibrary: bokeh 3.10.0 | Python 3.13.15\nQuality: 89/100 | Created: 2026-09-30\n\"\"\"\n\nimport os\nimport time\nfrom pathlib import Path\n\nimport numpy as np\nfrom bokeh.io import output_file, save\nfrom bokeh.models import BoxAnnotation, ColumnDataSource, HoverTool, Label, Range1d\nfrom bokeh.plotting import figure\nfrom selenium import webdriver\nfrom selenium.webdriver.chrome.options import Options\n\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\n\n# Imprint palette — first series is always brand green\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\nBRAND = IMPRINT_PALETTE[0]\n\n# Data — City metrics: population density vs median income, bubble = green space per capita\n# Green space inversely correlated with density (denser cities have less green space)\nnp.random.seed(42)\nn_cities = 55\n\npopulation_density = np.random.uniform(500, 12000, n_cities)  # people per km²\nmedian_income = 30 + population_density / 400 + np.random.normal(0, 4, n_cities)  # thousands USD\ndensity_norm = (population_density - population_density.min()) / (population_density.max() - population_density.min())\ngreen_space = 100 - density_norm * 90 + np.random.normal(0, 5, n_cities)\ngreen_space = np.clip(green_space, 10, 100)  # m² per capita\n\n# Area-proportional bubble sizes: size² ∝ data value, so size ∝ sqrt(data)\nsize_min, size_max = 16, 80\ngreen_norm = (green_space - green_space.min()) / (green_space.max() - green_space.min())\nbubble_size = np.sqrt(size_min**2 + (size_max**2 - size_min**2) * green_norm)\n\nsource = ColumnDataSource(\n    data={\n        \"density\": population_density,\n        \"income\": median_income,\n        \"size\": bubble_size,\n        \"density_display\": np.round(population_density).astype(int),\n        \"income_display\": np.round(median_income, 1),\n        \"green_display\": np.round(green_space, 1),\n    }\n)\n\n# Modest, symmetric axis padding — the size legend below lives inside the\n# naturally sparse bottom-right corner (high density + low income is rare\n# given the positive density-income correlation), so no extra headroom needs\n# to be reserved just to make room for it.\nx_pad = (population_density.max() - population_density.min()) * 0.07\ny_pad = (median_income.max() - median_income.min()) * 0.07\nx_start = population_density.min() - x_pad * 1.3\nx_end = population_density.max() + x_pad * 1.3\ny_start = median_income.min() - y_pad\ny_end = median_income.max() + y_pad * 2.2\n\nx_range = x_end - x_start\ny_range = y_end - y_start\n\n# Plot\ntitle = \"bubble-basic · python · bokeh · anyplot.ai\"\np = figure(\n    width=3200,\n    height=1800,\n    title=title,\n    x_axis_label=\"Population Density (people/km²)\",\n    y_axis_label=\"Median Income (thousands USD)\",\n    toolbar_location=None,\n    min_border_bottom=160,\n    min_border_left=180,\n    min_border_top=110,\n    min_border_right=50,\n)\np.x_range = Range1d(start=x_start, end=x_end)\np.y_range = Range1d(start=y_start, end=y_end)\n\nmain_renderer = p.scatter(\n    x=\"density\",\n    y=\"income\",\n    size=\"size\",\n    source=source,\n    fill_color=BRAND,\n    fill_alpha=0.65,\n    line_color=PAGE_BG,\n    line_width=2,\n    # Bokeh-specific hover_* vectorized props auto-build a hover glyph: the\n    # bubble under the cursor snaps to full opacity with an ink outline, no\n    # custom JS required.\n    hover_fill_alpha=1.0,\n    hover_line_color=INK,\n    hover_line_width=3,\n)\n\n# Hover tool — scoped to the main data renderer only (skip the legend swatches)\nhover = HoverTool(\n    renderers=[main_renderer],\n    tooltips=[\n        (\"Density\", \"@density_display{,} people/km²\"),\n        (\"Income\", \"$@income_display{0.0}k\"),\n        (\"Green Space\", \"@green_display m²/capita\"),\n    ],\n    mode=\"mouse\",\n)\np.add_tools(hover)\n\n# Theme-adaptive chrome\np.background_fill_color = PAGE_BG\np.border_fill_color = PAGE_BG\np.outline_line_color = None\np.outline_line_alpha = 0\n\np.title.text_font_size = \"50pt\"\np.title.text_color = INK\n\np.xaxis.axis_label_text_font_size = \"42pt\"\np.yaxis.axis_label_text_font_size = \"42pt\"\np.xaxis.axis_label_text_color = INK\np.yaxis.axis_label_text_color = INK\n\np.xaxis.major_label_text_font_size = \"34pt\"\np.yaxis.major_label_text_font_size = \"34pt\"\np.xaxis.major_label_text_color = INK_SOFT\np.yaxis.major_label_text_color = INK_SOFT\n\np.xaxis.axis_line_color = None\np.yaxis.axis_line_color = None\np.xaxis.major_tick_line_color = INK_SOFT\np.yaxis.major_tick_line_color = INK_SOFT\np.xaxis.minor_tick_line_color = None\np.yaxis.minor_tick_line_color = None\n\np.xgrid.grid_line_color = INK\np.xgrid.grid_line_alpha = 0.12\np.ygrid.grid_line_color = INK\np.ygrid.grid_line_alpha = 0.12\n\n# Size legend — anchored in the bottom-right corner, which the positive\n# density-income correlation leaves naturally sparse (high density rarely\n# pairs with low income), reclaiming space instead of padding the canvas.\n# Reference bubbles use the same fill, outline and translucency as the data\n# marks so they read as miniatures of the real thing.\nlegend_cx = x_end - x_range * 0.20\nlegend_top = y_start + y_range * 0.34\ny_step = y_range * 0.07\n\nref_green = [green_space.min(), (green_space.min() + green_space.max()) / 2, green_space.max()]\nref_norm = [(v - green_space.min()) / (green_space.max() - green_space.min()) for v in ref_green]\nref_sizes = [np.sqrt(size_min**2 + (size_max**2 - size_min**2) * n) for n in ref_norm]\nref_labels = [f\"{v:.0f} m²/capita\" for v in ref_green]\n\nlegend_box = BoxAnnotation(\n    left=legend_cx - x_range * 0.17,\n    right=legend_cx + x_range * 0.17,\n    top=legend_top + y_range * 0.01,\n    bottom=legend_top - y_step * 3.8,\n    fill_color=ELEVATED_BG,\n    fill_alpha=0.9,\n    line_color=INK_SOFT,\n    line_alpha=0.4,\n    level=\"underlay\",\n)\np.add_layout(legend_box)\n\np.add_layout(\n    Label(\n        x=legend_cx,\n        y=legend_top - y_range * 0.01,\n        text=\"Green Space\",\n        text_font_size=\"38pt\",\n        text_font_style=\"bold\",\n        text_color=INK,\n        text_align=\"center\",\n    )\n)\n\nfor i, (sz, lbl) in enumerate(zip(ref_sizes, ref_labels, strict=True)):\n    ly = legend_top - y_step * (i + 0.85)\n    ref_src = ColumnDataSource(data={\"x\": [legend_cx - x_range * 0.04], \"y\": [ly], \"size\": [sz]})\n    p.scatter(\n        x=\"x\", y=\"y\", size=\"size\", source=ref_src, fill_color=BRAND, fill_alpha=0.65, line_color=PAGE_BG, line_width=2\n    )\n    p.add_layout(\n        Label(\n            x=legend_cx + x_range * 0.01,\n            y=ly,\n            text=lbl,\n            text_font_size=\"34pt\",\n            text_baseline=\"middle\",\n            text_color=INK_SOFT,\n        )\n    )\n\n# Save HTML\noutput_file(f\"plot-{THEME}.html\")\nsave(p)\n\n# Save PNG via headless Chrome — pin the viewport exactly via CDP, since\n# --window-size sets the OUTER window and still reserves a phantom title-bar\n# height even headless, which would shrink the screenshot below H.\nW, H = 3200, 1800\nopts = Options()\nfor arg in (\n    \"--headless=new\",\n    \"--no-sandbox\",\n    \"--disable-dev-shm-usage\",\n    \"--disable-gpu\",\n    f\"--window-size={W},{H}\",\n    \"--hide-scrollbars\",\n):\n    opts.add_argument(arg)\ndriver = webdriver.Chrome(options=opts)\ndriver.set_window_size(W, H)\ndriver.get(f\"file://{Path(f'plot-{THEME}.html').resolve()}\")\ndriver.execute_cdp_cmd(\n    \"Emulation.setDeviceMetricsOverride\", {\"width\": W, \"height\": H, \"deviceScaleFactor\": 1, \"mobile\": False}\n)\ntime.sleep(3)\ndriver.save_screenshot(f\"plot-{THEME}.png\")\ndriver.quit()\n"}