{"spec_id":"alluvial-basic","library":"chartjs","language":"javascript","code":"// anyplot.ai\n// alluvial-basic: Basic Alluvial Diagram\n// Library: chartjs 4.4.7 | JavaScript 22.23.2\n// Quality: 95/100 | Created: 2026-09-02\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Cohort of 240 students tracked across four semesters as they move between\n// performance tiers. Each column sums to 240 — the alluvial reads as a\n// conserved population reshuffling between tiers, not independent bars.\nconst semesters = [\"Semester 1\", \"Semester 2\", \"Semester 3\", \"Semester 4\"];\n\nconst tierNames = [\"Honors\", \"Proficient\", \"Developing\", \"Needs Support\"];\nconst tierColors = [t.palette[0], t.palette[1], t.palette[2], t.palette[4]];\nconst tierValues = [\n  [30, 45, 65, 90], // Honors\n  [60, 70, 75, 80], // Proficient\n  [80, 75, 65, 50], // Developing\n  [70, 50, 35, 20], // Needs Support\n];\n\nconst tiers = tierNames.map((name, i) => ({\n  name,\n  color: tierColors[i],\n  values: tierValues[i],\n}));\n\n// Per-gap transition matrices: transitions[gap][from][to] = number of students\n// who were in tier `from` at semester `gap` and land in tier `to` at semester\n// `gap + 1`. Movement is restricted to adjacent tiers (a student shifts by at\n// most one rank per semester) and each row/column sums exactly to that tier's\n// column total above, so the flow bands genuinely depict students crossing\n// between tiers rather than each tier's own segment merely resizing.\nconst transitions = [\n  // Semester 1 -> Semester 2\n  [\n    [27, 3, 0, 0],\n    [18, 38, 4, 0],\n    [0, 29, 45, 6],\n    [0, 0, 26, 44],\n  ],\n  // Semester 2 -> Semester 3\n  [\n    [43, 2, 0, 0],\n    [22, 45, 3, 0],\n    [0, 28, 42, 5],\n    [0, 0, 20, 30],\n  ],\n  // Semester 3 -> Semester 4\n  [\n    [64, 1, 0, 0],\n    [26, 47, 2, 0],\n    [0, 32, 30, 3],\n    [0, 0, 18, 17],\n  ],\n];\n\n// --- Flow ribbons: bezier bands for each actual tier-to-tier transition -----\nconst alluvialFlows = {\n  id: \"alluvialFlows\",\n  beforeDatasetsDraw(chart) {\n    const { ctx } = chart;\n    const numColumns = chart.data.labels.length;\n    const numTiers = tiers.length;\n    ctx.save();\n    ctx.globalAlpha = 0.45;\n\n    for (let gap = 0; gap < numColumns - 1; gap++) {\n      const flowMatrix = transitions[gap];\n\n      // Pixel span [top, bottom] of every tier's bar segment on both sides\n      // of this gap, read straight from the stacked-bar geometry.\n      const leftSpan = [];\n      const rightSpan = [];\n      for (let tierIdx = 0; tierIdx < numTiers; tierIdx++) {\n        const leftBar = chart.getDatasetMeta(tierIdx).data[gap];\n        const rightBar = chart.getDatasetMeta(tierIdx).data[gap + 1];\n        leftSpan.push([\n          Math.min(leftBar.y, leftBar.base),\n          Math.max(leftBar.y, leftBar.base),\n        ]);\n        rightSpan.push([\n          Math.min(rightBar.y, rightBar.base),\n          Math.max(rightBar.y, rightBar.base),\n        ]);\n      }\n\n      // Running top-of-next-band cursor within each tier's segment, so\n      // multiple sub-bands stack without gaps or overlap.\n      const leftCursor = leftSpan.map(([top]) => top);\n      const rightCursor = rightSpan.map(([top]) => top);\n\n      for (let from = 0; from < numTiers; from++) {\n        const rowTotal = flowMatrix[from].reduce((sum, v) => sum + v, 0);\n        const leftHeight = leftSpan[from][1] - leftSpan[from][0];\n\n        for (let to = 0; to < numTiers; to++) {\n          const amount = flowMatrix[from][to];\n          if (amount === 0) continue;\n\n          const colTotal = flowMatrix.reduce((sum, row) => sum + row[to], 0);\n          const rightHeight = rightSpan[to][1] - rightSpan[to][0];\n\n          const lTop = leftCursor[from];\n          const lBot = lTop + (amount / rowTotal) * leftHeight;\n          const rTop = rightCursor[to];\n          const rBot = rTop + (amount / colTotal) * rightHeight;\n          leftCursor[from] = lBot;\n          rightCursor[to] = rBot;\n\n          const leftBar = chart.getDatasetMeta(from).data[gap];\n          const rightBar = chart.getDatasetMeta(to).data[gap + 1];\n          const lx = leftBar.x + leftBar.width / 2;\n          const rx = rightBar.x - rightBar.width / 2;\n          const midX = (lx + rx) / 2;\n\n          ctx.fillStyle = tiers[from].color;\n          ctx.beginPath();\n          ctx.moveTo(lx, lTop);\n          ctx.bezierCurveTo(midX, lTop, midX, rTop, rx, rTop);\n          ctx.lineTo(rx, rBot);\n          ctx.bezierCurveTo(midX, rBot, midX, lBot, lx, lBot);\n          ctx.closePath();\n          ctx.fill();\n        }\n      }\n    }\n    ctx.restore();\n  },\n};\n\n// --- Mount -------------------------------------------------------------------\nconst canvas = document.createElement(\"canvas\");\ndocument.getElementById(\"container\").appendChild(canvas);\n\n// --- Chart ---------------------------------------------------------------\nnew Chart(canvas, {\n  type: \"bar\",\n  data: {\n    labels: semesters,\n    datasets: tiers.map((tier) => ({\n      label: tier.name,\n      data: tier.values,\n      backgroundColor: tier.color,\n      borderColor: t.pageBg,\n      borderWidth: 2,\n      stack: \"cohort\",\n      barPercentage: 1.0,\n      categoryPercentage: 0.32,\n    })),\n  },\n  options: {\n    responsive: true,\n    maintainAspectRatio: false,\n    animation: false,\n    layout: { padding: { top: 8, right: 24, bottom: 8, left: 8 } },\n    plugins: {\n      title: {\n        display: true,\n        text: \"alluvial-basic · javascript · chartjs · anyplot.ai\",\n        color: t.ink,\n        font: { size: 22, weight: \"500\" },\n        padding: { bottom: 20 },\n      },\n      legend: {\n        position: \"bottom\",\n        labels: { color: t.ink, font: { size: 16 }, boxWidth: 18, padding: 20 },\n      },\n      tooltip: { enabled: false },\n    },\n    scales: {\n      x: {\n        stacked: true,\n        ticks: { color: t.inkSoft, font: { size: 15 } },\n        grid: { display: false },\n        border: { color: t.inkSoft },\n      },\n      y: {\n        stacked: true,\n        beginAtZero: true,\n        suggestedMax: 250,\n        ticks: { color: t.inkSoft, font: { size: 14 } },\n        grid: { color: t.grid },\n        border: { display: false },\n        title: {\n          display: true,\n          text: \"Students in tier\",\n          color: t.ink,\n          font: { size: 16 },\n        },\n      },\n    },\n  },\n  plugins: [alluvialFlows],\n});\n"}