{"spec_id":"alluvial-opinion-flow","library":"highcharts","language":"javascript","code":"// anyplot.ai\n// alluvial-opinion-flow: Opinion Flow Diagram\n// Library: highcharts 12.6.0 | JavaScript 22.23.2\n// Quality: 90/100 | Created: 2026-08-26\n\n//# anyplot-orientation: landscape\n\n// The core Highcharts bundle has no sankey/alluvial module vendored (see\n// prompts/library/highcharts.md — only modules/* are excluded, chart.renderer\n// is core). Nodes and flow ribbons below are drawn natively with the SVG\n// renderer, the same low-level API Highcharts itself uses for its module\n// series types.\n\nconst t = window.ANYPLOT_TOKENS;\nconst size = window.ANYPLOT_SIZE;\n\n// --- Data: quarterly product-satisfaction survey, 1,000 respondents --------\n// Deterministic transition matrices — no fetch, no RNG. A Gaussian kernel\n// keeps most respondents near their previous category (the \"stable\" mass),\n// with a small drift toward the two extremes that grows each wave to model\n// a polarizing campaign response.\nconst WAVE_LABELS = [\"Q1 2026\", \"Q2 2026\", \"Q3 2026\", \"Q4 2026\"];\nconst CATEGORIES = [\"Very Satisfied\", \"Satisfied\", \"Neutral\", \"Dissatisfied\", \"Very Dissatisfied\"];\nconst N_WAVES = WAVE_LABELS.length;\nconst N_CAT = CATEGORIES.length;\n// First and last categories anchor to the semantic green/red pair; the three\n// middle categories take the next canonical Imprint slots in order (see\n// prompts/default-style-guide.md \"Categorical Palette\"). All 5 hexes are\n// fixed across themes so each category keeps its identity in both renders.\nconst COLORS = [t.palette[0], t.palette[1], t.palette[2], t.palette[3], t.palette[4]];\n\nfunction gaussian(distance, sigma) {\n  return Math.exp(-(distance * distance) / (2 * sigma * sigma));\n}\n\nfunction buildTransition(sourceTotals, waveIndex) {\n  const sigma = 1.0 + waveIndex * 0.2;\n  const matrix = [];\n  for (let i = 0; i < N_CAT; i++) {\n    const weights = [];\n    let weightSum = 0;\n    for (let j = 0; j < N_CAT; j++) {\n      let w = gaussian(j - i, sigma);\n      if (j !== i && (j === 0 || j === N_CAT - 1)) w *= 1 + waveIndex * 0.35;\n      weights.push(w);\n      weightSum += w;\n    }\n    const rowTotal = sourceTotals[i];\n    const rowCounts = weights.map((w) => Math.round((w / weightSum) * rowTotal));\n    const drift = rowTotal - rowCounts.reduce((sum, v) => sum + v, 0);\n    rowCounts[i] = Math.max(0, rowCounts[i] + drift);\n    matrix.push(rowCounts);\n  }\n  return matrix;\n}\n\nconst waveTotals = [[120, 230, 340, 220, 90]];\nconst transitions = [];\nfor (let w = 0; w < N_WAVES - 1; w++) {\n  const matrix = buildTransition(waveTotals[w], w);\n  transitions.push(matrix);\n  const nextTotals = new Array(N_CAT).fill(0);\n  for (let i = 0; i < N_CAT; i++) {\n    for (let j = 0; j < N_CAT; j++) nextTotals[j] += matrix[i][j];\n  }\n  waveTotals.push(nextTotals);\n}\nconst RESPONDENTS = waveTotals[0].reduce((a, b) => a + b, 0);\n\n// Net-flow summary (Q1 -> Q4): how many percentage points shifted into the\n// two extreme categories versus out of Neutral, to make the polarization\n// trend an explicit, named callout rather than an implicit visual pattern.\nconst firstWave = waveTotals[0];\nconst lastWave = waveTotals[N_WAVES - 1];\nconst extremesPct = Math.round(\n  ((lastWave[0] + lastWave[N_CAT - 1] - (firstWave[0] + firstWave[N_CAT - 1])) / RESPONDENTS) * 100,\n);\nconst neutralPct = Math.round(((lastWave[2] - firstWave[2]) / RESPONDENTS) * 100);\n\n// --- Layout ------------------------------------------------------------\nconst marginX = 140;\nconst plotTop = 190;\nconst plotBottom = size.height - 110;\nconst nodeWidth = 100;\nconst nodeGap = 10;\nconst usableHeight = plotBottom - plotTop - nodeGap * (N_CAT - 1);\nconst pxPerRespondent = usableHeight / RESPONDENTS;\nconst colX = (w) => marginX + (w * (size.width - 2 * marginX)) / (N_WAVES - 1);\n\n// nodes[wave][category] = { y0, y1, total }\nconst nodes = waveTotals.map((totals) => {\n  let cursor = plotTop;\n  return totals.map((total) => {\n    const h = total * pxPerRespondent;\n    const node = { y0: cursor, y1: cursor + h, total };\n    cursor += h + nodeGap;\n    return node;\n  });\n});\n\nfunction subdivide(node, valuesInOrder) {\n  const total = valuesInOrder.reduce((a, b) => a + b, 0);\n  const height = node.y1 - node.y0;\n  let cursor = node.y0;\n  return valuesInOrder.map((v) => {\n    const h = total > 0 ? (v / total) * height : 0;\n    const seg = { y0: cursor, y1: cursor + h };\n    cursor += h;\n    return seg;\n  });\n}\n\nfunction luminance(hex) {\n  const [r, g, b] = hex\n    .replace(\"#\", \"\")\n    .match(/.{2}/g)\n    .map((c) => parseInt(c, 16) / 255)\n    .map((v) => (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)));\n  return 0.2126 * r + 0.7152 * g + 0.0722 * b;\n}\nfunction contrastText(hex) {\n  return luminance(hex) > 0.45 ? \"#1A1A17\" : \"#F0EFE8\";\n}\n\n// --- Chart (empty series — axes hidden, everything drawn via the renderer) -\nconst chart = Highcharts.chart(\"container\", {\n  chart: { backgroundColor: \"transparent\", animation: false, style: { fontFamily: \"inherit\" } },\n  credits: { enabled: false },\n  title: {\n    text: \"alluvial-opinion-flow · javascript · highcharts · anyplot.ai\",\n    style: { color: t.ink, fontSize: \"22px\", fontWeight: \"600\" },\n  },\n  subtitle: {\n    text: \"Quarterly product-satisfaction survey · n = 1,000 respondents per wave\",\n    style: { color: t.inkSoft, fontSize: \"14px\" },\n  },\n  xAxis: { visible: false },\n  yAxis: { visible: false },\n  legend: { enabled: false },\n  plotOptions: { series: { animation: false } },\n  series: [],\n});\n\n// --- Net-flow callout (explicit polarization-trend highlight, SC-02) -----\nchart.renderer\n  .text(\n    `Net drift Q1 → Q4: extremes +${extremesPct}pp · Neutral ${neutralPct}pp`,\n    size.width / 2,\n    plotTop - 62,\n  )\n  .attr({ align: \"center\" })\n  .css({ color: t.ink, fontSize: \"13px\", fontWeight: \"600\", fontStyle: \"italic\" })\n  .add();\n\n// --- Column headers ------------------------------------------------------\nWAVE_LABELS.forEach((label, w) => {\n  chart.renderer\n    .text(label, colX(w) + nodeWidth / 2, plotTop - 35)\n    .attr({ align: \"center\" })\n    .css({ color: t.ink, fontSize: \"17px\", fontWeight: \"600\" })\n    .add();\n});\n\n// --- Flow ribbons (drawn first so node blocks sit on top) ----------------\nfor (let w = 0; w < N_WAVES - 1; w++) {\n  const matrix = transitions[w];\n  const sourceNodes = nodes[w];\n  const targetNodes = nodes[w + 1];\n  const sourceSegs = sourceNodes.map((node, i) => subdivide(node, matrix[i]));\n  const targetSegs = targetNodes.map((node, j) => subdivide(node, matrix.map((row) => row[j])));\n\n  const x0 = colX(w) + nodeWidth;\n  const x1 = colX(w + 1);\n  const xMid = (x0 + x1) / 2;\n\n  for (let i = 0; i < N_CAT; i++) {\n    for (let j = 0; j < N_CAT; j++) {\n      const value = matrix[i][j];\n      if (value <= 0) continue;\n      const s = sourceSegs[i][j];\n      const d = targetSegs[j][i];\n      const stable = i === j;\n      const path = [\n        \"M\", x0, s.y0,\n        \"C\", xMid, s.y0, xMid, d.y0, x1, d.y0,\n        \"L\", x1, d.y1,\n        \"C\", xMid, d.y1, xMid, s.y1, x0, s.y1,\n        \"Z\",\n      ];\n      chart.renderer\n        .path(path)\n        .attr({\n          fill: COLORS[i],\n          opacity: stable ? 0.6 : 0.42,\n          stroke: COLORS[i],\n          \"stroke-width\": 0.5,\n          \"stroke-opacity\": stable ? 0.6 : 0.55,\n        })\n        .add();\n    }\n  }\n}\n\n// --- Nodes + respondent-count labels --------------------------------------\nnodes.forEach((waveNodes, w) => {\n  const x = colX(w);\n  waveNodes.forEach((node, c) => {\n    chart.renderer\n      .rect(x, node.y0, nodeWidth, node.y1 - node.y0, 2)\n      .attr({ fill: COLORS[c], opacity: 0.95 })\n      .add();\n\n    const height = node.y1 - node.y0;\n    if (height >= 26) {\n      chart.renderer\n        .text(`n = ${node.total.toLocaleString(\"en-US\")}`, x + nodeWidth / 2, (node.y0 + node.y1) / 2 + 4)\n        .attr({ align: \"center\" })\n        .css({ color: contrastText(COLORS[c]), fontSize: \"13px\", fontWeight: \"600\" })\n        .add();\n    }\n  });\n});\n\n// --- Category legend (measured and centered under the plot) --------------\nconst legendY = size.height - 50;\nconst swatchSize = 16;\nconst swatchTextGap = 8;\nconst itemGap = 28;\nconst legendEntries = CATEGORIES.map((name, idx) => {\n  const label = chart.renderer.text(name, 0, legendY).css({ color: t.inkSoft, fontSize: \"14px\" }).add();\n  return { idx, label, width: label.getBBox().width };\n});\nconst legendWidth =\n  legendEntries.reduce((sum, e) => sum + swatchSize + swatchTextGap + e.width, 0) + itemGap * (legendEntries.length - 1);\nlet cursorX = (size.width - legendWidth) / 2;\nlegendEntries.forEach((e) => {\n  chart.renderer\n    .rect(cursorX, legendY - swatchSize + 3, swatchSize, swatchSize, 3)\n    .attr({ fill: COLORS[e.idx] })\n    .add();\n  e.label.attr({ x: cursorX + swatchSize + swatchTextGap, y: legendY });\n  cursorX += swatchSize + swatchTextGap + e.width + itemGap;\n});\n"}