Basic Bubble Chart in Pygal (Python)

The same plot in 14 other libraries — Python: Altair, Bokeh, lets-plot, Matplotlib, Plotly, plotnine, Seaborn; R: ggplot2; Julia: Makie.jl; JavaScript: Chart.js, D3.js, Apache ECharts, Highcharts, MUI X Charts. Compare all 15 side by side: Basic Bubble Chart in Python, R, Julia and JavaScript.

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 Pygal

Renders

Python source (Pygal)

""" anyplot.ai
bubble-basic: Basic Bubble Chart
Library: pygal 3.1.3 | Python 3.13.15
Quality: 88/100 | Created: 2026-09-27
"""

import os
import sys


# Remove the script's own directory from sys.path so `import pygal` resolves to the
# installed package rather than this file (which shares the package name).
_here = os.path.dirname(os.path.abspath(__file__))
sys.path = [p for p in sys.path if os.path.abspath(p) != _here]

import numpy as np
import pygal
from pygal.style import Style


# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
_r, _g, _b = int(INK_MUTED[1:3], 16), int(INK_MUTED[3:5], 16), int(INK_MUTED[5:7], 16)
GRID = f"rgba({_r}, {_g}, {_b}, 0.35)"  # lighter, theme-adaptive gridlines (pygal defaults to a hardcoded black)

# Data — Transit coverage vs congestion, bubble = vehicle registrations per 1,000 residents
np.random.seed(42)
n_cities = 50

# Uniform spread across the coverage range to avoid clustering. The low-coverage
# band is widened further (5-45 vs. the previous 8-40) to thin out the densest
# overlap region in the top-left of the chart, where transit coverage is low and
# congestion is high.
transit_coverage = np.concatenate(
    [np.random.uniform(5, 45, 15), np.random.uniform(45, 70, 20), np.random.uniform(70, 95, 15)]
)
np.random.shuffle(transit_coverage)

congestion_index = np.clip(85 - transit_coverage * 0.65 + np.random.normal(0, 9, n_cities), 15, 95)
vehicle_registrations = np.clip(200 + congestion_index * 4 + np.random.normal(0, 60, n_cities), 150, 700)

# Area-scaled bubble sizing via 7 tiers (pygal has no per-point size API).
# Quantile-based bin edges (rather than equal-width) keep tiers evenly
# populated, so no single tier absorbs most of the dense mid-chart cluster.
vr_min, vr_max = vehicle_registrations.min(), vehicle_registrations.max()

n_tiers = 7
bin_edges_vr = np.quantile(vehicle_registrations, np.linspace(0, 1, n_tiers + 1))
tier_bins = np.clip(np.digitize(vehicle_registrations, bin_edges_vr[1:-1]), 0, n_tiers - 1)

# sqrt of each tier's actual midpoint value (not tier index) for accurate area scaling.
# Widened range (18-130, up from 20-120) so adjacent tiers stay visually separable
# as nested rings when bubbles overlap in the dense low-coverage cluster.
tier_mid_norm = [((bin_edges_vr[t] + bin_edges_vr[t + 1]) / 2 - vr_min) / (vr_max - vr_min) for t in range(n_tiers)]
tier_sizes = [int(18 + 112 * norm**0.5) for norm in tier_mid_norm]

# Imprint sequential colormap (imprint_seq): #009E73 (brand green) → #4467A3 (blue), equidistant stops
tier_colors = tuple(
    "#{:02X}{:02X}{:02X}".format(
        round(0x00 + (0x44 - 0x00) * i / (n_tiers - 1)),
        round(0x9E + (0x67 - 0x9E) * i / (n_tiers - 1)),
        round(0x73 + (0xA3 - 0x73) * i / (n_tiers - 1)),
    )
    for i in range(n_tiers)
)

# ANYPLOT_AMBER marks the focal city (6th color in the series cycle)
ANYPLOT_AMBER = "#DDCC77"
style_colors = tier_colors + (ANYPLOT_AMBER,)

# Tier labels serve as the size legend (vehicle-registration quantile ranges).
# The small->large ordering itself is explained once in the x-axis title
# below, instead of repeating a descriptor word in every one of the 7 legend
# entries — spelling it out per-entry overflowed the 4-column legend layout.
tier_labels = [f"{bin_edges_vr[t]:.0f}–{bin_edges_vr[t + 1]:.0f} vehicles/1,000" for t in range(n_tiers)]

# Focal city: highest congestion despite the data spread — visual anchor for data storytelling
focal_idx = int(np.argmax(congestion_index))
focal_tier = int(tier_bins[focal_idx])

# Group cities by vehicle-registration tier, excluding focal city
tier_data = {t: [] for t in range(n_tiers)}
for i in range(n_cities):
    if i == focal_idx:
        continue
    t = int(tier_bins[i])
    tier_data[t].append({"value": (round(float(transit_coverage[i]), 1), round(float(congestion_index[i]), 1))})

# `label` metadata (only set here, on the single focal point) drives pygal's
# `print_labels` static text overlay — the on-canvas annotation naming the
# hotspot's exact metrics. Every other point omits `label` so the overlay
# stays limited to this one bubble instead of cluttering all 50.
focal_point_data = [
    {
        "value": (round(float(transit_coverage[focal_idx]), 1), round(float(congestion_index[focal_idx]), 1)),
        "label": (
            f"★ Congestion Hotspot: {transit_coverage[focal_idx]:.0f}% coverage, "
            f"{congestion_index[focal_idx]:.0f} congestion index"
        ),
    }
]

# Style — theme-adaptive, anyplot sizing
custom_style = Style(
    background=PAGE_BG,
    plot_background=PAGE_BG,
    foreground=INK,
    foreground_strong=INK,
    foreground_subtle=INK_MUTED,
    colors=style_colors,
    guide_stroke_color=GRID,
    major_guide_stroke_color=GRID,
    # `dot_opacity` (not `opacity`) governs the static fill/stroke alpha of XY
    # scatter dots — pygal's `.dot` CSS rule cascades after `.reactive`, so
    # `opacity` alone left bubbles fully solid in the previous pass despite the
    # spec's alpha-transparency requirement. `opacity_hover` still applies via
    # `.reactive.active` since `.dot` has no active-state fill-opacity rule.
    # Lowered further (0.58 -> 0.48) so individual bubbles stay traceable in the
    # densest low-coverage cluster, where several tiers still overlap.
    dot_opacity=0.48,
    opacity=0.70,
    opacity_hover=0.95,
    title_font_size=66,
    label_font_size=56,
    major_label_font_size=44,
    legend_font_size=44,
    value_font_size=36,
    # `value_label_font_size` (not `value_font_size`) governs the `.text-overlay
    # text.label` CSS rule that print_labels renders into — a separate style
    # token pygal defaults to 10px regardless of the other font sizes above.
    # Left unset, the hotspot annotation fell back to that 10px default no
    # matter how large title/label/legend were configured.
    value_label_font_size=40,
    tooltip_font_size=36,
    title_font_family="Helvetica Neue, Helvetica, Arial, sans-serif",
    label_font_family="Helvetica Neue, Helvetica, Arial, sans-serif",
    major_label_font_family="Helvetica Neue, Helvetica, Arial, sans-serif",
    legend_font_family="Helvetica Neue, Helvetica, Arial, sans-serif",
    value_label_font_family="Helvetica Neue, Helvetica, Arial, sans-serif",
)

# Plot
chart = pygal.XY(
    width=3200,
    height=1800,
    style=custom_style,
    title="bubble-basic · python · pygal · anyplot.ai",
    x_title="Public Transit Coverage (%)  —  bubble size ↔ vehicle registrations",
    y_title="Traffic Congestion Index (0–100 scale)",
    show_legend=True,
    legend_at_bottom=True,
    legend_at_bottom_columns=4,
    legend_box_size=36,
    stroke=False,
    dots_size=20,
    show_x_guides=True,
    show_y_guides=True,
    x_value_formatter=lambda x: f"{x:.0f}%",
    value_formatter=lambda x: f"{x:.0f}",
    margin_top=80,
    margin_bottom=200,
    margin_left=100,
    margin_right=80,
    tooltip_border_radius=8,
    tooltip_fancy_mode=True,
    print_values=False,
    print_labels=True,
    truncate_legend=30,
    spacing=30,
)

# Thin page-background "halo" ring around every bubble keeps overlapping dots
# individually traceable instead of melting into one blob. graph.css's default
# `.dot` rule ties stroke to the fill color, and its per-series `.text-overlay
# .color-N text` rule hardcodes black for the print_labels annotation — both
# need an override at matching (or higher) CSS specificity, scoped to this
# chart's own id-selector, appended last so source order wins any tie.
chart.css = (
    "file://style.css",
    "file://graph.css",
    # stroke-width bumped 3px -> 6px: a thicker halo ring stays a visible
    # boundary even in the densest overlap zone (low transit coverage).
    f"inline:#chart-{chart.uuid} .dot {{ stroke: {PAGE_BG}; stroke-width: 6px; stroke-opacity: 1; }}",
    f"inline:#chart-{chart.uuid} .text-overlay text.label {{ fill: {INK}; }}",
)

for t in range(n_tiers):
    chart.add(tier_labels[t], tier_data[t] if tier_data[t] else [], dots_size=tier_sizes[t])

# Focal city rendered last in ANYPLOT_AMBER — stands out as a visual anchor
chart.add("★ Congestion Hotspot", focal_point_data, dots_size=tier_sizes[focal_tier] + 8)

# Save
chart.render_to_file(f"plot-{THEME}.html")
chart.render_to_png(f"plot-{THEME}.png")

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/bubble-basic/pygal/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "bubble-basic",
  "language": "python",
  "library": "pygal",
  "page": "https://anyplot.ai/bubble-basic/python/pygal",
  "hub": "https://anyplot.ai/bubble-basic",
  "code_json": "https://api.anyplot.ai/specs/bubble-basic/pygal/code",
  "spec_json": "https://api.anyplot.ai/specs/bubble-basic",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/bubble-basic/python/pygal/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/bubble-basic/python/pygal/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/bubble-basic/python/pygal/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/bubble-basic/python/pygal/plot-dark.html",
  "quality_score": 88.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

Part of Basic Bubble Chart on anyplot.ai.

Other implementations