{"spec_id":"alluvial-opinion-flow","library":"d3","language":"javascript","code":"// anyplot.ai\n// alluvial-opinion-flow: Opinion Flow Diagram\n// Library: d3 7.9.0 | JavaScript 22.23.2\n// Quality: 91/100 | Created: 2026-08-26\n//# anyplot-orientation: landscape\n// anyplot.ai\n// alluvial-opinion-flow: Opinion Flow Diagram\n// Library: d3 7.9.0 | JavaScript 22\n// Quality: pending | Created: 2026-08-26\n\nconst t = window.ANYPLOT_TOKENS;\nconst { width, height } = window.ANYPLOT_SIZE;\n\n// --- Data (in-memory, deterministic) ----------------------------------------\n// Quarterly opinion survey, 1000 respondents, 5-point agreement scale.\nconst categories = [\"Strongly Agree\", \"Agree\", \"Neutral\", \"Disagree\", \"Strongly Disagree\"];\nconst waveLabels = [\"Q1 2025\", \"Q2 2025\", \"Q3 2025\", \"Q4 2025\"];\nconst respondentCounts = [150, 250, 300, 200, 100]; // wave-1 totals, sums to 1000\n\n// Row-stochastic transition matrices (source category share -> target category).\n// Neutral is the least \"sticky\" category and the extremes retain a growing\n// share wave over wave, modeling gradual opinion polarization.\nconst transitionMatrices = [\n  [\n    [0.7, 0.22, 0.05, 0.02, 0.01],\n    [0.1, 0.62, 0.2, 0.06, 0.02],\n    [0.05, 0.2, 0.45, 0.2, 0.1],\n    [0.02, 0.06, 0.2, 0.62, 0.1],\n    [0.01, 0.02, 0.05, 0.22, 0.7],\n  ],\n  [\n    [0.74, 0.19, 0.04, 0.02, 0.01],\n    [0.12, 0.6, 0.18, 0.07, 0.03],\n    [0.08, 0.19, 0.38, 0.2, 0.15],\n    [0.03, 0.07, 0.18, 0.6, 0.12],\n    [0.01, 0.02, 0.04, 0.19, 0.74],\n  ],\n  [\n    [0.76, 0.17, 0.03, 0.02, 0.02],\n    [0.13, 0.58, 0.16, 0.08, 0.05],\n    [0.1, 0.18, 0.32, 0.2, 0.2],\n    [0.05, 0.08, 0.16, 0.58, 0.13],\n    [0.02, 0.02, 0.03, 0.17, 0.76],\n  ],\n];\n\n// Derive per-wave category totals and the flow list from the transition chain.\nconst waveCategoryTotals = [respondentCounts];\nconst flowLinks = []; // { wave, sourceCat, targetCat, count }\nfor (let w = 0; w < transitionMatrices.length; w++) {\n  const prevTotals = waveCategoryTotals[w];\n  const nextTotals = new Array(categories.length).fill(0);\n  for (let s = 0; s < categories.length; s++) {\n    for (let tgt = 0; tgt < categories.length; tgt++) {\n      const count = prevTotals[s] * transitionMatrices[w][s][tgt];\n      if (count > 0.5) flowLinks.push({ wave: w, sourceCat: s, targetCat: tgt, count });\n      nextTotals[tgt] += count;\n    }\n  }\n  waveCategoryTotals.push(nextTotals);\n}\n\nconst extremeShareStart = (waveCategoryTotals[0][0] + waveCategoryTotals[0][4]) / 1000;\nconst extremeShareEnd =\n  (waveCategoryTotals[waveCategoryTotals.length - 1][0] +\n    waveCategoryTotals[waveCategoryTotals.length - 1][4]) /\n  1000;\n\n// --- Layout -------------------------------------------------------------------\nconst margin = { top: 190, right: 170, bottom: 90, left: 170 };\nconst iw = width - margin.left - margin.right;\nconst ih = height - margin.top - margin.bottom;\nconst nodeWidth = 28;\nconst catPad = 14;\nconst columnGap = (iw - nodeWidth) / (waveLabels.length - 1);\nconst yScale = (ih - catPad * (categories.length - 1)) / 1000;\n\nconst color = d3.scaleOrdinal().domain(d3.range(categories.length)).range(t.palette);\nconst categoryAbbrev = categories.map((name) => name.split(\" \").map((w) => w[0]).join(\"\"));\nconst isExtreme = (c) => c === 0 || c === categories.length - 1;\n\n// Nodes: one block per (wave, category), stacked top-to-bottom in scale order.\nconst nodes = waveCategoryTotals.map((totals, w) => {\n  const x0 = margin.left + w * columnGap;\n  let y = margin.top;\n  return totals.map((value, c) => {\n    const nodeHeight = value * yScale;\n    const node = { wave: w, cat: c, value, x0, x1: x0 + nodeWidth, y0: y, y1: y + nodeHeight };\n    node.outOffset = node.y0;\n    node.inOffset = node.y0;\n    y += nodeHeight + catPad;\n    return node;\n  });\n});\n\n// Stack flow ribbons within each node's span in the order they connect.\nconst ribbons = flowLinks.map((f) => {\n  const sourceNode = nodes[f.wave][f.sourceCat];\n  const targetNode = nodes[f.wave + 1][f.targetCat];\n  const thickness = f.count * yScale;\n  const sy0 = sourceNode.outOffset;\n  const sy1 = sy0 + thickness;\n  const ty0 = targetNode.inOffset;\n  const ty1 = ty0 + thickness;\n  sourceNode.outOffset = sy1;\n  targetNode.inOffset = ty1;\n  return { ...f, x0: sourceNode.x1, x1: targetNode.x0, sy0, sy1, ty0, ty1 };\n});\n\nconst ribbonPath = (d) => {\n  const xm = (d.x0 + d.x1) / 2;\n  return `M${d.x0},${d.sy0}C${xm},${d.sy0} ${xm},${d.ty0} ${d.x1},${d.ty0}L${d.x1},${d.ty1}C${xm},${d.ty1} ${xm},${d.sy1} ${d.x0},${d.sy1}Z`;\n};\n\n// --- SVG mount ----------------------------------------------------------------\nconst svg = d3.select(\"#container\").append(\"svg\").attr(\"width\", width).attr(\"height\", height);\n\n// --- Title ----------------------------------------------------------------\nsvg\n  .append(\"text\")\n  .attr(\"x\", width / 2)\n  .attr(\"y\", 50)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"22px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"alluvial-opinion-flow · javascript · d3 · anyplot.ai\");\n\n// --- Polarization callout (net-flow annotation) --------------------------------\nconst calloutW = 420;\nconst calloutX = width - margin.right - calloutW;\nsvg\n  .append(\"rect\")\n  .attr(\"x\", calloutX)\n  .attr(\"y\", 70)\n  .attr(\"width\", calloutW)\n  .attr(\"height\", 54)\n  .attr(\"rx\", 6)\n  .attr(\"fill\", t.elevatedBg)\n  .attr(\"stroke\", t.grid);\nsvg\n  .append(\"text\")\n  .attr(\"x\", calloutX + 16)\n  .attr(\"y\", 92)\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"14px\")\n  .style(\"font-weight\", \"600\")\n  .text(\"Polarization trend\");\nsvg\n  .append(\"text\")\n  .attr(\"x\", calloutX + 16)\n  .attr(\"y\", 112)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text(\n    `Strongly Agree + Strongly Disagree: ${Math.round(extremeShareStart * 100)}% → ${Math.round(extremeShareEnd * 100)}% of respondents`\n  );\n\n// --- Wave column headers -----------------------------------------------------\nsvg\n  .selectAll(\".wave-header\")\n  .data(nodes.map((col, w) => ({ x: col[0].x0 + nodeWidth / 2, label: waveLabels[w] })))\n  .join(\"text\")\n  .attr(\"class\", \"wave-header\")\n  .attr(\"x\", (d) => d.x)\n  .attr(\"y\", margin.top - 30)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.ink)\n  .style(\"font-size\", \"16px\")\n  .style(\"font-weight\", \"600\")\n  .text((d) => d.label);\n\n// --- Ribbons (flows) ----------------------------------------------------------\n// Draw \"changed category\" ribbons first so the higher-opacity \"stable\" ribbons\n// layer on top, cutting down the crossing clutter around the Neutral band.\nconst sortedRibbons = ribbons\n  .slice()\n  .sort((a, b) => (a.sourceCat === a.targetCat ? 1 : 0) - (b.sourceCat === b.targetCat ? 1 : 0));\n\nsvg\n  .selectAll(\".ribbon\")\n  .data(sortedRibbons)\n  .join(\"path\")\n  .attr(\"class\", \"ribbon\")\n  .attr(\"d\", ribbonPath)\n  .attr(\"fill\", (d) => color(d.sourceCat))\n  .attr(\"fill-opacity\", (d) => {\n    const stable = d.sourceCat === d.targetCat;\n    const base = stable ? 0.55 : 0.2;\n    const thin = d.sy1 - d.sy0 < 10;\n    return thin ? base + 0.15 : base;\n  })\n  .attr(\"stroke\", (d) => (d.sourceCat === d.targetCat && isExtreme(d.sourceCat) ? color(d.sourceCat) : \"none\"))\n  .attr(\"stroke-width\", (d) => (d.sourceCat === d.targetCat && isExtreme(d.sourceCat) ? 1.5 : 0))\n  .attr(\"stroke-opacity\", 0.9);\n\n// --- Nodes ----------------------------------------------------------------\nconst nodeFlat = nodes.flat();\nsvg\n  .selectAll(\".node\")\n  .data(nodeFlat)\n  .join(\"rect\")\n  .attr(\"class\", \"node\")\n  .attr(\"x\", (d) => d.x0)\n  .attr(\"width\", nodeWidth)\n  .attr(\"y\", (d) => d.y0)\n  .attr(\"height\", (d) => d.y1 - d.y0)\n  .attr(\"fill\", (d) => color(d.cat))\n  .attr(\"stroke\", t.pageBg)\n  .attr(\"stroke-width\", 2);\n\n// --- Node value labels (respondent counts) -------------------------------\nsvg\n  .selectAll(\".node-label\")\n  .data(nodeFlat.filter((d) => d.y1 - d.y0 >= 22))\n  .join(\"text\")\n  .attr(\"class\", \"node-label\")\n  .attr(\"x\", (d) => d.x0 + nodeWidth / 2)\n  .attr(\"y\", (d) => (d.y0 + d.y1) / 2 + 4)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.pageBg)\n  .style(\"font-size\", \"13px\")\n  .style(\"font-weight\", \"600\")\n  .text((d) => Math.round(d.value));\n\n// --- Category name labels (first and last wave only) -----------------------\nconst firstWave = nodes[0];\nconst lastWave = nodes[nodes.length - 1];\nsvg\n  .selectAll(\".label-first\")\n  .data(firstWave)\n  .join(\"text\")\n  .attr(\"class\", \"label-first\")\n  .attr(\"x\", (d) => d.x0 - 12)\n  .attr(\"y\", (d) => (d.y0 + d.y1) / 2 + 4)\n  .attr(\"text-anchor\", \"end\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .text((d) => categories[d.cat]);\nsvg\n  .selectAll(\".label-last\")\n  .data(lastWave)\n  .join(\"text\")\n  .attr(\"class\", \"label-last\")\n  .attr(\"x\", (d) => d.x1 + 12)\n  .attr(\"y\", (d) => (d.y0 + d.y1) / 2 + 4)\n  .attr(\"text-anchor\", \"start\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"14px\")\n  .text((d) => categories[d.cat]);\n\n// --- Category abbreviation tags (interior waves) -----------------------------\n// Q1/Q4 get full names beside the column; interior waves get a compact tag\n// above each node so color + vertical position aren't the only identity signal.\nconst interiorNodes = nodes.slice(1, -1).flat();\nsvg\n  .selectAll(\".label-interior\")\n  .data(interiorNodes)\n  .join(\"text\")\n  .attr(\"class\", \"label-interior\")\n  .attr(\"x\", (d) => d.x0 + nodeWidth / 2)\n  .attr(\"y\", (d) => d.y0 - 6)\n  .attr(\"text-anchor\", \"middle\")\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"11px\")\n  .style(\"font-weight\", \"600\")\n  .text((d) => categoryAbbrev[d.cat]);\n\n// --- Stability legend (opacity encoding) --------------------------------------\nconst legendY = height - 34;\nconst legendItems = [\n  { label: \"Stable (same category)\", opacity: 0.55 },\n  { label: \"Changed category\", opacity: 0.2 },\n];\nconst legend = svg\n  .selectAll(\".legend-item\")\n  .data(legendItems)\n  .join(\"g\")\n  .attr(\"class\", \"legend-item\")\n  .attr(\"transform\", (_, i) => `translate(${width / 2 - 220 + i * 260},${legendY})`);\nlegend\n  .append(\"rect\")\n  .attr(\"width\", 28)\n  .attr(\"height\", 16)\n  .attr(\"fill\", t.inkSoft)\n  .attr(\"fill-opacity\", (d) => d.opacity);\nlegend\n  .append(\"text\")\n  .attr(\"x\", 36)\n  .attr(\"y\", 13)\n  .attr(\"fill\", t.inkSoft)\n  .style(\"font-size\", \"13px\")\n  .text((d) => d.label);\n"}