{"spec_id":"alluvial-opinion-flow","library":"matplotlib","language":"python","code":"\"\"\" anyplot.ai\nalluvial-opinion-flow: Opinion Flow Diagram\nLibrary: matplotlib 3.10.9 | Python 3.13.13\nQuality: 92/100 | Updated: 2026-05-30\n\"\"\"\n\nimport os\n\nimport matplotlib.patches as mpatches\nimport matplotlib.patheffects as patheffects\nimport matplotlib.pyplot as plt\nimport numpy as np\nfrom matplotlib.path import Path\n\n\n# Theme tokens\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# Imprint palette — semantic mapping for diverging opinion scale\n# Strongly positive → green, positive → cyan, neutral → muted, negative → ochre, strongly negative → red\nCAT_COLORS = {\n    \"Strongly Support\": \"#009E73\",  # Imprint brand green — strongly positive\n    \"Support\": \"#2ABCCD\",  # Imprint cyan — positive\n    \"Neutral\": INK_MUTED,  # Imprint muted anchor — undecided\n    \"Oppose\": \"#BD8233\",  # Imprint ochre — negative leaning\n    \"Strongly Oppose\": \"#AE3030\",  # Imprint matte red — strongly negative\n}\n\n# Data: Voter opinion on climate legislation tracked across 4 quarterly waves (n=1000)\nnp.random.seed(42)\n\nwaves = [\"Q1 2025\", \"Q2 2025\", \"Q3 2025\", \"Q4 2025\"]\ncategories = [\"Strongly Support\", \"Support\", \"Neutral\", \"Oppose\", \"Strongly Oppose\"]\n\nnode_values = {\n    \"Q1 2025\": {\"Strongly Support\": 160, \"Support\": 260, \"Neutral\": 230, \"Oppose\": 195, \"Strongly Oppose\": 155},\n    \"Q2 2025\": {\"Strongly Support\": 180, \"Support\": 245, \"Neutral\": 200, \"Oppose\": 205, \"Strongly Oppose\": 170},\n    \"Q3 2025\": {\"Strongly Support\": 205, \"Support\": 225, \"Neutral\": 165, \"Oppose\": 215, \"Strongly Oppose\": 190},\n    \"Q4 2025\": {\"Strongly Support\": 230, \"Support\": 200, \"Neutral\": 135, \"Oppose\": 225, \"Strongly Oppose\": 210},\n}\n\n# Flow matrices — row = source, col = target; row sums match source wave totals\nflows = [\n    # Q1 → Q2\n    {\n        (\"Strongly Support\", \"Strongly Support\"): 140,\n        (\"Strongly Support\", \"Support\"): 15,\n        (\"Strongly Support\", \"Neutral\"): 5,\n        (\"Strongly Support\", \"Oppose\"): 0,\n        (\"Strongly Support\", \"Strongly Oppose\"): 0,\n        (\"Support\", \"Strongly Support\"): 30,\n        (\"Support\", \"Support\"): 208,\n        (\"Support\", \"Neutral\"): 17,\n        (\"Support\", \"Oppose\"): 5,\n        (\"Support\", \"Strongly Oppose\"): 0,\n        (\"Neutral\", \"Strongly Support\"): 7,\n        (\"Neutral\", \"Support\"): 20,\n        (\"Neutral\", \"Neutral\"): 156,\n        (\"Neutral\", \"Oppose\"): 37,\n        (\"Neutral\", \"Strongly Oppose\"): 10,\n        (\"Oppose\", \"Strongly Support\"): 3,\n        (\"Oppose\", \"Support\"): 2,\n        (\"Oppose\", \"Neutral\"): 22,\n        (\"Oppose\", \"Oppose\"): 143,\n        (\"Oppose\", \"Strongly Oppose\"): 25,\n        (\"Strongly Oppose\", \"Strongly Support\"): 0,\n        (\"Strongly Oppose\", \"Support\"): 0,\n        (\"Strongly Oppose\", \"Neutral\"): 0,\n        (\"Strongly Oppose\", \"Oppose\"): 20,\n        (\"Strongly Oppose\", \"Strongly Oppose\"): 135,\n    },\n    # Q2 → Q3\n    {\n        (\"Strongly Support\", \"Strongly Support\"): 155,\n        (\"Strongly Support\", \"Support\"): 20,\n        (\"Strongly Support\", \"Neutral\"): 5,\n        (\"Strongly Support\", \"Oppose\"): 0,\n        (\"Strongly Support\", \"Strongly Oppose\"): 0,\n        (\"Support\", \"Strongly Support\"): 38,\n        (\"Support\", \"Support\"): 180,\n        (\"Support\", \"Neutral\"): 18,\n        (\"Support\", \"Oppose\"): 9,\n        (\"Support\", \"Strongly Oppose\"): 0,\n        (\"Neutral\", \"Strongly Support\"): 9,\n        (\"Neutral\", \"Support\"): 22,\n        (\"Neutral\", \"Neutral\"): 120,\n        (\"Neutral\", \"Oppose\"): 39,\n        (\"Neutral\", \"Strongly Oppose\"): 10,\n        (\"Oppose\", \"Strongly Support\"): 3,\n        (\"Oppose\", \"Support\"): 3,\n        (\"Oppose\", \"Neutral\"): 22,\n        (\"Oppose\", \"Oppose\"): 147,\n        (\"Oppose\", \"Strongly Oppose\"): 30,\n        (\"Strongly Oppose\", \"Strongly Support\"): 0,\n        (\"Strongly Oppose\", \"Support\"): 0,\n        (\"Strongly Oppose\", \"Neutral\"): 0,\n        (\"Strongly Oppose\", \"Oppose\"): 20,\n        (\"Strongly Oppose\", \"Strongly Oppose\"): 150,\n    },\n    # Q3 → Q4\n    {\n        (\"Strongly Support\", \"Strongly Support\"): 182,\n        (\"Strongly Support\", \"Support\"): 18,\n        (\"Strongly Support\", \"Neutral\"): 5,\n        (\"Strongly Support\", \"Oppose\"): 0,\n        (\"Strongly Support\", \"Strongly Oppose\"): 0,\n        (\"Support\", \"Strongly Support\"): 37,\n        (\"Support\", \"Support\"): 162,\n        (\"Support\", \"Neutral\"): 18,\n        (\"Support\", \"Oppose\"): 8,\n        (\"Support\", \"Strongly Oppose\"): 0,\n        (\"Neutral\", \"Strongly Support\"): 8,\n        (\"Neutral\", \"Support\"): 17,\n        (\"Neutral\", \"Neutral\"): 105,\n        (\"Neutral\", \"Oppose\"): 25,\n        (\"Neutral\", \"Strongly Oppose\"): 10,\n        (\"Oppose\", \"Strongly Support\"): 3,\n        (\"Oppose\", \"Support\"): 3,\n        (\"Oppose\", \"Neutral\"): 7,\n        (\"Oppose\", \"Oppose\"): 172,\n        (\"Oppose\", \"Strongly Oppose\"): 30,\n        (\"Strongly Oppose\", \"Strongly Support\"): 0,\n        (\"Strongly Oppose\", \"Support\"): 0,\n        (\"Strongly Oppose\", \"Neutral\"): 0,\n        (\"Strongly Oppose\", \"Oppose\"): 20,\n        (\"Strongly Oppose\", \"Strongly Oppose\"): 170,\n    },\n]\n\n# Plot\nfig, ax = plt.subplots(figsize=(8, 4.5), dpi=400, facecolor=PAGE_BG)\nax.set_facecolor(PAGE_BG)\n\nx_positions = [2.0, 4.5, 7.0, 9.5]\nnode_width = 0.8\ntotal_height = 7.5\nnode_gap = 0.22\n\n# Calculate node positions\nnode_bounds = {}\nfor t_idx, wave in enumerate(waves):\n    values = [node_values[wave][cat] for cat in categories]\n    total = sum(values)\n    usable_height = total_height - (len(categories) - 1) * node_gap\n    heights = [v / total * usable_height for v in values]\n\n    y = 0.5\n    for c_idx, cat in enumerate(categories):\n        h = heights[c_idx]\n        node_bounds[(wave, cat)] = {\"x\": x_positions[t_idx], \"y_start\": y, \"height\": h}\n        y += h + node_gap\n\n# Draw bezier flows between consecutive waves\nfor t_idx in range(len(waves) - 1):\n    wave_from = waves[t_idx]\n    wave_to = waves[t_idx + 1]\n\n    from_offsets = dict.fromkeys(categories, 0.0)\n    to_offsets = dict.fromkeys(categories, 0.0)\n\n    flow_data = flows[t_idx]\n    for from_cat in categories:\n        for to_cat in categories:\n            flow_val = flow_data.get((from_cat, to_cat), 0)\n            if flow_val <= 0:\n                continue\n\n            from_node = node_bounds[(wave_from, from_cat)]\n            to_node = node_bounds[(wave_to, to_cat)]\n\n            from_total = sum(node_values[wave_from].values())\n            to_total = sum(node_values[wave_to].values())\n\n            usable_height = total_height - (len(categories) - 1) * node_gap\n            from_height = flow_val / from_total * usable_height\n            to_height = flow_val / to_total * usable_height\n\n            x0 = from_node[\"x\"] + node_width / 2\n            x1 = to_node[\"x\"] - node_width / 2\n            mid_x = (x0 + x1) / 2\n\n            y0_start = from_node[\"y_start\"] + from_offsets[from_cat]\n            y0_end = y0_start + from_height\n            y1_start = to_node[\"y_start\"] + to_offsets[to_cat]\n            y1_end = y1_start + to_height\n\n            is_stable = from_cat == to_cat\n            alpha = 0.55 if is_stable else 0.22\n\n            verts = [\n                (x0, y0_start),\n                (mid_x, y0_start),\n                (mid_x, y1_start),\n                (x1, y1_start),\n                (x1, y1_end),\n                (mid_x, y1_end),\n                (mid_x, y0_end),\n                (x0, y0_end),\n                (x0, y0_start),\n            ]\n            codes = [\n                Path.MOVETO,\n                Path.CURVE4,\n                Path.CURVE4,\n                Path.CURVE4,\n                Path.LINETO,\n                Path.CURVE4,\n                Path.CURVE4,\n                Path.CURVE4,\n                Path.CLOSEPOLY,\n            ]\n            path = Path(verts, codes)\n            patch = mpatches.PathPatch(path, facecolor=CAT_COLORS[from_cat], edgecolor=\"none\", alpha=alpha)\n            ax.add_patch(patch)\n\n            from_offsets[from_cat] += from_height\n            to_offsets[to_cat] += to_height\n\n# Draw nodes with rounded corners\nshadow_effect = [patheffects.withSimplePatchShadow(offset=(1.0, -1.0), shadow_rgbFace=\"#00000012\")]\nshort_names = {\n    \"Strongly Support\": \"Str.Sup\",\n    \"Support\": \"Support\",\n    \"Neutral\": \"Neutral\",\n    \"Oppose\": \"Oppose\",\n    \"Strongly Oppose\": \"Str.Opp\",\n}\nfor wave in waves:\n    for cat in categories:\n        node = node_bounds[(wave, cat)]\n        rect = mpatches.FancyBboxPatch(\n            (node[\"x\"] - node_width / 2, node[\"y_start\"]),\n            node_width,\n            node[\"height\"],\n            boxstyle=\"round,pad=0.02\",\n            facecolor=CAT_COLORS[cat],\n            edgecolor=PAGE_BG,\n            linewidth=1.5,\n            path_effects=shadow_effect,\n        )\n        ax.add_patch(rect)\n\n        count = node_values[wave][cat]\n        label = f\"{short_names[cat]}\\nn={count}\"\n        text_color = INK if cat == \"Neutral\" else \"white\"\n        ax.text(\n            node[\"x\"],\n            node[\"y_start\"] + node[\"height\"] / 2,\n            label,\n            ha=\"center\",\n            va=\"center\",\n            fontsize=7,\n            fontweight=\"bold\",\n            color=text_color,\n            path_effects=[patheffects.withStroke(linewidth=0.8, foreground=\"#00000015\")],\n        )\n\n# Wave column headers\nfor t_idx, wave in enumerate(waves):\n    ax.text(\n        x_positions[t_idx],\n        total_height + 0.75,\n        wave,\n        ha=\"center\",\n        va=\"bottom\",\n        fontsize=9,\n        fontweight=\"bold\",\n        color=INK,\n    )\n\n# Trend annotations to the right of Q4\nq1_neutral = node_values[\"Q1 2025\"][\"Neutral\"]\nq4_neutral = node_values[\"Q4 2025\"][\"Neutral\"]\nq1_strong = node_values[\"Q1 2025\"][\"Strongly Support\"] + node_values[\"Q1 2025\"][\"Strongly Oppose\"]\nq4_strong = node_values[\"Q4 2025\"][\"Strongly Support\"] + node_values[\"Q4 2025\"][\"Strongly Oppose\"]\n\nax.annotate(\n    f\"Neutral: {q1_neutral} → {q4_neutral}  (−{q1_neutral - q4_neutral})\",\n    xy=(\n        x_positions[-1] + node_width / 2 + 0.15,\n        node_bounds[(\"Q4 2025\", \"Neutral\")][\"y_start\"] + node_bounds[(\"Q4 2025\", \"Neutral\")][\"height\"] / 2,\n    ),\n    fontsize=7.5,\n    color=INK_MUTED,\n    va=\"center\",\n)\nax.annotate(\n    f\"Strong views: {q1_strong} → {q4_strong}  (+{q4_strong - q1_strong})\",\n    xy=(\n        x_positions[-1] + node_width / 2 + 0.15,\n        node_bounds[(\"Q4 2025\", \"Strongly Support\")][\"y_start\"]\n        + node_bounds[(\"Q4 2025\", \"Strongly Support\")][\"height\"] / 2,\n    ),\n    fontsize=7.5,\n    color=CAT_COLORS[\"Strongly Support\"],\n    va=\"center\",\n)\n\n# Legend\nlegend_elements = [\n    mpatches.Patch(facecolor=INK_SOFT, alpha=0.55, edgecolor=\"none\", label=\"Stable (same view)\"),\n    mpatches.Patch(facecolor=INK_SOFT, alpha=0.22, edgecolor=\"none\", label=\"Changed view\"),\n]\nfor cat in categories:\n    legend_elements.append(mpatches.Patch(facecolor=CAT_COLORS[cat], label=cat))\n\nleg = ax.legend(\n    handles=legend_elements,\n    loc=\"lower left\",\n    bbox_to_anchor=(0.0, -0.02),\n    fontsize=7,\n    framealpha=0.92,\n    edgecolor=\"none\",\n    ncol=4,\n)\nif leg:\n    leg.get_frame().set_facecolor(ELEVATED_BG)\n    plt.setp(leg.get_texts(), color=INK_SOFT)\n\n# Title and subtitle\ntitle = \"alluvial-opinion-flow · python · matplotlib · anyplot.ai\"\ntitle_n = len(title)\ntitle_fontsize = max(8, round(12 * 67 / title_n)) if title_n > 67 else 12\n\nax.set_xlim(0.5, 14.0)\nax.set_ylim(-1.2, total_height + 2.4)\nax.set_title(title, fontsize=title_fontsize, fontweight=\"medium\", pad=10, color=INK)\nax.text(\n    0.5,\n    1.012,\n    \"Neutral stance shrinks as voter opinion on climate policy polarizes toward stronger positions\",\n    transform=ax.transAxes,\n    ha=\"center\",\n    va=\"bottom\",\n    fontsize=8,\n    fontstyle=\"italic\",\n    color=INK_MUTED,\n)\nax.axis(\"off\")\n\nfig.subplots_adjust(left=0.02, right=0.98, bottom=0.08, top=0.88)\nplt.savefig(f\"plot-{THEME}.png\", dpi=400, facecolor=PAGE_BG)\n"}