{"spec_id":"bubble-basic","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nbubble-basic: Basic Bubble Chart\nLibrary: altair 6.3.0 | Python 3.13.15\nQuality: 91/100 | Created: 2026-09-30\n\"\"\"\n\nimport os\n\nimport altair as alt\nimport numpy as np\nimport pandas as pd\nfrom PIL import Image\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 (canonical order)\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\nstage_colors = IMPRINT_PALETTE[:4]\n\n# Data — tech startup metrics: funding vs revenue, sized by segment share, colored by stage\nnp.random.seed(42)\nn = 49\n\nstages = np.random.choice([\"Seed\", \"Series A\", \"Series B\", \"Growth\"], size=n, p=[0.25, 0.30, 0.25, 0.20])\n\nstage_funding = {\"Seed\": (6, 3.5), \"Series A\": (15, 5.5), \"Series B\": (40, 9), \"Growth\": (60, 12)}\nfunding_m = np.array([np.random.normal(*stage_funding[s]) for s in stages])\nfunding_m = np.clip(funding_m, 1, 80)\n\nrevenue_m = funding_m * np.random.uniform(0.6, 1.6, size=n) + np.random.normal(5, 3.5, size=n)\nrevenue_m = np.clip(revenue_m, 2, 118)\n\n# Segment share (%) of each company's own addressable segment, not a single global\n# market — so per-stage totals need not sum to 100%. Drawn independently of Stage\n# (a single wide distribution) so bubble size carries genuinely new information\n# rather than tracking the color-encoded cohort.\nmarket_share = np.clip(np.random.normal(30, 13, size=n), 8, 62)\n\ndf = pd.DataFrame(\n    {\n        \"Funding ($M)\": np.round(funding_m, 1),\n        \"Revenue ($M)\": np.round(revenue_m, 1),\n        \"Segment Share (%)\": np.round(market_share, 1),\n        \"Stage\": pd.Categorical(stages, categories=[\"Seed\", \"Series A\", \"Series B\", \"Growth\"], ordered=True),\n    }\n)\n\n# Add two counter-examples so funding and revenue don't form a single\n# near-linear cloud: a high-funded, low-revenue laggard, and a\n# capital-efficient, low-funded high-revenue climber.\noutlier_laggard = pd.DataFrame(\n    {\n        \"Funding ($M)\": [68.5],\n        \"Revenue ($M)\": [7.2],\n        \"Segment Share (%)\": [22.0],\n        \"Stage\": pd.Categorical([\"Series B\"], categories=[\"Seed\", \"Series A\", \"Series B\", \"Growth\"], ordered=True),\n    }\n)\noutlier_climber = pd.DataFrame(\n    {\n        \"Funding ($M)\": [11.0],\n        \"Revenue ($M)\": [54.0],\n        \"Segment Share (%)\": [31.0],\n        \"Stage\": pd.Categorical([\"Series A\"], categories=[\"Seed\", \"Series A\", \"Series B\", \"Growth\"], ordered=True),\n    }\n)\ndf = pd.concat([df, outlier_laggard, outlier_climber], ignore_index=True)\n\ntitle = \"bubble-basic · python · altair · anyplot.ai\"\n\n# Legend-bound point selection — clicking a Stage in the legend spotlights that\n# cohort by dimming the rest, an interactive capability distinctive to Altair's\n# Vega-Lite selection grammar (preserved in the saved interactive HTML). The\n# default \"select all\" state renders every bubble at the same 0.5 opacity, so\n# the static PNG shows no highlight — only the saved HTML is interactive.\nstage_selection = alt.selection_point(fields=[\"Stage\"], bind=\"legend\")\n\n# Plot — bubble layer\nbubbles = (\n    alt.Chart(df)\n    .mark_circle(stroke=PAGE_BG, strokeWidth=2)\n    .encode(\n        order=alt.Order(\n            \"Segment Share (%):Q\", sort=\"descending\"\n        ),  # largest bubbles drawn first (behind), smallest on top so overlapping clusters stay legible\n        x=alt.X(\n            \"Funding ($M):Q\", scale=alt.Scale(domain=[0, 85], nice=False), axis=alt.Axis(domain=False, ticks=False)\n        ),\n        y=alt.Y(\n            \"Revenue ($M):Q\", scale=alt.Scale(domain=[0, 125], nice=False), axis=alt.Axis(domain=False, ticks=False)\n        ),\n        size=alt.Size(\n            \"Segment Share (%):Q\",\n            scale=alt.Scale(range=[50, 1500], domain=[8, 62]),\n            legend=alt.Legend(\n                title=\"Segment Share (%)\",\n                titleFontSize=11,\n                labelFontSize=12,\n                values=[10, 25, 40, 55],\n                symbolFillColor=INK_SOFT,  # neutral fill (not a categorical hue) - the size legend applies to every Stage, not just Seed\n                symbolStrokeColor=PAGE_BG,\n                symbolOpacity=0.65,\n                direction=\"vertical\",\n            ),\n        ),\n        color=alt.Color(\n            \"Stage:N\",\n            scale=alt.Scale(domain=[\"Seed\", \"Series A\", \"Series B\", \"Growth\"], range=stage_colors),\n            legend=alt.Legend(\n                title=\"Stage\",\n                titleFontSize=11,\n                labelFontSize=12,\n                symbolType=\"circle\",\n                symbolSize=200,\n                symbolStrokeWidth=0,\n                symbolOpacity=0.65,\n            ),\n        ),\n        opacity=alt.condition(stage_selection, alt.value(0.5), alt.value(0.1)),\n        tooltip=[\"Stage:N\", \"Funding ($M):Q\", \"Revenue ($M):Q\", \"Segment Share (%):Q\"],\n    )\n    .add_params(stage_selection)\n)\n\nchart = (\n    bubbles.properties(\n        width=620,\n        height=320,\n        background=PAGE_BG,\n        padding={\"left\": 0, \"right\": 0, \"top\": 0, \"bottom\": 0},\n        title=alt.Title(\n            title,\n            fontSize=16,\n            fontWeight=\"bold\",\n            color=INK,\n            anchor=\"middle\",\n            subtitle=\"Tech Startup Metrics — Funding vs Revenue by Stage & Segment Share\",\n            subtitleFontSize=12,\n            subtitleColor=INK_SOFT,\n            subtitlePadding=4,\n        ),\n    )\n    .configure_view(fill=PAGE_BG, strokeWidth=0, continuousWidth=620, continuousHeight=320)\n    .configure_axis(\n        tickColor=INK_SOFT,\n        gridColor=INK,\n        gridOpacity=0.15,\n        labelColor=INK_SOFT,\n        titleColor=INK,\n        labelFontSize=12,\n        titleFontSize=12,\n    )\n    .configure_legend(\n        fillColor=ELEVATED_BG, strokeWidth=0, labelColor=INK_SOFT, titleColor=INK, orient=\"right\", padding=10\n    )\n)\n\n# Save PNG\nchart.save(f\"plot-{THEME}.png\", scale_factor=4.0)\n\n# PAD to exact 3200×1800 (do not crop — cropping clips title/axis labels)\nTW, TH = 3200, 1800\n_img = Image.open(f\"plot-{THEME}.png\").convert(\"RGB\")\n_w, _h = _img.size\nif _w > TW or _h > TH:\n    raise SystemExit(\n        f\"altair vl-convert produced {_w}×{_h}, exceeds target {TW}×{TH}. \"\n        f\"Shrink chart .properties(width=, height=) values and re-render.\"\n    )\nif _w < TW or _h < TH:\n    _canvas = Image.new(\"RGB\", (TW, TH), PAGE_BG)\n    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))\n    _canvas.save(f\"plot-{THEME}.png\")\n\n# Save HTML\nchart.save(f\"plot-{THEME}.html\")\n"}