{"spec_id":"alluvial-opinion-flow","library":"plotly","language":"python","code":"\"\"\" anyplot.ai\nalluvial-opinion-flow: Opinion Flow Diagram\nLibrary: plotly 6.7.0 | Python 3.13.13\nQuality: 90/100 | Updated: 2026-05-30\n\"\"\"\n\nimport os\n\nimport plotly.graph_objects as go\n\n\n# Theme tokens — Imprint palette, theme-adaptive chrome\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nELEVATED_BG = \"#FFFDF6\" if THEME == \"light\" else \"#242420\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\nINK_MUTED = \"#6B6A63\" if THEME == \"light\" else \"#A8A79F\"\n\n# Opinion category colors — Imprint palette semantic exception (positive→green, negative→red)\ncat_colors = {\n    \"Strongly Agree\": \"#009E73\",  # brand green — strong positive\n    \"Agree\": \"#4467A3\",  # blue — moderate positive\n    \"Neutral\": INK_MUTED,  # theme-adaptive muted — neutral/rest anchor\n    \"Disagree\": \"#BD8233\",  # ochre — mild negative\n    \"Strongly Disagree\": \"#AE3030\",  # matte red — strong negative\n}\n\n# Data: 1000 respondents tracking opinions on public transit expansion across 4 quarterly waves\nwaves = [\"Q1 2025\", \"Q2 2025\", \"Q3 2025\", \"Q4 2025\"]\ncategories = [\"Strongly Agree\", \"Agree\", \"Neutral\", \"Disagree\", \"Strongly Disagree\"]\nn_cats = len(categories)\nn_waves = len(waves)\n\n# Transition flows between consecutive waves: (source_cat_idx, target_cat_idx, count)\n# Pattern: polarization — Neutral shrinks as extremes grow\ntransitions_w1_w2 = [\n    (0, 0, 130),\n    (0, 1, 20),\n    (1, 0, 25),\n    (1, 1, 200),\n    (1, 2, 25),\n    (2, 1, 20),\n    (2, 2, 230),\n    (2, 3, 35),\n    (2, 4, 15),\n    (3, 2, 10),\n    (3, 3, 165),\n    (3, 4, 25),\n    (4, 3, 5),\n    (4, 4, 95),\n]\ntransitions_w2_w3 = [\n    (0, 0, 135),\n    (0, 1, 20),\n    (1, 0, 30),\n    (1, 1, 185),\n    (1, 2, 25),\n    (2, 1, 15),\n    (2, 2, 195),\n    (2, 3, 40),\n    (2, 4, 15),\n    (3, 2, 10),\n    (3, 3, 170),\n    (3, 4, 25),\n    (4, 3, 5),\n    (4, 4, 130),\n]\ntransitions_w3_w4 = [\n    (0, 0, 150),\n    (0, 1, 15),\n    (1, 0, 35),\n    (1, 1, 160),\n    (1, 2, 25),\n    (2, 1, 10),\n    (2, 2, 160),\n    (2, 3, 45),\n    (2, 4, 15),\n    (3, 2, 10),\n    (3, 3, 175),\n    (3, 4, 30),\n    (4, 3, 5),\n    (4, 4, 165),\n]\nall_transitions = [transitions_w1_w2, transitions_w2_w3, transitions_w3_w4]\n\n# Per-wave totals for node labels\nwave_totals = [\n    [150, 250, 300, 200, 100],\n    [155, 240, 265, 205, 135],\n    [165, 220, 230, 215, 170],\n    [185, 185, 195, 225, 210],\n]\n\n# Build node arrays — y range [0.10, 0.90] on square canvas gives each node enough height\nnode_labels = []\nnode_colors = []\nx_positions = []\ny_positions = []\n\nfor w in range(n_waves):\n    for c in range(n_cats):\n        cat_name = categories[c]\n        count = wave_totals[w][c]\n        node_labels.append(str(count))\n        node_colors.append(cat_colors[cat_name])\n        x_positions.append(0.05 + (w / (n_waves - 1)) * 0.80)\n        y_positions.append(0.10 + (c / (n_cats - 1)) * 0.80)\n\n# Build link arrays\nsources = []\ntargets = []\nvalues = []\nlink_colors = []\nlink_customdata = []\n\nfor wave_idx, trans in enumerate(all_transitions):\n    for src_cat, tgt_cat, count in trans:\n        src_node = wave_idx * n_cats + src_cat\n        tgt_node = (wave_idx + 1) * n_cats + tgt_cat\n        sources.append(src_node)\n        targets.append(tgt_node)\n        values.append(count)\n\n        is_stable = src_cat == tgt_cat\n        hex_color = cat_colors[categories[src_cat]]\n        r = int(hex_color[1:3], 16)\n        g = int(hex_color[3:5], 16)\n        b = int(hex_color[5:7], 16)\n        opacity = 0.55 if is_stable else 0.35\n        link_colors.append(f\"rgba({r},{g},{b},{opacity})\")\n\n        link_customdata.append(\n            [\n                categories[src_cat],\n                waves[wave_idx],\n                categories[tgt_cat],\n                waves[wave_idx + 1],\n                \"Stable\" if is_stable else \"Changed\",\n            ]\n        )\n\n# Plot\nfig = go.Figure(\n    data=[\n        go.Sankey(\n            arrangement=\"snap\",\n            textfont={\"size\": 14, \"color\": INK},\n            node={\n                \"pad\": 18,\n                \"thickness\": 30,\n                \"line\": {\"color\": PAGE_BG, \"width\": 2},\n                \"label\": node_labels,\n                \"color\": node_colors,\n                \"x\": x_positions,\n                \"y\": y_positions,\n                \"hovertemplate\": \"<b>%{label}</b><br>Respondents: %{value:,}<extra></extra>\",\n            },\n            link={\n                \"source\": sources,\n                \"target\": targets,\n                \"value\": values,\n                \"color\": link_colors,\n                \"customdata\": link_customdata,\n                \"hovertemplate\": (\n                    \"<b>%{customdata[0]}</b> (%{customdata[1]})<br>\"\n                    \"→ <b>%{customdata[2]}</b> (%{customdata[3]})<br>\"\n                    \"Respondents: <b>%{value:,}</b><br>\"\n                    \"Status: %{customdata[4]}<extra></extra>\"\n                ),\n            },\n        )\n    ]\n)\n\n# Title — scaled fontsize for 75-char title (floor 11px, default 16px)\nTITLE = \"Opinion Polarization · alluvial-opinion-flow · python · plotly · anyplot.ai\"\ntitle_fontsize = max(11, round(16 * 67 / len(TITLE)))\n\n# Subtitle annotation narrating the key insight\nfig.add_annotation(\n    x=0.5,\n    y=1.18,\n    xref=\"paper\",\n    yref=\"paper\",\n    text=\"Neutral respondents declined 35% as opinions polarized toward extremes over four quarters\",\n    showarrow=False,\n    font={\"size\": 14, \"color\": INK_SOFT},\n    xanchor=\"center\",\n)\n\n# Wave column headers above nodes\nwave_x_paper = [0.07, 0.335, 0.60, 0.87]\nfor i, wave in enumerate(waves):\n    fig.add_annotation(\n        x=wave_x_paper[i],\n        y=1.08,\n        xref=\"paper\",\n        yref=\"paper\",\n        text=f\"<b>{wave}</b>\",\n        showarrow=False,\n        font={\"size\": 20, \"color\": INK},\n        xanchor=\"center\",\n    )\n\n# Net change annotations on right side highlighting polarization trend\n# Sankey y=0 is top, y=1 is bottom — invert to paper coordinates (y=0 bottom, y=1 top)\nnet_changes = [\n    (categories[c], wave_totals[-1][c] - wave_totals[0][c], cat_colors[categories[c]]) for c in range(n_cats)\n]\n\nfor c in range(n_cats):\n    cat_name, delta, color = net_changes[c]\n    sign = \"+\" if delta > 0 else \"\"\n    node_y = 0.10 + (c / (n_cats - 1)) * 0.80\n    paper_y = 1.0 - node_y\n    fig.add_annotation(\n        x=1.01,\n        y=paper_y,\n        xref=\"paper\",\n        yref=\"paper\",\n        text=f\"<b>{sign}{delta}</b>\",\n        showarrow=False,\n        font={\"size\": 18, \"color\": color},\n        xanchor=\"left\",\n    )\n\n# Legend via invisible scatter traces\nfor cat, color in cat_colors.items():\n    fig.add_trace(\n        go.Scatter(\n            x=[None],\n            y=[None],\n            mode=\"markers\",\n            marker={\"size\": 16, \"color\": color, \"symbol\": \"square\"},\n            name=cat,\n            showlegend=True,\n        )\n    )\n\n# Layout — square canvas (2400×2400) gives ample vertical space for 5 stacked category nodes\nfig.update_layout(\n    autosize=False,\n    title={\"text\": TITLE, \"font\": {\"size\": title_fontsize, \"color\": INK}, \"x\": 0.5, \"xanchor\": \"center\", \"y\": 0.985},\n    font={\"size\": 14, \"color\": INK},\n    template=\"plotly_white\",\n    margin={\"l\": 60, \"r\": 80, \"t\": 180, \"b\": 170},\n    paper_bgcolor=PAGE_BG,\n    plot_bgcolor=PAGE_BG,\n    legend={\n        \"orientation\": \"h\",\n        \"yanchor\": \"top\",\n        \"y\": -0.08,\n        \"xanchor\": \"center\",\n        \"x\": 0.5,\n        \"font\": {\"size\": 14, \"color\": INK_SOFT},\n        \"bgcolor\": ELEVATED_BG,\n        \"bordercolor\": INK_SOFT,\n        \"borderwidth\": 1,\n        \"itemsizing\": \"constant\",\n    },\n    xaxis={\"visible\": False},\n    yaxis={\"visible\": False},\n)\n\n# Save — canvas: 600×600 × scale=4 → 2400×2400 px (square, 5 vertical categories benefit from equal h/w)\nfig.write_image(f\"plot-{THEME}.png\", width=600, height=600, scale=4)\nfig.write_html(f\"plot-{THEME}.html\", include_plotlyjs=\"cdn\")\n"}