{"spec_id":"bar-spine","library":"plotnine","language":"python","code":"\"\"\" anyplot.ai\nbar-spine: Spine Plot for Two-Variable Proportions\nLibrary: plotnine 0.15.8 | Python 3.13.15\nQuality: 89/100 | Updated: 2026-09-27\n\"\"\"\n\nimport os\n\nimport pandas as pd\nfrom plotnine import (\n    aes,\n    element_blank,\n    element_line,\n    element_rect,\n    element_text,\n    geom_rect,\n    geom_text,\n    ggplot,\n    labs,\n    scale_fill_manual,\n    scale_x_continuous,\n    scale_y_continuous,\n    theme,\n)\n\n\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 — position 1 always the brand green\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\", \"#AE3030\", \"#2ABCCD\", \"#954477\", \"#99B314\"]\n\n# Data — e-commerce order outcomes by product category.\n# Categories are ordered by return rate, worst offender first — the story is\n# \"where do returns hurt most\", so the eye lands on Apparel before Books.\norders_by_category = {\n    \"Apparel\": {\"Kept\": 780, \"Exchanged\": 120, \"Refunded\": 300},\n    \"Electronics\": {\"Kept\": 650, \"Exchanged\": 70, \"Refunded\": 180},\n    \"Home & Garden\": {\"Kept\": 410, \"Exchanged\": 20, \"Refunded\": 70},\n    \"Toys\": {\"Kept\": 320, \"Exchanged\": 5, \"Refunded\": 25},\n    \"Books\": {\"Kept\": 240, \"Exchanged\": 2, \"Refunded\": 8},\n}\ncategory_order = list(orders_by_category)\noutcome_order = [\"Kept\", \"Exchanged\", \"Refunded\"]\n\ndf = pd.DataFrame(\n    [\n        {\"category\": category, \"outcome\": outcome, \"orders\": count}\n        for category, counts in orders_by_category.items()\n        for outcome, count in counts.items()\n    ]\n)\n\n# Marginal totals and bar widths (width ∝ order volume per category)\ntotals = df.groupby(\"category\", sort=False)[\"orders\"].sum().reindex(category_order).reset_index()\ntotals.columns = [\"category\", \"total\"]\ngrand_total = totals[\"total\"].sum()\ntotals[\"width\"] = totals[\"total\"] / grand_total\ntotals[\"xmax\"] = totals[\"width\"].cumsum()\ntotals[\"xmin\"] = totals[\"xmax\"] - totals[\"width\"]\ntotals[\"xcenter\"] = (totals[\"xmin\"] + totals[\"xmax\"]) / 2\n\n# Merge widths into main dataframe\ndf = df.merge(totals[[\"category\", \"total\", \"xmin\", \"xmax\", \"xcenter\"]], on=\"category\")\ndf[\"prop\"] = df[\"orders\"] / df[\"total\"]\n\n# Sort and compute cumulative y positions (conditional proportions)\ndf[\"category\"] = pd.Categorical(df[\"category\"], categories=category_order, ordered=True)\ndf[\"outcome\"] = pd.Categorical(df[\"outcome\"], categories=outcome_order, ordered=True)\ndf = df.sort_values([\"category\", \"outcome\"]).reset_index(drop=True)\ndf[\"ymax\"] = df.groupby(\"category\", observed=True)[\"prop\"].cumsum()\ndf[\"ymin\"] = df[\"ymax\"] - df[\"prop\"]\ndf[\"ylabel\"] = (df[\"ymin\"] + df[\"ymax\"]) / 2\ndf[\"label\"] = df[\"prop\"].apply(lambda p: f\"{p:.0%}\" if p >= 0.06 else \"\")\n\n# X-axis: one tick per category, centered under each variable-width bar\nx_breaks = totals[\"xcenter\"].tolist()\nx_labels = [f\"{c}\\n(n={t:,})\" for c, t in zip(totals[\"category\"], totals[\"total\"], strict=True)]\n\nanyplot_theme = theme(\n    figure_size=(8, 4.5),\n    text=element_text(size=7),\n    plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n    panel_background=element_rect(fill=PAGE_BG),\n    panel_grid_major=element_blank(),\n    panel_grid_minor=element_blank(),\n    panel_border=element_blank(),\n    axis_line=element_line(color=INK_SOFT, size=0.5),\n    axis_title=element_text(color=INK, size=10),\n    axis_text=element_text(color=INK_SOFT, size=8),\n    axis_ticks=element_line(color=INK_SOFT),\n    plot_title=element_text(color=INK, size=10, weight=\"bold\"),\n    legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n    legend_text=element_text(color=INK_SOFT, size=8),\n    legend_title=element_text(color=INK, size=8),\n    legend_key=element_rect(fill=ELEVATED_BG),\n)\n\n# Outcome -> Imprint slot: Kept keeps the mandatory brand green; Refunded is\n# the genuine dollar loss so it borrows the semantic-red anchor (slot 5)\n# instead of the next ordinal slot, making the costliest segment the one\n# that visually jumps out in every category.\nfill_values = {\"Kept\": IMPRINT[0], \"Exchanged\": IMPRINT[1], \"Refunded\": IMPRINT[4]}\n\n# Plot\nplot = (\n    ggplot(df)\n    + geom_rect(aes(xmin=\"xmin\", xmax=\"xmax\", ymin=\"ymin\", ymax=\"ymax\", fill=\"outcome\"), color=PAGE_BG, size=0.5)\n    + geom_text(aes(x=\"xcenter\", y=\"ylabel\", label=\"label\"), color=\"white\", size=2.8, fontweight=\"bold\")\n    + scale_fill_manual(values=fill_values, name=\"Outcome\", limits=outcome_order)\n    + scale_x_continuous(breaks=x_breaks, labels=x_labels, limits=(0, 1), expand=(0, 0))\n    + scale_y_continuous(\n        breaks=[0, 0.25, 0.5, 0.75, 1.0], labels=[\"0%\", \"25%\", \"50%\", \"75%\", \"100%\"], limits=(0, 1), expand=(0, 0)\n    )\n    + labs(\n        x=\"Product Category  (bar width ∝ order volume)\",\n        y=\"Share of Orders\",\n        title=\"Product Returns by Category · bar-spine · python · plotnine · anyplot.ai\",\n    )\n    + anyplot_theme\n)\n\n# Save\nplot.save(f\"plot-{THEME}.png\", dpi=400, width=8, height=4.5, units=\"in\")\n"}