{"spec_id":"bar-spine","library":"muix","language":"javascript","code":"// anyplot.ai\n// bar-spine: Spine Plot for Two-Variable Proportions\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 95/100 | Updated: 2026-09-27\nimport { BarChart } from \"@mui/x-charts/BarChart\";\n\nconst t = window.ANYPLOT_TOKENS;\nconst size = window.ANYPLOT_SIZE;\nconst INK_MUTED = window.ANYPLOT_THEME === \"light\" ? \"#6B6A63\" : \"#A8A79F\";\n\n// Segment labels: a redundant text cue on top of stacking position so the\n// three-way split doesn't rely on hue alone. Fixed (theme-independent) fill\n// colors are picked per series for contrast against that series' fixed data\n// color, not the page theme.\nconst MIN_LABEL_HEIGHT = 22; // px; hide the label if a segment can't fit one line of text\nconst segmentLabel = (item, context) =>\n  context.bar.height < MIN_LABEL_HEIGHT ? null : `${item.value}%`;\nconst LABEL_FILL = { retained: \"#1A1A17\", downgraded: \"#1A1A17\", churned: \"#FAF8F1\" };\nconst labelSlotProps = (ownerState) => ({\n  style: { fill: LABEL_FILL[ownerState.seriesId], fontSize: 12, fontWeight: 600 },\n});\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Customer counts per subscription tier, split into three outcomes: churned\n// (cancelled), downgraded (moved to a cheaper tier but stayed a customer),\n// and retained (stayed at the same tier). Bar WIDTH encodes each tier's\n// marginal share of the customer base; segment HEIGHT encodes the\n// conditional outcome split within that tier — the three-category split\n// showcases the spine plot's conditional-distribution feature better than a\n// binary churned/retained split would.\nconst tiers = [\n  { name: \"Free\", churned: 1664, downgraded: 900, retained: 2636 },\n  { name: \"Basic\", churned: 651, downgraded: 500, retained: 1949 },\n  { name: \"Pro\", churned: 216, downgraded: 180, retained: 1404 },\n  { name: \"Enterprise\", churned: 42, downgraded: 20, retained: 638 },\n];\n\nconst totals = tiers.map((d) => d.churned + d.downgraded + d.retained);\nconst grandTotal = totals.reduce((sum, v) => sum + v, 0);\nconst churnPct = tiers.map((d, i) => Math.round((d.churned / totals[i]) * 1000) / 10);\nconst downgradePct = tiers.map((d, i) => Math.round((d.downgraded / totals[i]) * 1000) / 10);\n// Retained is the remainder, guaranteeing each stack sums to exactly 100%.\nconst retainPct = tiers.map((_, i) => Math.round((100 - churnPct[i] - downgradePct[i]) * 10) / 10);\n\n// --- Layout: variable-width columns, each a single 100%-stacked bar --------\nconst PAD_H = 32;\nconst PAD_V = 28;\nconst GAP = 10;\nconst AXIS_LABEL_W = 26; // rotated \"Share of customers\" caption, drawn outside the chart SVGs\nconst TICK_W = 52; // reserved for the shared y-axis tick numbers, drawn only on the first column\nconst TITLE_H = 34;\nconst LEGEND_H = 28;\nconst CAPTION_H = 24;\n\nconst rowWidth = size.width - PAD_H * 2;\nconst barAreaWidth = rowWidth - AXIS_LABEL_W - TICK_W;\nconst barWidths = totals.map((total) => Math.round((barAreaWidth * total) / grandTotal));\nbarWidths[barWidths.length - 1] += barAreaWidth - barWidths.reduce((sum, w) => sum + w, 0);\nconst chartWidths = barWidths.map((w, i) => (i === 0 ? TICK_W + w : w));\nconst chartHeight = size.height - PAD_V * 2 - TITLE_H - LEGEND_H - CAPTION_H - GAP * 3;\n\nconst title = \"Customer Churn by Subscription Tier · bar-spine · javascript · muix · anyplot.ai\";\nconst titleFontSize = Math.round(18 * (title.length > 67 ? 67 / title.length : 1));\n\n// Amber (downgraded) falls below WCAG 3:1 contrast on the cream background —\n// a thin ink-color stroke on that series only keeps the segment boundary\n// legible without adding noise to the higher-contrast green/red segments.\nconst barSx = { \"& .MuiBarElement-series-downgraded\": { stroke: t.ink, strokeWidth: 1 } };\n\n// Reference ticks at 25/50/75%, drawn as an overlay ON TOP of the bars (MUI\n// X's own `grid` prop paints behind the bar fills, invisible on a\n// fully-opaque 100%-stack) so readers can gauge the wide Free bar's segment\n// boundaries without tracing back to the shared y-axis. Kept short and\n// confined to the axis edge of the (widest) Free bar — its percentage labels\n// sit horizontally centered, well clear of this reach — so the ticks never\n// cross a label.\nconst MARGIN_TOP = 8;\nconst MARGIN_BOTTOM = 26;\nconst REF_TICK_W = 130;\nconst plotAreaHeight = chartHeight - MARGIN_TOP - MARGIN_BOTTOM;\nconst refLines = [75, 50, 25].map((pct) => MARGIN_TOP + ((100 - pct) / 100) * plotAreaHeight);\n\n// --- Chart (default-exported component — the harness mounts it) -------------\nexport default function Chart() {\n  return (\n    <div\n      style={{\n        width: size.width,\n        height: size.height,\n        padding: `${PAD_V}px ${PAD_H}px`,\n        boxSizing: \"border-box\",\n        display: \"flex\",\n        flexDirection: \"column\",\n        gap: GAP,\n        fontFamily: \"system-ui, sans-serif\",\n      }}\n    >\n      <div style={{ height: TITLE_H, fontSize: titleFontSize, fontWeight: 600, color: t.ink }}>\n        {title}\n      </div>\n\n      <div\n        style={{\n          height: LEGEND_H,\n          display: \"flex\",\n          alignItems: \"center\",\n          gap: 20,\n          fontSize: 13,\n          color: t.inkSoft,\n        }}\n      >\n        <span style={{ display: \"flex\", alignItems: \"center\", gap: 6 }}>\n          <span style={{ width: 11, height: 11, background: t.palette[0], display: \"inline-block\" }} />\n          Retained\n        </span>\n        <span style={{ display: \"flex\", alignItems: \"center\", gap: 6 }}>\n          <span\n            style={{\n              width: 11,\n              height: 11,\n              background: t.amber,\n              border: `1px solid ${t.ink}`,\n              boxSizing: \"border-box\",\n              display: \"inline-block\",\n            }}\n          />\n          Downgraded\n        </span>\n        <span style={{ display: \"flex\", alignItems: \"center\", gap: 6 }}>\n          <span style={{ width: 11, height: 11, background: t.palette[4], display: \"inline-block\" }} />\n          Churned\n        </span>\n        <span style={{ marginLeft: \"auto\" }}>n = {grandTotal.toLocaleString(\"en-US\")} customers</span>\n      </div>\n\n      <div style={{ display: \"flex\", height: chartHeight, position: \"relative\" }}>\n        <div\n          style={{\n            width: AXIS_LABEL_W,\n            height: chartHeight,\n            display: \"flex\",\n            alignItems: \"center\",\n            justifyContent: \"center\",\n            flexShrink: 0,\n          }}\n        >\n          <span\n            style={{\n              display: \"inline-block\",\n              transform: \"rotate(-90deg)\",\n              whiteSpace: \"nowrap\",\n              fontSize: 13,\n              color: t.inkSoft,\n            }}\n          >\n            Share of customers\n          </span>\n        </div>\n        {tiers.map((tier, i) => (\n          <BarChart\n            key={tier.name}\n            width={chartWidths[i]}\n            height={chartHeight}\n            skipAnimation\n            margin={{ top: MARGIN_TOP, right: 0, bottom: MARGIN_BOTTOM, left: i === 0 ? TICK_W : 0 }}\n            sx={barSx}\n            xAxis={[\n              {\n                scaleType: \"band\",\n                data: [tier.name],\n                categoryGapRatio: 0,\n                tickLabelStyle: { fontSize: 13 },\n                disableTicks: true,\n                disableLine: true,\n              },\n            ]}\n            yAxis={[\n              {\n                min: 0,\n                max: 100,\n                disableTicks: i !== 0,\n                disableLine: i !== 0,\n                valueFormatter: i === 0 ? (v) => `${v}%` : () => \"\",\n                tickLabelStyle: { fontSize: 12 },\n              },\n            ]}\n            series={[\n              {\n                id: \"retained\",\n                data: [retainPct[i]],\n                label: \"Retained\",\n                stack: \"total\",\n                color: t.palette[0],\n              },\n              {\n                id: \"downgraded\",\n                data: [downgradePct[i]],\n                label: \"Downgraded\",\n                stack: \"total\",\n                color: t.amber,\n              },\n              {\n                id: \"churned\",\n                data: [churnPct[i]],\n                label: \"Churned\",\n                stack: \"total\",\n                color: t.palette[4],\n              },\n            ]}\n            barLabel={segmentLabel}\n            slotProps={{ legend: { hidden: true }, barLabel: labelSlotProps }}\n          />\n        ))}\n        <div\n          style={{\n            position: \"absolute\",\n            left: AXIS_LABEL_W + TICK_W,\n            width: REF_TICK_W,\n            top: 0,\n            height: chartHeight,\n            pointerEvents: \"none\",\n          }}\n        >\n          {refLines.map((top) => (\n            <div\n              key={top}\n              style={{ position: \"absolute\", left: 0, right: 0, top, borderTop: `1px solid ${t.grid}` }}\n            />\n          ))}\n        </div>\n      </div>\n\n      <div style={{ height: CAPTION_H, fontSize: 12, fontStyle: \"italic\", color: INK_MUTED }}>\n        Bar width ∝ tier size · segment height = conditional retained/downgraded/churned rate within\n        tier\n      </div>\n    </div>\n  );\n}\n"}