{"spec_id":"bubble-basic","library":"matplotlib","language":"python","code":"\"\"\" anyplot.ai\nbubble-basic: Basic Bubble Chart\nLibrary: matplotlib 3.11.2 | Python 3.13.15\nQuality: 86/100 | Updated: 2026-09-30\n\"\"\"\n\nimport os\n\nimport matplotlib.pyplot as plt\nimport numpy as np\nfrom matplotlib.ticker import FuncFormatter, NullLocator\nfrom mpl_toolkits.axes_grid1.inset_locator import inset_axes\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\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Imprint palette — 8 hues, canonical order\nIMPRINT_PALETTE = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\n\n# Data — retail product portfolio: price vs quality rating, sales volume as bubble size\nnp.random.seed(42)\n\ncategory_specs = [\n    {\n        \"name\": \"Electronics\",\n        \"price_mean\": 320,\n        \"price_std\": 170,\n        \"quality_mean\": 7.2,\n        \"quality_std\": 1.0,\n        \"sales_mean\": 45,\n        \"sales_std\": 22,\n        \"n\": 24,\n    },\n    {\n        \"name\": \"Furniture\",\n        \"price_mean\": 680,\n        \"price_std\": 240,\n        \"quality_mean\": 6.7,\n        \"quality_std\": 1.2,\n        \"sales_mean\": 18,\n        \"sales_std\": 9,\n        \"n\": 20,\n    },\n    {\n        \"name\": \"Kitchenware\",\n        \"price_mean\": 55,\n        \"price_std\": 30,\n        \"quality_mean\": 6.1,\n        \"quality_std\": 1.4,\n        \"sales_mean\": 68,\n        \"sales_std\": 32,\n        \"n\": 26,\n    },\n    {\n        \"name\": \"Outdoor Gear\",\n        \"price_mean\": 190,\n        \"price_std\": 85,\n        \"quality_mean\": 7.6,\n        \"quality_std\": 0.9,\n        \"sales_mean\": 30,\n        \"sales_std\": 14,\n        \"n\": 20,\n    },\n]\n\nprice_parts, quality_parts, sales_parts, category_parts = [], [], [], []\nfor spec in category_specs:\n    n = spec[\"n\"]\n    prices = np.random.normal(spec[\"price_mean\"], spec[\"price_std\"], n)\n    qualities = np.random.normal(spec[\"quality_mean\"], spec[\"quality_std\"], n)\n    sales = np.random.normal(spec[\"sales_mean\"], spec[\"sales_std\"], n)\n\n    # Explicit outlier pair per category — a bargain-bin low and a flagship\n    # high, derived from that category's own mean/std — so every category\n    # visibly spans its full realistic range instead of relying on luck.\n    bargain_price = max(spec[\"price_mean\"] - 2.2 * spec[\"price_std\"], 5)\n    flagship_price = spec[\"price_mean\"] + 2.6 * spec[\"price_std\"]\n    prices = np.append(prices, [bargain_price, flagship_price])\n    qualities = np.append(\n        qualities, [spec[\"quality_mean\"] - 1.6 * spec[\"quality_std\"], spec[\"quality_mean\"] + 1.7 * spec[\"quality_std\"]]\n    )\n    sales = np.append(\n        sales, [spec[\"sales_mean\"] + 1.4 * spec[\"sales_std\"], spec[\"sales_mean\"] - 0.9 * spec[\"sales_std\"]]\n    )\n\n    price_parts.append(prices)\n    quality_parts.append(qualities)\n    sales_parts.append(sales)\n    category_parts.extend([spec[\"name\"]] * (n + 2))\n\nprice = np.clip(np.concatenate(price_parts), 8, 1200)\nquality_rating = np.clip(np.concatenate(quality_parts), 1, 10)\nsales_volume = np.clip(np.concatenate(sales_parts), 3, 130)\ncategory = np.array(category_parts)\n\ncategory_names = [spec[\"name\"] for spec in category_specs]\ncategory_colors = IMPRINT_PALETTE[:4]\n\n# Scale bubble sizes by area for accurate visual perception (tuned for 3200×1800 canvas).\nsize_scaled = (sales_volume / sales_volume.max()) * 380 + 25\n\n# Plot\nfig, ax = plt.subplots(figsize=(8, 4.5), dpi=400, facecolor=PAGE_BG)\nax.set_facecolor(PAGE_BG)\n\nfor name, color in zip(category_names, category_colors, strict=True):\n    mask = category == name\n    ax.scatter(\n        price[mask],\n        quality_rating[mask],\n        s=size_scaled[mask],\n        alpha=0.5,\n        color=color,\n        edgecolors=PAGE_BG,\n        linewidths=0.8,\n        label=name,\n        zorder=3,\n    )\n\n# Size legend — a real inset panel (matplotlib-native technique), placed in the\n# one clear gap in the data (high price, low quality) so it never covers bubbles\nlegend_caps = [15, 60, 120]\nlegend_ax = inset_axes(\n    ax,\n    width=\"20%\",\n    height=\"30%\",\n    loc=\"lower left\",\n    bbox_to_anchor=(0.62, 0.04, 1, 1),\n    bbox_transform=ax.transAxes,\n    borderpad=0,\n)\nlegend_ax.set_facecolor(ELEVATED_BG)\nlegend_ax.set_xlim(0, 1)\nlegend_ax.set_ylim(0, 1)\nlegend_ax.set_xticks([])\nlegend_ax.set_yticks([])\nfor spine in legend_ax.spines.values():\n    spine.set_color(INK_SOFT)\n    spine.set_linewidth(0.8)\nlegend_ax.set_title(\"Sales Volume\", fontsize=10, fontweight=\"bold\", color=INK, loc=\"left\", pad=4)\nfor cap, y in zip(legend_caps, (0.18, 0.5, 0.84), strict=True):\n    legend_ax.scatter(\n        0.28,\n        y,\n        s=(cap / sales_volume.max()) * 380 + 25,\n        color=INK_MUTED,\n        alpha=0.5,\n        edgecolors=PAGE_BG,\n        linewidths=0.8,\n        transform=legend_ax.transAxes,\n        clip_on=False,\n    )\n    legend_ax.text(0.55, y, f\"{cap}K units\", fontsize=9, color=INK_SOFT, va=\"center\", transform=legend_ax.transAxes)\n\n# Category color legend — upper right, bold title leads the eye ahead of the body text\ncategory_legend = ax.legend(\n    fontsize=9,\n    loc=\"upper right\",\n    framealpha=1.0,\n    facecolor=ELEVATED_BG,\n    edgecolor=INK_SOFT,\n    title=\"Product Category\",\n    title_fontsize=10,\n    markerscale=0.7,\n    handletextpad=0.5,\n    borderpad=0.7,\n)\nplt.setp(category_legend.get_title(), color=INK, fontweight=\"bold\")\nplt.setp(category_legend.get_texts(), color=INK_SOFT)\n\n# Style — descriptive prefix clarifies the retail domain; title fontsize scales\n# down since the prefix pushes past the 67-char baseline the style-guide default\n# is tuned for.\ntitle = \"Retail Product Portfolio · bubble-basic · python · matplotlib · anyplot.ai\"\ntitle_fontsize = max(8, round(12 * 67 / len(title))) if len(title) > 67 else 12\nax.set_xlabel(\"Retail Price ($, log scale)\", fontsize=10, color=INK, labelpad=8)\nax.set_ylabel(\"Customer Quality Rating (1–10)\", fontsize=10, color=INK, labelpad=8)\nax.set_title(title, fontsize=title_fontsize, fontweight=\"medium\", color=INK, pad=12)\nax.tick_params(axis=\"both\", labelsize=8, labelcolor=INK_SOFT, length=0)\n\nfor spine in ax.spines.values():\n    spine.set_visible(False)\n\nax.yaxis.grid(True, alpha=0.15, linewidth=0.8, color=INK, zorder=0)\nax.xaxis.grid(False)\n\n# Log x-axis: retail price is heavily right-skewed (Kitchenware ~$55 mean vs\n# Furniture ~$680 mean), so a linear axis crushes the low-price cluster into an\n# unreadable clump. Log spacing gives that region room to breathe while keeping\n# the high-price tail proportionate.\nax.set_xscale(\"log\")\nax.set_xlim(7, 1400)\nax.set_xticks([10, 30, 100, 300, 1000])\nax.xaxis.set_minor_locator(NullLocator())\nax.xaxis.set_major_formatter(FuncFormatter(lambda v, _: f\"${v:,.0f}\"))\nax.set_ylim(1, 10.8)\n\nfig.subplots_adjust(left=0.09, right=0.97, top=0.92, bottom=0.12)\n\n# Save\nplt.savefig(f\"plot-{THEME}.png\", dpi=400, facecolor=PAGE_BG)\n"}