{"spec_id":"bubble-basic","library":"pygal","language":"python","code":"\"\"\" anyplot.ai\nbubble-basic: Basic Bubble Chart\nLibrary: pygal 3.1.3 | Python 3.13.15\nQuality: 88/100 | Created: 2026-09-27\n\"\"\"\n\nimport os\nimport sys\n\n\n# Remove the script's own directory from sys.path so `import pygal` resolves to the\n# installed package rather than this file (which shares the package name).\n_here = os.path.dirname(os.path.abspath(__file__))\nsys.path = [p for p in sys.path if os.path.abspath(p) != _here]\n\nimport numpy as np\nimport pygal\nfrom pygal.style import Style\n\n\n# Theme tokens\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n_r, _g, _b = int(INK_MUTED[1:3], 16), int(INK_MUTED[3:5], 16), int(INK_MUTED[5:7], 16)\nGRID = f\"rgba({_r}, {_g}, {_b}, 0.35)\"  # lighter, theme-adaptive gridlines (pygal defaults to a hardcoded black)\n\n# Data — Transit coverage vs congestion, bubble = vehicle registrations per 1,000 residents\nnp.random.seed(42)\nn_cities = 50\n\n# Uniform spread across the coverage range to avoid clustering. The low-coverage\n# band is widened further (5-45 vs. the previous 8-40) to thin out the densest\n# overlap region in the top-left of the chart, where transit coverage is low and\n# congestion is high.\ntransit_coverage = np.concatenate(\n    [np.random.uniform(5, 45, 15), np.random.uniform(45, 70, 20), np.random.uniform(70, 95, 15)]\n)\nnp.random.shuffle(transit_coverage)\n\ncongestion_index = np.clip(85 - transit_coverage * 0.65 + np.random.normal(0, 9, n_cities), 15, 95)\nvehicle_registrations = np.clip(200 + congestion_index * 4 + np.random.normal(0, 60, n_cities), 150, 700)\n\n# Area-scaled bubble sizing via 7 tiers (pygal has no per-point size API).\n# Quantile-based bin edges (rather than equal-width) keep tiers evenly\n# populated, so no single tier absorbs most of the dense mid-chart cluster.\nvr_min, vr_max = vehicle_registrations.min(), vehicle_registrations.max()\n\nn_tiers = 7\nbin_edges_vr = np.quantile(vehicle_registrations, np.linspace(0, 1, n_tiers + 1))\ntier_bins = np.clip(np.digitize(vehicle_registrations, bin_edges_vr[1:-1]), 0, n_tiers - 1)\n\n# sqrt of each tier's actual midpoint value (not tier index) for accurate area scaling.\n# Widened range (18-130, up from 20-120) so adjacent tiers stay visually separable\n# as nested rings when bubbles overlap in the dense low-coverage cluster.\ntier_mid_norm = [((bin_edges_vr[t] + bin_edges_vr[t + 1]) / 2 - vr_min) / (vr_max - vr_min) for t in range(n_tiers)]\ntier_sizes = [int(18 + 112 * norm**0.5) for norm in tier_mid_norm]\n\n# Imprint sequential colormap (imprint_seq): #009E73 (brand green) → #4467A3 (blue), equidistant stops\ntier_colors = tuple(\n    \"#{:02X}{:02X}{:02X}\".format(\n        round(0x00 + (0x44 - 0x00) * i / (n_tiers - 1)),\n        round(0x9E + (0x67 - 0x9E) * i / (n_tiers - 1)),\n        round(0x73 + (0xA3 - 0x73) * i / (n_tiers - 1)),\n    )\n    for i in range(n_tiers)\n)\n\n# ANYPLOT_AMBER marks the focal city (6th color in the series cycle)\nANYPLOT_AMBER = \"#DDCC77\"\nstyle_colors = tier_colors + (ANYPLOT_AMBER,)\n\n# Tier labels serve as the size legend (vehicle-registration quantile ranges).\n# The small->large ordering itself is explained once in the x-axis title\n# below, instead of repeating a descriptor word in every one of the 7 legend\n# entries — spelling it out per-entry overflowed the 4-column legend layout.\ntier_labels = [f\"{bin_edges_vr[t]:.0f}–{bin_edges_vr[t + 1]:.0f} vehicles/1,000\" for t in range(n_tiers)]\n\n# Focal city: highest congestion despite the data spread — visual anchor for data storytelling\nfocal_idx = int(np.argmax(congestion_index))\nfocal_tier = int(tier_bins[focal_idx])\n\n# Group cities by vehicle-registration tier, excluding focal city\ntier_data = {t: [] for t in range(n_tiers)}\nfor i in range(n_cities):\n    if i == focal_idx:\n        continue\n    t = int(tier_bins[i])\n    tier_data[t].append({\"value\": (round(float(transit_coverage[i]), 1), round(float(congestion_index[i]), 1))})\n\n# `label` metadata (only set here, on the single focal point) drives pygal's\n# `print_labels` static text overlay — the on-canvas annotation naming the\n# hotspot's exact metrics. Every other point omits `label` so the overlay\n# stays limited to this one bubble instead of cluttering all 50.\nfocal_point_data = [\n    {\n        \"value\": (round(float(transit_coverage[focal_idx]), 1), round(float(congestion_index[focal_idx]), 1)),\n        \"label\": (\n            f\"★ Congestion Hotspot: {transit_coverage[focal_idx]:.0f}% coverage, \"\n            f\"{congestion_index[focal_idx]:.0f} congestion index\"\n        ),\n    }\n]\n\n# Style — theme-adaptive, anyplot sizing\ncustom_style = Style(\n    background=PAGE_BG,\n    plot_background=PAGE_BG,\n    foreground=INK,\n    foreground_strong=INK,\n    foreground_subtle=INK_MUTED,\n    colors=style_colors,\n    guide_stroke_color=GRID,\n    major_guide_stroke_color=GRID,\n    # `dot_opacity` (not `opacity`) governs the static fill/stroke alpha of XY\n    # scatter dots — pygal's `.dot` CSS rule cascades after `.reactive`, so\n    # `opacity` alone left bubbles fully solid in the previous pass despite the\n    # spec's alpha-transparency requirement. `opacity_hover` still applies via\n    # `.reactive.active` since `.dot` has no active-state fill-opacity rule.\n    # Lowered further (0.58 -> 0.48) so individual bubbles stay traceable in the\n    # densest low-coverage cluster, where several tiers still overlap.\n    dot_opacity=0.48,\n    opacity=0.70,\n    opacity_hover=0.95,\n    title_font_size=66,\n    label_font_size=56,\n    major_label_font_size=44,\n    legend_font_size=44,\n    value_font_size=36,\n    # `value_label_font_size` (not `value_font_size`) governs the `.text-overlay\n    # text.label` CSS rule that print_labels renders into — a separate style\n    # token pygal defaults to 10px regardless of the other font sizes above.\n    # Left unset, the hotspot annotation fell back to that 10px default no\n    # matter how large title/label/legend were configured.\n    value_label_font_size=40,\n    tooltip_font_size=36,\n    title_font_family=\"Helvetica Neue, Helvetica, Arial, sans-serif\",\n    label_font_family=\"Helvetica Neue, Helvetica, Arial, sans-serif\",\n    major_label_font_family=\"Helvetica Neue, Helvetica, Arial, sans-serif\",\n    legend_font_family=\"Helvetica Neue, Helvetica, Arial, sans-serif\",\n    value_label_font_family=\"Helvetica Neue, Helvetica, Arial, sans-serif\",\n)\n\n# Plot\nchart = pygal.XY(\n    width=3200,\n    height=1800,\n    style=custom_style,\n    title=\"bubble-basic · python · pygal · anyplot.ai\",\n    x_title=\"Public Transit Coverage (%)  —  bubble size ↔ vehicle registrations\",\n    y_title=\"Traffic Congestion Index (0–100 scale)\",\n    show_legend=True,\n    legend_at_bottom=True,\n    legend_at_bottom_columns=4,\n    legend_box_size=36,\n    stroke=False,\n    dots_size=20,\n    show_x_guides=True,\n    show_y_guides=True,\n    x_value_formatter=lambda x: f\"{x:.0f}%\",\n    value_formatter=lambda x: f\"{x:.0f}\",\n    margin_top=80,\n    margin_bottom=200,\n    margin_left=100,\n    margin_right=80,\n    tooltip_border_radius=8,\n    tooltip_fancy_mode=True,\n    print_values=False,\n    print_labels=True,\n    truncate_legend=30,\n    spacing=30,\n)\n\n# Thin page-background \"halo\" ring around every bubble keeps overlapping dots\n# individually traceable instead of melting into one blob. graph.css's default\n# `.dot` rule ties stroke to the fill color, and its per-series `.text-overlay\n# .color-N text` rule hardcodes black for the print_labels annotation — both\n# need an override at matching (or higher) CSS specificity, scoped to this\n# chart's own id-selector, appended last so source order wins any tie.\nchart.css = (\n    \"file://style.css\",\n    \"file://graph.css\",\n    # stroke-width bumped 3px -> 6px: a thicker halo ring stays a visible\n    # boundary even in the densest overlap zone (low transit coverage).\n    f\"inline:#chart-{chart.uuid} .dot {{ stroke: {PAGE_BG}; stroke-width: 6px; stroke-opacity: 1; }}\",\n    f\"inline:#chart-{chart.uuid} .text-overlay text.label {{ fill: {INK}; }}\",\n)\n\nfor t in range(n_tiers):\n    chart.add(tier_labels[t], tier_data[t] if tier_data[t] else [], dots_size=tier_sizes[t])\n\n# Focal city rendered last in ANYPLOT_AMBER — stands out as a visual anchor\nchart.add(\"★ Congestion Hotspot\", focal_point_data, dots_size=tier_sizes[focal_tier] + 8)\n\n# Save\nchart.render_to_file(f\"plot-{THEME}.html\")\nchart.render_to_png(f\"plot-{THEME}.png\")\n"}