{"spec_id":"bubble-basic","library":"bokeh","language":"python","code":"\"\"\" anyplot.ai\nbubble-basic: Basic Bubble Chart\nLibrary: bokeh 3.9.0 | Python 3.13.13\nQuality: 92/100 | Created: 2026-05-28\n\"\"\"\n\nimport base64\nimport io\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, LinearColorMapper, Range1d\nfrom bokeh.plotting import figure\nfrom bokeh.transform import transform\nfrom PIL import Image\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_seq colormap (single-polarity: brand green → blue)\n_t = np.linspace(0, 1, 256)\n_c0 = np.array([0x00, 0x9E, 0x73])\n_c1 = np.array([0x44, 0x67, 0xA3])\nANYPLOT_SEQ256 = [\"#{:02X}{:02X}{:02X}\".format(*(_c0 + (_c1 - _c0) * t).round().astype(int)) for t in _t]\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 = 40\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\ngreen_space = 60 - (population_density / 12000) * 50 + np.random.normal(0, 4, n_cities)\ngreen_space = np.clip(green_space, 5, 60)  # m² per capita\n\n# Area-proportional bubble sizes: size² ∝ data value, so size ∝ sqrt(data)\nsize_min, size_max = 22, 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\ncolor_mapper = LinearColorMapper(palette=ANYPLOT_SEQ256, low=green_space.min(), high=green_space.max())\n\nsource = ColumnDataSource(\n    data={\n        \"density\": population_density,\n        \"income\": median_income,\n        \"size\": bubble_size,\n        \"green_space\": green_space,\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# Symmetric axis ranges — equal padding both sides; extra top space for legend\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.5\nx_end = population_density.max() + x_pad * 1.5\ny_start = median_income.min() - y_pad\ny_end = median_income.max() + y_pad * 7\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\np.scatter(\n    x=\"density\",\n    y=\"income\",\n    size=\"size\",\n    source=source,\n    fill_color=transform(\"green_space\", color_mapper),\n    fill_alpha=0.65,\n    line_color=PAGE_BG,\n    line_width=2,\n)\n\n# Hover tool\nhover = HoverTool(\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# Dashed trend line — guides viewer to the positive density-income correlation\ntrend_coeffs = np.polyfit(population_density, median_income, 1)\nx_trend = np.linspace(x_start, x_end, 100)\ny_trend = np.polyval(trend_coeffs, x_trend)\np.line(x=x_trend, y=y_trend, line_color=INK_SOFT, line_dash=\"dashed\", line_width=5, line_alpha=0.5)\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 = INK_SOFT\np.yaxis.axis_line_color = INK_SOFT\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.ygrid.grid_line_color = INK\np.xgrid.grid_line_alpha = 0.12\np.ygrid.grid_line_alpha = 0.12\n\n# Size legend — anchored above the main data cluster (top region is empty due to correlation)\nlegend_cx = x_start + x_range * 0.22\nlegend_top = y_end - y_range * 0.04\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.13,\n    right=legend_cx + x_range * 0.13,\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)\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=\"30pt\",\n        text_font_style=\"bold\",\n        text_color=INK,\n        text_align=\"center\",\n    )\n)\n\nfor i, (sz, lbl, gv) in enumerate(zip(ref_sizes, ref_labels, ref_green, 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], \"green_space\": [gv]})\n    p.scatter(\n        x=\"x\",\n        y=\"y\",\n        size=\"size\",\n        source=ref_src,\n        fill_color=transform(\"green_space\", color_mapper),\n        fill_alpha=0.65,\n        line_color=PAGE_BG,\n        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=\"26pt\",\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 — use captureBeyondViewport so browser chrome\n# overhead (~139px) doesn't truncate the canvas height\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()}\")\ntime.sleep(3)\nscreenshot = driver.execute_cdp_cmd(\"Page.captureScreenshot\", {\"format\": \"png\", \"captureBeyondViewport\": True})\ndriver.quit()\nImage.open(io.BytesIO(base64.b64decode(screenshot[\"data\"]))).save(f\"plot-{THEME}.png\")\n"}