{"spec_id":"bar-spine","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// bar-spine: Spine Plot for Two-Variable Proportions\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 93/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Customer churn by subscription tier: bar WIDTH = marginal tier size (how many\n// customers are on that plan), segment HEIGHT = conditional retained/churned\n// split within the tier. Cheaper tiers churn far more than paid ones.\nconst tiers = [\"Free\", \"Basic\", \"Pro\", \"Enterprise\"];\nconst totals = [600, 900, 400, 150]; // customers per tier (marginal frequency)\nconst churned = [240, 198, 40, 6]; // churned customers per tier\nconst retained = totals.map((total, i) => total - churned[i]);\nconst retainedPct = retained.map((count, i) => (count / totals[i]) * 100);\nconst churnedPct = churned.map((count, i) => (count / totals[i]) * 100);\n\n// Cumulative edges along x so bar width is exactly proportional to tier size\n// and adjacent bars are flush (no gaps), per the spec.\nconst totalCustomers = totals.reduce((sum, count) => sum + count, 0);\nconst edges = totals.reduce((acc, count) => [...acc, acc[acc.length - 1] + count], [0]);\nconst centers = tiers.map((_, i) => (edges[i] + edges[i + 1]) / 2);\n\n// Chart.js's bar controller lays out every bar in a dataset at one shared\n// width (it has no per-bar thickness hook), so a spine plot's variable-width\n// requirement can't be expressed through the stock bar element. Instead we\n// mount an invisible scatter chart purely to get Chart.js's linear scales,\n// legend and title, then draw the real proportional rectangles ourselves in a\n// plugin using those scales' own pixel mapping — the documented extension\n// point for custom geometry Chart.js's built-in controllers don't cover.\nconst SEGMENT_LABEL_COLOR = \"#FAF8F1\"; // fixed light label, reads on saturated fills in both themes\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Spine drawing plugin ---------------------------------------------------\nconst drawSpine = {\n  id: \"drawSpine\",\n  afterDatasetsDraw(chart) {\n    const { ctx, chartArea, scales } = chart;\n    const xScale = scales.x;\n    const yScale = scales.y;\n    const yZero = yScale.getPixelForValue(0);\n    const yHundred = yScale.getPixelForValue(100);\n\n    ctx.save();\n    ctx.beginPath();\n    ctx.rect(chartArea.left, chartArea.top, chartArea.width, chartArea.height);\n    ctx.clip();\n\n    tiers.forEach((tier, i) => {\n      const xLeft = xScale.getPixelForValue(edges[i]);\n      const xRight = xScale.getPixelForValue(edges[i + 1]);\n      const xCenter = (xLeft + xRight) / 2;\n      const ySplit = yScale.getPixelForValue(retainedPct[i]);\n\n      ctx.fillStyle = t.palette[0]; // Retained — brand green, always first series\n      ctx.fillRect(xLeft, ySplit, xRight - xLeft, yZero - ySplit);\n\n      ctx.fillStyle = t.palette[4]; // Churned — matte red, semantic loss anchor\n      ctx.fillRect(xLeft, yHundred, xRight - xLeft, ySplit - yHundred);\n\n      // Bar edges: thin page-bg stroke for definition (default-style-guide.md\n      // \"Data Element Styling\"), between the two stacked segments...\n      ctx.fillStyle = t.pageBg;\n      ctx.fillRect(xLeft, ySplit - 1, xRight - xLeft, 2);\n\n      ctx.font = \"600 17px -apple-system, BlinkMacSystemFont, sans-serif\";\n      ctx.textAlign = \"center\";\n      ctx.textBaseline = \"middle\";\n      ctx.fillStyle = SEGMENT_LABEL_COLOR;\n      const retainedHeight = yZero - ySplit;\n      const churnedHeight = ySplit - yHundred;\n      if (retainedHeight >= 45) {\n        ctx.fillText(`${Math.round(retainedPct[i])}%`, xCenter, (yZero + ySplit) / 2);\n      }\n      if (churnedHeight >= 45) {\n        ctx.fillText(`${Math.round(churnedPct[i])}%`, xCenter, (ySplit + yHundred) / 2);\n      }\n    });\n\n    // ...and between adjacent bars, so equal-colored neighbors stay legible.\n    ctx.fillStyle = t.pageBg;\n    for (let i = 1; i < edges.length - 1; i++) {\n      const x = xScale.getPixelForValue(edges[i]);\n      ctx.fillRect(x - 1, yHundred, 2, yZero - yHundred);\n    }\n\n    ctx.restore();\n  },\n};\n\n// --- Title -------------------------------------------------------------------\nconst title = \"Customer Churn by Subscription Tier · bar-spine · javascript · chartjs · anyplot.ai\";\nconst titleSize = Math.round(22 * Math.min(1, 67 / title.length));\n\n// --- Chart -------------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"scatter\",\n  data: {\n    datasets: [\n      { label: \"Retained\", data: [], backgroundColor: t.palette[0], showLine: false },\n      { label: \"Churned\", data: [], backgroundColor: t.palette[4], showLine: false },\n    ],\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 8, right: 20, bottom: 4, left: 4 } },\n    plugins: {\n      title: {\n        display: true,\n        text: title,\n        color: t.ink,\n        font: { size: titleSize, weight: \"500\" },\n        padding: { bottom: 18 },\n      },\n      legend: {\n        position: \"top\",\n        align: \"center\",\n        labels: { color: t.ink, font: { size: 16 }, boxWidth: 22, boxHeight: 16 },\n      },\n      tooltip: { enabled: false },\n    },\n    scales: {\n      x: {\n        type: \"linear\",\n        min: 0,\n        max: totalCustomers,\n        afterBuildTicks: (axis) => {\n          axis.ticks = centers.map((value) => ({ value }));\n        },\n        ticks: {\n          autoSkip: false,\n          maxTicksLimit: tiers.length,\n          color: t.inkSoft,\n          font: { size: 15 },\n          callback: (value, index) => tiers[index],\n        },\n        grid: { display: false },\n        title: {\n          display: true,\n          text: \"Subscription tier — bar width ∝ customer count\",\n          color: t.ink,\n          font: { size: 15 },\n        },\n      },\n      y: {\n        type: \"linear\",\n        min: 0,\n        max: 100,\n        ticks: {\n          stepSize: 20,\n          color: t.inkSoft,\n          font: { size: 14 },\n          callback: (value) => `${value}%`,\n        },\n        grid: { color: t.grid },\n        title: { display: true, text: \"Share of customers\", color: t.ink, font: { size: 15 } },\n      },\n    },\n  },\n  plugins: [drawSpine],\n});\n"}