Basic Bubble Chart — Bokeh

A bubble chart extending scatter plots by adding a third dimension through bubble size. Each point's position shows two variables (x, y) while the bubble size represents a third quantitative variable. This visualization is excellent for understanding relationships between three numerical variables simultaneously, revealing patterns that would be hidden in traditional 2D scatter plots.

Basic Bubble Chart rendered with Bokeh

Python source (Bokeh)

""" anyplot.ai
bubble-basic: Basic Bubble Chart
Library: bokeh 3.9.0 | Python 3.13.13
Quality: 92/100 | Created: 2026-05-28
"""

import base64
import io
import os
import time
from pathlib import Path

import numpy as np
from bokeh.io import output_file, save
from bokeh.models import BoxAnnotation, ColumnDataSource, HoverTool, Label, LinearColorMapper, Range1d
from bokeh.plotting import figure
from bokeh.transform import transform
from PIL import Image
from selenium import webdriver
from selenium.webdriver.chrome.options import Options


# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"

# imprint_seq colormap (single-polarity: brand green → blue)
_t = np.linspace(0, 1, 256)
_c0 = np.array([0x00, 0x9E, 0x73])
_c1 = np.array([0x44, 0x67, 0xA3])
ANYPLOT_SEQ256 = ["#{:02X}{:02X}{:02X}".format(*(_c0 + (_c1 - _c0) * t).round().astype(int)) for t in _t]

# Data — City metrics: population density vs median income, bubble = green space per capita
# Green space inversely correlated with density (denser cities have less green space)
np.random.seed(42)
n_cities = 40

population_density = np.random.uniform(500, 12000, n_cities)  # people per km²
median_income = 30 + population_density / 400 + np.random.normal(0, 4, n_cities)  # thousands USD
green_space = 60 - (population_density / 12000) * 50 + np.random.normal(0, 4, n_cities)
green_space = np.clip(green_space, 5, 60)  # m² per capita

# Area-proportional bubble sizes: size² ∝ data value, so size ∝ sqrt(data)
size_min, size_max = 22, 80
green_norm = (green_space - green_space.min()) / (green_space.max() - green_space.min())
bubble_size = np.sqrt(size_min**2 + (size_max**2 - size_min**2) * green_norm)

color_mapper = LinearColorMapper(palette=ANYPLOT_SEQ256, low=green_space.min(), high=green_space.max())

source = ColumnDataSource(
    data={
        "density": population_density,
        "income": median_income,
        "size": bubble_size,
        "green_space": green_space,
        "density_display": np.round(population_density).astype(int),
        "income_display": np.round(median_income, 1),
        "green_display": np.round(green_space, 1),
    }
)

# Symmetric axis ranges — equal padding both sides; extra top space for legend
x_pad = (population_density.max() - population_density.min()) * 0.07
y_pad = (median_income.max() - median_income.min()) * 0.07
x_start = population_density.min() - x_pad * 1.5
x_end = population_density.max() + x_pad * 1.5
y_start = median_income.min() - y_pad
y_end = median_income.max() + y_pad * 7

x_range = x_end - x_start
y_range = y_end - y_start

# Plot
title = "bubble-basic · python · bokeh · anyplot.ai"
p = figure(
    width=3200,
    height=1800,
    title=title,
    x_axis_label="Population Density (people/km²)",
    y_axis_label="Median Income (thousands USD)",
    toolbar_location=None,
    min_border_bottom=160,
    min_border_left=180,
    min_border_top=110,
    min_border_right=50,
)
p.x_range = Range1d(start=x_start, end=x_end)
p.y_range = Range1d(start=y_start, end=y_end)

p.scatter(
    x="density",
    y="income",
    size="size",
    source=source,
    fill_color=transform("green_space", color_mapper),
    fill_alpha=0.65,
    line_color=PAGE_BG,
    line_width=2,
)

# Hover tool
hover = HoverTool(
    tooltips=[
        ("Density", "@density_display{,} people/km²"),
        ("Income", "$@income_display{0.0}k"),
        ("Green Space", "@green_display m²/capita"),
    ],
    mode="mouse",
)
p.add_tools(hover)

# Dashed trend line — guides viewer to the positive density-income correlation
trend_coeffs = np.polyfit(population_density, median_income, 1)
x_trend = np.linspace(x_start, x_end, 100)
y_trend = np.polyval(trend_coeffs, x_trend)
p.line(x=x_trend, y=y_trend, line_color=INK_SOFT, line_dash="dashed", line_width=5, line_alpha=0.5)

# Theme-adaptive chrome
p.background_fill_color = PAGE_BG
p.border_fill_color = PAGE_BG
p.outline_line_color = None
p.outline_line_alpha = 0

p.title.text_font_size = "50pt"
p.title.text_color = INK

p.xaxis.axis_label_text_font_size = "42pt"
p.yaxis.axis_label_text_font_size = "42pt"
p.xaxis.axis_label_text_color = INK
p.yaxis.axis_label_text_color = INK

p.xaxis.major_label_text_font_size = "34pt"
p.yaxis.major_label_text_font_size = "34pt"
p.xaxis.major_label_text_color = INK_SOFT
p.yaxis.major_label_text_color = INK_SOFT

p.xaxis.axis_line_color = INK_SOFT
p.yaxis.axis_line_color = INK_SOFT
p.xaxis.major_tick_line_color = INK_SOFT
p.yaxis.major_tick_line_color = INK_SOFT
p.xaxis.minor_tick_line_color = None
p.yaxis.minor_tick_line_color = None

p.xgrid.grid_line_color = INK
p.ygrid.grid_line_color = INK
p.xgrid.grid_line_alpha = 0.12
p.ygrid.grid_line_alpha = 0.12

# Size legend — anchored above the main data cluster (top region is empty due to correlation)
legend_cx = x_start + x_range * 0.22
legend_top = y_end - y_range * 0.04
y_step = y_range * 0.07

ref_green = [green_space.min(), (green_space.min() + green_space.max()) / 2, green_space.max()]
ref_norm = [(v - green_space.min()) / (green_space.max() - green_space.min()) for v in ref_green]
ref_sizes = [np.sqrt(size_min**2 + (size_max**2 - size_min**2) * n) for n in ref_norm]
ref_labels = [f"{v:.0f} m²/capita" for v in ref_green]

legend_box = BoxAnnotation(
    left=legend_cx - x_range * 0.13,
    right=legend_cx + x_range * 0.13,
    top=legend_top + y_range * 0.01,
    bottom=legend_top - y_step * 3.8,
    fill_color=ELEVATED_BG,
    fill_alpha=0.9,
    line_color=INK_SOFT,
    line_alpha=0.4,
)
p.add_layout(legend_box)

p.add_layout(
    Label(
        x=legend_cx,
        y=legend_top - y_range * 0.01,
        text="Green Space",
        text_font_size="30pt",
        text_font_style="bold",
        text_color=INK,
        text_align="center",
    )
)

for i, (sz, lbl, gv) in enumerate(zip(ref_sizes, ref_labels, ref_green, strict=True)):
    ly = legend_top - y_step * (i + 0.85)
    ref_src = ColumnDataSource(data={"x": [legend_cx - x_range * 0.04], "y": [ly], "size": [sz], "green_space": [gv]})
    p.scatter(
        x="x",
        y="y",
        size="size",
        source=ref_src,
        fill_color=transform("green_space", color_mapper),
        fill_alpha=0.65,
        line_color=PAGE_BG,
        line_width=2,
    )
    p.add_layout(
        Label(
            x=legend_cx + x_range * 0.01,
            y=ly,
            text=lbl,
            text_font_size="26pt",
            text_baseline="middle",
            text_color=INK_SOFT,
        )
    )

# Save HTML
output_file(f"plot-{THEME}.html")
save(p)

# Save PNG via headless Chrome — use captureBeyondViewport so browser chrome
# overhead (~139px) doesn't truncate the canvas height
W, H = 3200, 1800
opts = Options()
for arg in (
    "--headless=new",
    "--no-sandbox",
    "--disable-dev-shm-usage",
    "--disable-gpu",
    f"--window-size={W},{H}",
    "--hide-scrollbars",
):
    opts.add_argument(arg)
driver = webdriver.Chrome(options=opts)
driver.set_window_size(W, H)
driver.get(f"file://{Path(f'plot-{THEME}.html').resolve()}")
time.sleep(3)
screenshot = driver.execute_cdp_cmd("Page.captureScreenshot", {"format": "png", "captureBeyondViewport": True})
driver.quit()
Image.open(io.BytesIO(base64.b64decode(screenshot["data"]))).save(f"plot-{THEME}.png")

Part of Basic Bubble Chart on anyplot.ai.

Other implementations