{"spec_id":"alluvial-basic","library":"muix","language":"javascript","code":"// anyplot.ai\n// alluvial-basic: Basic Alluvial Diagram\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 88/100 | Created: 2026-09-02\nimport * as React from \"react\";\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\nimport { ChartsXAxis } from \"@mui/x-charts/ChartsXAxis\";\nimport { useXScale, useYScale, useDrawingArea } from \"@mui/x-charts/hooks\";\n\nconst t = window.ANYPLOT_TOKENS;\n\n// --- Data: SaaS subscription-tier migration across four quarters -----------\n// A Markov-style transition matrix drives every quarter-to-quarter step, so\n// node values (populations) and flow values (transitions) stay perfectly\n// consistent by construction — no separate bookkeeping needed.\nconst CATEGORIES = [\"Free\", \"Basic\", \"Pro\", \"Enterprise\", \"Churned\"];\n// Visual stack order, top to bottom: higher tiers rise to the top of each\n// column, churn sinks to the bottom.\nconst STACK_ORDER = [\"Enterprise\", \"Pro\", \"Basic\", \"Free\", \"Churned\"];\nconst TIME_POINTS = [\"Q1 2024\", \"Q2 2024\", \"Q3 2024\", \"Q4 2024\"];\n\n// Only adjacent-tier upgrades/downgrades (plus churn, which can originate\n// from any paid tier) — skip-tier jumps like Enterprise <-> Free would both\n// be unrealistic customer behavior and force ribbons into wide, crossing\n// detours that muddy the diagram. A small win-back rate (Churned -> Free)\n// keeps churn from being a fully absorbing state, matching real SaaS\n// reactivation behavior.\nconst TRANSITION = {\n  Free: { Free: 0.7, Basic: 0.22, Pro: 0, Enterprise: 0, Churned: 0.08 },\n  Basic: { Free: 0.1, Basic: 0.55, Pro: 0.28, Enterprise: 0, Churned: 0.07 },\n  Pro: { Free: 0, Basic: 0.1, Pro: 0.65, Enterprise: 0.2, Churned: 0.05 },\n  Enterprise: { Free: 0, Basic: 0, Pro: 0.08, Enterprise: 0.9, Churned: 0.02 },\n  Churned: { Free: 0.03, Basic: 0, Pro: 0, Enterprise: 0, Churned: 0.97 },\n};\n\nconst INITIAL_POPULATION = {\n  Free: 500,\n  Basic: 300,\n  Pro: 150,\n  Enterprise: 50,\n  Churned: 0,\n};\nconst TOTAL = CATEGORIES.reduce((sum, cat) => sum + INITIAL_POPULATION[cat], 0);\n\nconst nextPopulation = (pop) => {\n  const next = {};\n  CATEGORIES.forEach((to) => {\n    next[to] = CATEGORIES.reduce(\n      (sum, from) => sum + pop[from] * TRANSITION[from][to],\n      0,\n    );\n  });\n  return next;\n};\n\nconst POPULATIONS = [INITIAL_POPULATION];\nfor (let i = 1; i < TIME_POINTS.length; i += 1) {\n  POPULATIONS.push(nextPopulation(POPULATIONS[i - 1]));\n}\n\n// Stack each column's categories (top to bottom) into cumulative [top,\n// bottom] value-space extents — shared by both the node rects and the flow\n// ribbons that attach to them.\nconst stackExtents = (pop) => {\n  let cursor = TOTAL;\n  const extents = {};\n  STACK_ORDER.forEach((cat) => {\n    const bottom = cursor - pop[cat];\n    extents[cat] = { top: cursor, bottom };\n    cursor = bottom;\n  });\n  return extents;\n};\n\nconst NODE_EXTENTS = POPULATIONS.map(stackExtents);\n\n// Subdivide each node's extent among its flows, ordered by the counterpart\n// category's stack rank — this keeps ribbons visually coherent instead of\n// crossing more than the tier changes themselves require.\nconst buildFlows = () => {\n  const flows = [];\n  for (let step = 0; step < TIME_POINTS.length - 1; step += 1) {\n    const srcCol = step;\n    const dstCol = step + 1;\n    const outCursor = {};\n    const inCursor = {};\n    STACK_ORDER.forEach((cat) => {\n      outCursor[cat] = NODE_EXTENTS[srcCol][cat].top;\n      inCursor[cat] = NODE_EXTENTS[dstCol][cat].top;\n    });\n    STACK_ORDER.forEach((from) => {\n      STACK_ORDER.forEach((to) => {\n        const rate = TRANSITION[from][to];\n        if (!rate) return;\n        const value = POPULATIONS[srcCol][from] * rate;\n        if (value < 0.5) return;\n        const srcTop = outCursor[from];\n        const srcBottom = srcTop - value;\n        outCursor[from] = srcBottom;\n        const dstTop = inCursor[to];\n        const dstBottom = dstTop - value;\n        inCursor[to] = dstBottom;\n        flows.push({\n          step,\n          from,\n          to,\n          value,\n          srcTop,\n          srcBottom,\n          dstTop,\n          dstBottom,\n        });\n      });\n    });\n  }\n  return flows;\n};\n\nconst FLOWS = buildFlows();\n\n// First series is ALWAYS brand green; \"Churned\" is the semantic exception\n// for loss (see default-style-guide \"Semantic exception\").\nconst CATEGORY_COLOR = {\n  Free: t.palette[0],\n  Basic: t.palette[1],\n  Pro: t.palette[2],\n  Enterprise: t.palette[3],\n  Churned: t.palette[4],\n};\n\nconst NODE_W = 18;\n\n// Compact codes for the mid-diagram node chips (Q2/Q3) — short enough to sit\n// centered on the narrow node column without swallowing the ribbons on\n// either side.\nconst CATEGORY_ABBR = {\n  Free: \"Free\",\n  Basic: \"Basic\",\n  Pro: \"Pro\",\n  Enterprise: \"Ent\",\n  Churned: \"Chu\",\n};\nconst MID_LABEL_MIN_HEIGHT = 16;\nconst MID_LABEL_FONT = 10;\n\n// --- Custom SVG layers, positioned via the chart's own scales --------------\nfunction AlluvialFlows() {\n  const xScale = useXScale();\n  const yScale = useYScale();\n  return (\n    <g data-drawing-container>\n      {FLOWS.map((f, i) => {\n        const x1 = xScale(TIME_POINTS[f.step]) + NODE_W / 2;\n        const x2 = xScale(TIME_POINTS[f.step + 1]) - NODE_W / 2;\n        const midX = (x1 + x2) / 2;\n        const ySrcTop = yScale(f.srcTop);\n        const ySrcBottom = yScale(f.srcBottom);\n        const yDstTop = yScale(f.dstTop);\n        const yDstBottom = yScale(f.dstBottom);\n        return (\n          <path\n            key={`flow-${i}`}\n            d={`M ${x1} ${ySrcTop} C ${midX} ${ySrcTop}, ${midX} ${yDstTop}, ${x2} ${yDstTop} L ${x2} ${yDstBottom} C ${midX} ${yDstBottom}, ${midX} ${ySrcBottom}, ${x1} ${ySrcBottom} Z`}\n            fill={CATEGORY_COLOR[f.from]}\n            fillOpacity={0.55}\n            stroke={CATEGORY_COLOR[f.from]}\n            strokeOpacity={0.55}\n            strokeWidth={1}\n          />\n        );\n      })}\n    </g>\n  );\n}\n\nfunction AlluvialNodes() {\n  const xScale = useXScale();\n  const yScale = useYScale();\n  return (\n    <g data-drawing-container>\n      {TIME_POINTS.map((timePoint, colIndex) => {\n        const cx = xScale(timePoint);\n        const isFirst = colIndex === 0;\n        const isLast = colIndex === TIME_POINTS.length - 1;\n        return STACK_ORDER.map((cat) => {\n          const value = POPULATIONS[colIndex][cat];\n          if (value < 0.5) return null;\n          const { top, bottom } = NODE_EXTENTS[colIndex][cat];\n          const yTop = yScale(top);\n          const yBottom = yScale(bottom);\n          const midY = (yTop + yBottom) / 2;\n          const segmentH = yBottom - yTop;\n          const showMidLabel =\n            !isFirst && !isLast && segmentH >= MID_LABEL_MIN_HEIGHT;\n          const midLabel = CATEGORY_ABBR[cat];\n          const midPillW = midLabel.length * MID_LABEL_FONT * 0.62 + 10;\n          const midPillH = MID_LABEL_FONT + 8;\n          return (\n            <React.Fragment key={`${timePoint}-${cat}`}>\n              <rect\n                x={cx - NODE_W / 2}\n                y={yTop}\n                width={NODE_W}\n                height={segmentH}\n                fill={CATEGORY_COLOR[cat]}\n                stroke={t.pageBg}\n                strokeWidth={1.5}\n              />\n              {(isFirst || isLast) && (\n                <text\n                  x={isFirst ? cx - NODE_W / 2 - 10 : cx + NODE_W / 2 + 10}\n                  y={midY + 4}\n                  textAnchor={isFirst ? \"end\" : \"start\"}\n                  fontSize={13}\n                  fill={t.inkSoft}\n                >\n                  {`${cat} · ${Math.round(value)}`}\n                </text>\n              )}\n              {showMidLabel && (\n                <>\n                  <rect\n                    x={cx - midPillW / 2}\n                    y={midY - midPillH / 2}\n                    width={midPillW}\n                    height={midPillH}\n                    rx={midPillH / 2}\n                    fill={t.pageBg}\n                    opacity={0.92}\n                  />\n                  <text\n                    x={cx}\n                    y={midY + MID_LABEL_FONT / 2 - 1}\n                    textAnchor=\"middle\"\n                    fontSize={MID_LABEL_FONT}\n                    fill={t.inkSoft}\n                  >\n                    {midLabel}\n                  </text>\n                </>\n              )}\n            </React.Fragment>\n          );\n        });\n      })}\n    </g>\n  );\n}\n\nfunction ValueAxisTitle() {\n  const { top, height } = useDrawingArea();\n  const cy = top + height / 2;\n  return (\n    <text\n      x={16}\n      y={cy}\n      textAnchor=\"middle\"\n      fontSize={13}\n      fill={t.inkSoft}\n      transform={`rotate(-90, 16, ${cy})`}\n    >\n      Customers\n    </text>\n  );\n}\n\n// --- Title + legend chrome ---------------------------------------------------\nconst TITLE = \"alluvial-basic · javascript · muix · anyplot.ai\";\nconst TITLE_FONT_DEFAULT = 28;\nconst titleFontSize =\n  TITLE.length > 67\n    ? Math.round(TITLE_FONT_DEFAULT * (67 / TITLE.length))\n    : TITLE_FONT_DEFAULT;\nconst SUBTITLE =\n  \"Simulated SaaS subscription-tier migration · 1,000 customers across 4 quarters\";\nconst TITLE_H = 44;\nconst SUBTITLE_H = 26;\nconst LEGEND_H = 34;\n\nfunction Legend() {\n  return (\n    <div\n      style={{\n        height: LEGEND_H,\n        display: \"flex\",\n        alignItems: \"center\",\n        gap: \"16px\",\n        flexWrap: \"wrap\",\n      }}\n    >\n      {STACK_ORDER.map((cat) => (\n        <div\n          key={cat}\n          style={{ display: \"flex\", alignItems: \"center\", gap: \"7px\" }}\n        >\n          <span\n            style={{\n              width: \"13px\",\n              height: \"13px\",\n              borderRadius: \"3px\",\n              backgroundColor: CATEGORY_COLOR[cat],\n              display: \"inline-block\",\n            }}\n          />\n          <span style={{ fontSize: \"14px\", color: t.inkSoft }}>{cat}</span>\n        </div>\n      ))}\n      <span style={{ fontSize: \"14px\", color: t.inkSoft, fontStyle: \"italic\" }}>\n        Band width ∝ transition volume\n      </span>\n    </div>\n  );\n}\n\n// --- Chart (default-exported component — the harness mounts it) ------------\nexport default function Chart() {\n  const { width, height } = window.ANYPLOT_SIZE;\n  const chartHeight = height - TITLE_H - SUBTITLE_H - LEGEND_H;\n\n  return (\n    <div style={{ width, height, display: \"flex\", flexDirection: \"column\" }}>\n      <div style={{ paddingLeft: \"20px\" }}>\n        <div\n          style={{\n            height: `${TITLE_H}px`,\n            lineHeight: `${TITLE_H}px`,\n            fontSize: `${titleFontSize}px`,\n            fontWeight: 500,\n            color: t.ink,\n          }}\n        >\n          {TITLE}\n        </div>\n        <div\n          style={{\n            height: `${SUBTITLE_H}px`,\n            lineHeight: `${SUBTITLE_H}px`,\n            fontSize: \"15px\",\n            fontStyle: \"italic\",\n            color: t.inkSoft,\n          }}\n        >\n          {SUBTITLE}\n        </div>\n        <Legend />\n      </div>\n      <ChartContainer\n        width={width}\n        height={chartHeight}\n        series={[]}\n        margin={{ top: 40, bottom: 10, left: 150, right: 150 }}\n        xAxis={[\n          {\n            id: \"time\",\n            scaleType: \"point\",\n            data: TIME_POINTS,\n            position: \"top\",\n            disableLine: true,\n            disableTicks: true,\n            tickLabelStyle: { fontSize: 15, fill: t.inkSoft, fontWeight: 600 },\n          },\n        ]}\n        yAxis={[{ id: \"value\", scaleType: \"linear\", min: 0, max: TOTAL }]}\n        skipAnimation\n      >\n        <ChartsXAxis axisId=\"time\" />\n        <ValueAxisTitle />\n        <AlluvialFlows />\n        <AlluvialNodes />\n      </ChartContainer>\n    </div>\n  );\n}\n"}