{"spec_id":"alluvial-opinion-flow","library":"muix","language":"javascript","code":"// anyplot.ai\n// alluvial-opinion-flow: Opinion Flow Diagram\n// Library: muix 7.29.1 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-08-26\nimport { ChartContainer } from \"@mui/x-charts/ChartContainer\";\nimport { useXScale, useYScale } from \"@mui/x-charts/hooks\";\n\nconst t = window.ANYPLOT_TOKENS;\nconst SIZE = window.ANYPLOT_SIZE;\nconst TITLE = \"alluvial-opinion-flow · javascript · muix · anyplot.ai\";\nconst MUTED = t.theme === \"light\" ? \"#6B6A63\" : \"#A8A79F\"; // Imprint muted anchor\n\nconst FONT =\n  '-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, Helvetica, Arial, sans-serif';\n\n// --- Data: quarterly customer-satisfaction survey, 1,000 respondents ---------\n// Ordinal sentiment scale, deterministic. Populations and transition counts\n// are hand-designed so row/column sums match exactly (no rounding drift):\n// each wave sums to 1,000, and every transition matrix's row sums equal the\n// source wave's populations while its column sums equal the target wave's.\n// The scenario deliberately drifts toward the extremes (polarization) while\n// \"Neutral\" and \"Satisfied\" shrink.\nconst CATEGORIES = [\n  { id: \"vs\", label: \"Very Satisfied\", color: t.palette[0] }, // brand green — always first series\n  { id: \"s\", label: \"Satisfied\", color: t.palette[7] }, // lime — still green family, reads \"positive\"\n  { id: \"n\", label: \"Neutral\", color: MUTED }, // muted semantic anchor\n  { id: \"d\", label: \"Dissatisfied\", color: t.amber }, // amber semantic anchor — caution\n  { id: \"vd\", label: \"Very Dissatisfied\", color: t.palette[4] }, // matte red — bad/error semantic anchor\n];\nconst CATEGORY_ORDER = CATEGORIES.map((c) => c.id);\nconst CATEGORY_BY_ID = Object.fromEntries(CATEGORIES.map((c) => [c.id, c]));\n\nconst WAVES = [\"Q1 2025\", \"Q2 2025\", \"Q3 2025\", \"Q4 2025\"];\n\nconst POPULATIONS = [\n  { vs: 150, s: 300, n: 300, d: 150, vd: 100 },\n  { vs: 200, s: 250, n: 220, d: 180, vd: 150 },\n  { vs: 260, s: 200, n: 160, d: 200, vd: 180 },\n  { vs: 320, s: 150, n: 120, d: 190, vd: 220 },\n];\n\n// One transition matrix per wave-to-wave step (3 steps for 4 waves).\nconst TRANSITIONS = [\n  [\n    { source: \"vs\", target: \"vs\", count: 110 },\n    { source: \"vs\", target: \"s\", count: 30 },\n    { source: \"vs\", target: \"n\", count: 10 },\n    { source: \"s\", target: \"vs\", count: 60 },\n    { source: \"s\", target: \"s\", count: 180 },\n    { source: \"s\", target: \"n\", count: 40 },\n    { source: \"s\", target: \"d\", count: 15 },\n    { source: \"s\", target: \"vd\", count: 5 },\n    { source: \"n\", target: \"vs\", count: 25 },\n    { source: \"n\", target: \"s\", count: 35 },\n    { source: \"n\", target: \"n\", count: 140 },\n    { source: \"n\", target: \"d\", count: 70 },\n    { source: \"n\", target: \"vd\", count: 30 },\n    { source: \"d\", target: \"vs\", count: 3 },\n    { source: \"d\", target: \"s\", count: 3 },\n    { source: \"d\", target: \"n\", count: 20 },\n    { source: \"d\", target: \"d\", count: 80 },\n    { source: \"d\", target: \"vd\", count: 44 },\n    { source: \"vd\", target: \"vs\", count: 2 },\n    { source: \"vd\", target: \"s\", count: 2 },\n    { source: \"vd\", target: \"n\", count: 10 },\n    { source: \"vd\", target: \"d\", count: 15 },\n    { source: \"vd\", target: \"vd\", count: 71 },\n  ],\n  [\n    { source: \"vs\", target: \"vs\", count: 160 },\n    { source: \"vs\", target: \"s\", count: 30 },\n    { source: \"vs\", target: \"n\", count: 10 },\n    { source: \"s\", target: \"vs\", count: 70 },\n    { source: \"s\", target: \"s\", count: 150 },\n    { source: \"s\", target: \"n\", count: 20 },\n    { source: \"s\", target: \"d\", count: 8 },\n    { source: \"s\", target: \"vd\", count: 2 },\n    { source: \"n\", target: \"vs\", count: 25 },\n    { source: \"n\", target: \"s\", count: 15 },\n    { source: \"n\", target: \"n\", count: 100 },\n    { source: \"n\", target: \"d\", count: 60 },\n    { source: \"n\", target: \"vd\", count: 20 },\n    { source: \"d\", target: \"vs\", count: 3 },\n    { source: \"d\", target: \"s\", count: 3 },\n    { source: \"d\", target: \"n\", count: 20 },\n    { source: \"d\", target: \"d\", count: 110 },\n    { source: \"d\", target: \"vd\", count: 44 },\n    { source: \"vd\", target: \"vs\", count: 2 },\n    { source: \"vd\", target: \"s\", count: 2 },\n    { source: \"vd\", target: \"n\", count: 10 },\n    { source: \"vd\", target: \"d\", count: 22 },\n    { source: \"vd\", target: \"vd\", count: 114 },\n  ],\n  [\n    { source: \"vs\", target: \"vs\", count: 220 },\n    { source: \"vs\", target: \"s\", count: 30 },\n    { source: \"vs\", target: \"n\", count: 10 },\n    { source: \"s\", target: \"vs\", count: 90 },\n    { source: \"s\", target: \"s\", count: 90 },\n    { source: \"s\", target: \"n\", count: 12 },\n    { source: \"s\", target: \"d\", count: 6 },\n    { source: \"s\", target: \"vd\", count: 2 },\n    { source: \"n\", target: \"vs\", count: 8 },\n    { source: \"n\", target: \"s\", count: 20 },\n    { source: \"n\", target: \"n\", count: 80 },\n    { source: \"n\", target: \"d\", count: 40 },\n    { source: \"n\", target: \"vd\", count: 12 },\n    { source: \"d\", target: \"vs\", count: 2 },\n    { source: \"d\", target: \"s\", count: 8 },\n    { source: \"d\", target: \"n\", count: 15 },\n    { source: \"d\", target: \"d\", count: 125 },\n    { source: \"d\", target: \"vd\", count: 50 },\n    { source: \"vd\", target: \"s\", count: 2 },\n    { source: \"vd\", target: \"n\", count: 3 },\n    { source: \"vd\", target: \"d\", count: 19 },\n    { source: \"vd\", target: \"vd\", count: 156 },\n  ],\n];\n\n// --- Layout: fixed category row order in every column (polarization reads as\n// the top and bottom bands growing wave over wave) ---------------------------\nconst NODE_W = 24;\nconst GAP = 14;\nconst LEFT_MARGIN = 140;\nconst RIGHT_MARGIN = 170; // room for the last wave's node labels + net-change deltas\nconst PLOT_TOP = 170;\nconst PLOT_BOTTOM = SIZE.height - 50;\nconst PLOT_H = PLOT_BOTTOM - PLOT_TOP;\nconst H = SIZE.height; // flips top-down pixel y into the chart's bottom-up data y\n\nconst usableW = SIZE.width - LEFT_MARGIN - RIGHT_MARGIN - NODE_W;\nconst COL_X = WAVES.map((_, w) => LEFT_MARGIN + (usableW * w) / (WAVES.length - 1));\nconst SCALE = (PLOT_H - GAP * (CATEGORIES.length - 1)) / 1000; // every wave sums to 1,000\n\nconst nodes = [];\nconst nodeByKey = {};\nfor (let w = 0; w < WAVES.length; w++) {\n  let cursor = PLOT_TOP;\n  for (const cat of CATEGORIES) {\n    const value = POPULATIONS[w][cat.id];\n    const height = value * SCALE;\n    const node = {\n      key: `${w}:${cat.id}`,\n      wave: w,\n      catId: cat.id,\n      label: cat.label,\n      color: cat.color,\n      value,\n      x0: COL_X[w],\n      x1: COL_X[w] + NODE_W,\n      y0: cursor,\n      y1: cursor + height,\n      out: [],\n      in: [],\n    };\n    nodes.push(node);\n    nodeByKey[node.key] = node;\n    cursor = node.y1 + GAP;\n  }\n}\n\nconst flows = [];\nTRANSITIONS.forEach((step, t) => {\n  step.forEach((f) => {\n    const source = nodeByKey[`${t}:${f.source}`];\n    const target = nodeByKey[`${t + 1}:${f.target}`];\n    const flow = { source, target, count: f.count, stable: f.source === f.target };\n    source.out.push(flow);\n    target.in.push(flow);\n    flows.push(flow);\n  });\n});\n\n// Stack each node's links along its edge, ordered by the counterpart's fixed\n// row rank, so ribbons fan out with minimal crossing near the node.\nconst rankOf = (catId) => CATEGORY_ORDER.indexOf(catId);\nfor (const n of nodes) {\n  n.out.sort((a, b) => rankOf(a.target.catId) - rankOf(b.target.catId));\n  let oc = n.y0;\n  for (const f of n.out) {\n    f.sy0 = oc;\n    f.sy1 = oc + f.count * SCALE;\n    f.sx = n.x1;\n    oc = f.sy1;\n  }\n  n.in.sort((a, b) => rankOf(a.source.catId) - rankOf(b.source.catId));\n  let ic = n.y0;\n  for (const f of n.in) {\n    f.ty0 = ic;\n    f.ty1 = ic + f.count * SCALE;\n    f.tx = n.x0;\n    ic = f.ty1;\n  }\n}\n\nfunction ribbonPath(xs, ys, f) {\n  const cx = (f.sx + f.tx) / 2;\n  const P = (x, yPix) => `${xs(x).toFixed(1)} ${ys(H - yPix).toFixed(1)}`;\n  const C = (yPix) => `${xs(cx).toFixed(1)} ${ys(H - yPix).toFixed(1)}`;\n  return (\n    `M ${P(f.sx, f.sy0)} ` +\n    `C ${C(f.sy0)}, ${C(f.ty0)}, ${P(f.tx, f.ty0)} ` +\n    `L ${P(f.tx, f.ty1)} ` +\n    `C ${C(f.ty1)}, ${C(f.sy1)}, ${P(f.sx, f.sy1)} Z`\n  );\n}\n\n// --- Overlay layers -----------------------------------------------------------\nfunction Links() {\n  const xs = useXScale();\n  const ys = useYScale();\n  return (\n    <g>\n      {flows.map((f, k) => (\n        <path\n          key={k}\n          d={ribbonPath(xs, ys, f)}\n          fill={CATEGORY_BY_ID[f.source.catId].color}\n          fillOpacity={f.stable ? 0.55 : 0.16}\n        >\n          <title>\n            {`${f.source.label} (${WAVES[f.source.wave]}) → ${f.target.label} (${WAVES[f.target.wave]}): ${f.count} respondents`}\n          </title>\n        </path>\n      ))}\n    </g>\n  );\n}\n\nfunction Nodes() {\n  const xs = useXScale();\n  const ys = useYScale();\n  return (\n    <g>\n      {nodes.map((n) => (\n        <rect\n          key={n.key}\n          x={xs(n.x0).toFixed(1)}\n          y={ys(H - n.y0).toFixed(1)}\n          width={(xs(n.x1) - xs(n.x0)).toFixed(1)}\n          height={(ys(H - n.y1) - ys(H - n.y0)).toFixed(1)}\n          fill={n.color}\n          rx={3}\n        >\n          <title>{`${n.label} · ${WAVES[n.wave]}: ${n.value} respondents`}</title>\n        </rect>\n      ))}\n    </g>\n  );\n}\n\nfunction Labels() {\n  const xs = useXScale();\n  const ys = useYScale();\n  const firstWaveByCat = Object.fromEntries(CATEGORIES.map((c) => [c.id, POPULATIONS[0][c.id]]));\n  return (\n    <g fontFamily={FONT}>\n      {WAVES.map((label, w) => (\n        <text\n          key={label}\n          x={xs(COL_X[w] + NODE_W / 2)}\n          y={ys(H - (PLOT_TOP - 22))}\n          textAnchor=\"middle\"\n          fontSize={13}\n          fontWeight={600}\n          letterSpacing={1}\n          fill={t.inkSoft}\n        >\n          {label.toUpperCase()}\n        </text>\n      ))}\n      {nodes.map((n) => {\n        const cy = (n.y0 + n.y1) / 2;\n        const lx = xs(n.x1) + 10;\n        const isLast = n.wave === WAVES.length - 1;\n        const delta = isLast ? n.value - firstWaveByCat[n.catId] : null;\n        return (\n          <g key={n.key}>\n            <text x={lx} y={ys(H - (cy - 2))} textAnchor=\"start\" fontSize={13} fontWeight={600} fill={t.ink}>\n              {n.label}\n            </text>\n            <text x={lx} y={ys(H - (cy + 14))} textAnchor=\"start\" fontSize={12} fill={t.inkSoft}>\n              {n.value}\n            </text>\n            {isLast && (\n              <text\n                x={lx}\n                y={ys(H - (cy + 28))}\n                textAnchor=\"start\"\n                fontSize={12}\n                fontWeight={600}\n                fill={delta >= 0 ? t.palette[0] : t.palette[4]}\n              >\n                {delta >= 0 ? `▲ +${delta}` : `▼ ${delta}`}\n              </text>\n            )}\n          </g>\n        );\n      })}\n    </g>\n  );\n}\n\nfunction Frame() {\n  const xs = useXScale();\n  const ys = useYScale();\n  return (\n    <g fontFamily={FONT}>\n      <text x={xs(SIZE.width / 2)} y={ys(H - 40)} textAnchor=\"middle\" fontSize={26} fontWeight={600} fill={t.ink}>\n        {TITLE}\n      </text>\n      <text x={xs(SIZE.width / 2)} y={ys(H - 66)} textAnchor=\"middle\" fontSize={15} fill={t.inkSoft}>\n        Quarterly customer-satisfaction survey · 1,000 respondents tracked Q1–Q4 2025\n      </text>\n      <text x={xs(SIZE.width / 2)} y={ys(H - 90)} textAnchor=\"middle\" fontSize={12} fill={MUTED}>\n        Bold ribbon = same rating next wave · Faint ribbon = rating changed · ▲▼ = net change vs Q1\n      </text>\n    </g>\n  );\n}\n\n// --- Chart (default-exported component — the harness mounts it) -------------\nexport default function Chart() {\n  return (\n    <ChartContainer\n      width={SIZE.width}\n      height={SIZE.height}\n      margin={{ top: 0, right: 0, bottom: 0, left: 0 }}\n      series={[]}\n      xAxis={[{ id: \"x\", scaleType: \"linear\", min: 0, max: SIZE.width }]}\n      yAxis={[{ id: \"y\", scaleType: \"linear\", min: 0, max: SIZE.height }]}\n      skipAnimation\n    >\n      <Links />\n      <Nodes />\n      <Labels />\n      <Frame />\n    </ChartContainer>\n  );\n}\n"}