{"spec_id":"alluvial-opinion-flow","library":"echarts","language":"javascript","code":"// anyplot.ai\n// alluvial-opinion-flow: Opinion Flow Diagram\n// Library: echarts 6.1.0 | JavaScript 22.23.2\n// Quality: 92/100 | Created: 2026-08-26\n\nconst t = window.ANYPLOT_TOKENS;\nconst MUTED = t.theme === \"light\" ? \"#6B6A63\" : \"#A8A79F\";\n\n// --- Data: quarterly policy-approval survey, 1000 respondents ---------------\n// Sentiment scale colored via the Imprint semantic exception: agree side\n// green-family, disagree side red-family, neutral side the muted anchor.\nconst CATEGORIES = [\n  { key: \"Strongly Agree\", color: t.palette[0] },\n  { key: \"Agree\", color: t.palette[7] },\n  { key: \"Neutral\", color: MUTED },\n  { key: \"Disagree\", color: t.palette[3] },\n  { key: \"Strongly Disagree\", color: t.palette[4] },\n];\nconst WAVES = [\"Q1 2024\", \"Q2 2024\", \"Q3 2024\", \"Q4 2024\"];\n\n// Respondent counts transitioning between categories, one 5x5 matrix per\n// wave-to-wave gap. Rows/cols follow CATEGORIES order. Each row sums to the\n// source category's total for that wave.\nconst TRANSITIONS = [\n  [\n    [150, 22, 5, 2, 1],\n    [18, 190, 40, 9, 3],\n    [6, 50, 130, 44, 10],\n    [1, 10, 35, 130, 24],\n    [0, 3, 12, 25, 80],\n  ],\n  [\n    [145, 22, 5, 2, 1],\n    [20, 200, 40, 11, 4],\n    [6, 48, 110, 48, 10],\n    [1, 11, 35, 135, 28],\n    [0, 3, 10, 25, 80],\n  ],\n  [\n    [150, 16, 4, 1, 1],\n    [22, 210, 38, 10, 4],\n    [6, 42, 95, 47, 10],\n    [1, 10, 32, 150, 28],\n    [0, 3, 8, 22, 90],\n  ],\n];\n\n// --- Build nodes + links ------------------------------------------------\n// Node \"name\" is the sankey's unique key (referenced by links), so it embeds\n// the wave to stay unique; the visible label is rendered via a formatter\n// that shows only the category and its respondent total.\nconst nodeName = (wave, categoryIndex) => wave + \"|\" + categoryIndex;\n\nconst totals = [[180, 260, 240, 200, 120]];\nTRANSITIONS.forEach((matrix) => {\n  const next = CATEGORIES.map((_, col) =>\n    matrix.reduce((sum, row) => sum + row[col], 0),\n  );\n  totals.push(next);\n});\n\n// Net polarization callout: how the neutral middle and the disagree side\n// moved from the first wave to the last — computed from the same totals\n// driving the diagram, not a separate hard-coded claim.\nconst firstTotals = totals[0];\nconst lastTotals = totals[totals.length - 1];\nconst pctChange = (from, to) => Math.round(((to - from) / from) * 100);\nconst neutralPct = pctChange(firstTotals[2], lastTotals[2]);\nconst disagreeSidePct = pctChange(\n  firstTotals[3] + firstTotals[4],\n  lastTotals[3] + lastTotals[4],\n);\nconst polarizationCallout =\n  `Polarization trend: Neutral ${neutralPct}%, ` +\n  `Disagree + Strongly Disagree ${disagreeSidePct >= 0 ? \"+\" : \"\"}${disagreeSidePct}% (Q1 → Q4)`;\n\nconst nodes = [];\nWAVES.forEach((wave, w) => {\n  CATEGORIES.forEach((cat, c) => {\n    nodes.push({\n      name: nodeName(wave, c),\n      category: cat.key,\n      total: totals[w][c],\n      itemStyle: { color: cat.color, borderColor: t.pageBg, borderWidth: 1 },\n      label: {\n        position: w === WAVES.length - 1 ? \"left\" : \"right\",\n        formatter: (params) =>\n          params.data.category + \"\\n\" + params.data.total.toLocaleString(),\n      },\n    });\n  });\n});\n\nconst links = [];\nTRANSITIONS.forEach((matrix, gap) => {\n  const sourceWave = WAVES[gap];\n  const targetWave = WAVES[gap + 1];\n  matrix.forEach((row, srcIdx) => {\n    row.forEach((count, tgtIdx) => {\n      if (count === 0) return;\n      const stable = srcIdx === tgtIdx;\n      links.push({\n        source: nodeName(sourceWave, srcIdx),\n        target: nodeName(targetWave, tgtIdx),\n        value: count,\n        lineStyle: {\n          color: CATEGORIES[srcIdx].color,\n          opacity: stable ? 0.55 : 0.09,\n          curveness: 0.42,\n        },\n      });\n    });\n  });\n});\n\n// --- Init ---------------------------------------------------------------\nconst chart = echarts.init(document.getElementById(\"container\"));\n\n// --- Column headers (waves), placed above the sankey columns -------------\nconst headerXPct = [5, 35, 65, 95];\nconst headerAlign = [\"left\", \"center\", \"center\", \"right\"];\nconst graphicHeaders = WAVES.map((wave, i) => ({\n  type: \"text\",\n  left: `${headerXPct[i]}%`,\n  top: 96,\n  style: {\n    text: wave,\n    fill: t.ink,\n    fontSize: 18,\n    fontWeight: 600,\n    align: headerAlign[i],\n  },\n}));\n\n// Explicit callout (spec asks to \"highlight net flows … to reveal\n// polarization trends\") so the shrinking-middle / growing-disagreement\n// story reads without comparing node totals by eye.\nconst polarizationAnnotation = {\n  type: \"text\",\n  left: \"center\",\n  top: 62,\n  style: {\n    text: polarizationCallout,\n    fill: t.inkSoft,\n    fontSize: 15,\n    fontWeight: 600,\n    align: \"center\",\n  },\n};\n\n// --- Option ---------------------------------------------------------------\nchart.setOption({\n  animation: false,\n  backgroundColor: \"transparent\",\n  title: {\n    text: \"alluvial-opinion-flow · javascript · echarts · anyplot.ai\",\n    left: \"center\",\n    top: 24,\n    textStyle: { color: t.ink, fontSize: 22 },\n  },\n  graphic: { elements: [polarizationAnnotation, ...graphicHeaders] },\n  series: [\n    {\n      type: \"sankey\",\n      nodes,\n      links,\n      top: 140,\n      bottom: 50,\n      left: 70,\n      right: 70,\n      nodeWidth: 22,\n      nodeGap: 16,\n      nodeAlign: \"justify\",\n      layoutIterations: 0,\n      emphasis: { focus: \"adjacency\" },\n      label: {\n        color: t.inkSoft,\n        fontSize: 13,\n        lineHeight: 16,\n      },\n      lineStyle: { curveness: 0.42 },\n    },\n  ],\n});\n"}