{"spec_id":"alluvial-opinion-flow","library":"seaborn","language":"python","code":"\"\"\" anyplot.ai\nalluvial-opinion-flow: Opinion Flow Diagram\nLibrary: seaborn 0.13.2 | Python 3.13.13\nQuality: 87/100 | Updated: 2026-05-30\n\"\"\"\n\nimport os\n\nimport matplotlib.patches as mpatches\nimport matplotlib.pyplot as plt\nimport numpy as np\nimport pandas as pd\nimport seaborn as sns\nfrom matplotlib.path import Path\n\n\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\nsns.set_theme(\n    style=\"ticks\",\n    rc={\n        \"figure.facecolor\": PAGE_BG,\n        \"axes.facecolor\": PAGE_BG,\n        \"axes.edgecolor\": INK_SOFT,\n        \"axes.labelcolor\": INK,\n        \"text.color\": INK,\n        \"xtick.color\": INK_SOFT,\n        \"ytick.color\": INK_SOFT,\n        \"grid.color\": INK,\n        \"grid.alpha\": 0.15,\n        \"legend.facecolor\": ELEVATED_BG,\n        \"legend.edgecolor\": INK_SOFT,\n    },\n)\n\n# Data: AI-assisted diagnostics policy survey, 1,000 respondents over 4 quarterly waves\n# Pattern: gradual drift from net-skeptical to net-supportive as clinical evidence grows\nwaves = [\"Q1 2025\", \"Q2 2025\", \"Q3 2025\", \"Q4 2025\"]\ncategories = [\"Strongly Support\", \"Support\", \"Neutral\", \"Oppose\", \"Strongly Oppose\"]\n\n# Imprint palette — semantic mapping: positive→green/cyan, neutral→muted, negative→ochre/red\ncategory_colors = {\n    \"Strongly Support\": \"#009E73\",\n    \"Support\": \"#2ABCCD\",\n    \"Neutral\": INK_MUTED,\n    \"Oppose\": \"#BD8233\",\n    \"Strongly Oppose\": \"#AE3030\",\n}\n\n# Desaturated variants for changing flows (seaborn-native desaturation — lower ratio = more muted)\nchanger_colors = {cat: sns.desaturate(col, 0.35) for cat, col in category_colors.items()}\n\n# Label colors — lighter red for Strongly Oppose on dark surface to improve contrast\nlabel_colors = dict(category_colors)\nif THEME == \"dark\":\n    label_colors[\"Strongly Oppose\"] = \"#E06060\"\n\n# Counts per category at each wave (row=category, col=wave; total=1000 per wave)\n# Gradual drift: skeptics convert as AI diagnostics prove effective across trials\ncounts = np.array(\n    [\n        [80, 100, 130, 170],  # Strongly Support\n        [150, 200, 250, 310],  # Support\n        [300, 270, 240, 200],  # Neutral\n        [300, 270, 240, 200],  # Oppose\n        [170, 160, 140, 120],  # Strongly Oppose\n    ]\n)\n\n# Flow transitions (row sums = source wave counts, col sums = target wave counts)\nflows = [\n    {  # Q1 → Q2\n        (\"Strongly Support\", \"Strongly Support\"): 65,\n        (\"Strongly Support\", \"Support\"): 10,\n        (\"Strongly Support\", \"Neutral\"): 3,\n        (\"Strongly Support\", \"Oppose\"): 1,\n        (\"Strongly Support\", \"Strongly Oppose\"): 1,\n        (\"Support\", \"Strongly Support\"): 22,\n        (\"Support\", \"Support\"): 115,\n        (\"Support\", \"Neutral\"): 8,\n        (\"Support\", \"Oppose\"): 3,\n        (\"Support\", \"Strongly Oppose\"): 2,\n        (\"Neutral\", \"Strongly Support\"): 7,\n        (\"Neutral\", \"Support\"): 55,\n        (\"Neutral\", \"Neutral\"): 215,\n        (\"Neutral\", \"Oppose\"): 18,\n        (\"Neutral\", \"Strongly Oppose\"): 5,\n        (\"Oppose\", \"Strongly Support\"): 4,\n        (\"Oppose\", \"Support\"): 12,\n        (\"Oppose\", \"Neutral\"): 25,\n        (\"Oppose\", \"Oppose\"): 230,\n        (\"Oppose\", \"Strongly Oppose\"): 29,\n        (\"Strongly Oppose\", \"Strongly Support\"): 2,\n        (\"Strongly Oppose\", \"Support\"): 8,\n        (\"Strongly Oppose\", \"Neutral\"): 19,\n        (\"Strongly Oppose\", \"Oppose\"): 18,\n        (\"Strongly Oppose\", \"Strongly Oppose\"): 123,\n    },\n    {  # Q2 → Q3\n        (\"Strongly Support\", \"Strongly Support\"): 85,\n        (\"Strongly Support\", \"Support\"): 12,\n        (\"Strongly Support\", \"Neutral\"): 2,\n        (\"Strongly Support\", \"Oppose\"): 1,\n        (\"Strongly Support\", \"Strongly Oppose\"): 0,\n        (\"Support\", \"Strongly Support\"): 30,\n        (\"Support\", \"Support\"): 150,\n        (\"Support\", \"Neutral\"): 12,\n        (\"Support\", \"Oppose\"): 5,\n        (\"Support\", \"Strongly Oppose\"): 3,\n        (\"Neutral\", \"Strongly Support\"): 8,\n        (\"Neutral\", \"Support\"): 65,\n        (\"Neutral\", \"Neutral\"): 175,\n        (\"Neutral\", \"Oppose\"): 17,\n        (\"Neutral\", \"Strongly Oppose\"): 5,\n        (\"Oppose\", \"Strongly Support\"): 5,\n        (\"Oppose\", \"Support\"): 15,\n        (\"Oppose\", \"Neutral\"): 30,\n        (\"Oppose\", \"Oppose\"): 210,\n        (\"Oppose\", \"Strongly Oppose\"): 10,\n        (\"Strongly Oppose\", \"Strongly Support\"): 2,\n        (\"Strongly Oppose\", \"Support\"): 8,\n        (\"Strongly Oppose\", \"Neutral\"): 21,\n        (\"Strongly Oppose\", \"Oppose\"): 7,\n        (\"Strongly Oppose\", \"Strongly Oppose\"): 122,\n    },\n    {  # Q3 → Q4\n        (\"Strongly Support\", \"Strongly Support\"): 120,\n        (\"Strongly Support\", \"Support\"): 8,\n        (\"Strongly Support\", \"Neutral\"): 1,\n        (\"Strongly Support\", \"Oppose\"): 1,\n        (\"Strongly Support\", \"Strongly Oppose\"): 0,\n        (\"Support\", \"Strongly Support\"): 40,\n        (\"Support\", \"Support\"): 190,\n        (\"Support\", \"Neutral\"): 13,\n        (\"Support\", \"Oppose\"): 5,\n        (\"Support\", \"Strongly Oppose\"): 2,\n        (\"Neutral\", \"Strongly Support\"): 8,\n        (\"Neutral\", \"Support\"): 85,\n        (\"Neutral\", \"Neutral\"): 130,\n        (\"Neutral\", \"Oppose\"): 14,\n        (\"Neutral\", \"Strongly Oppose\"): 3,\n        (\"Oppose\", \"Strongly Support\"): 1,\n        (\"Oppose\", \"Support\"): 20,\n        (\"Oppose\", \"Neutral\"): 35,\n        (\"Oppose\", \"Oppose\"): 175,\n        (\"Oppose\", \"Strongly Oppose\"): 9,\n        (\"Strongly Oppose\", \"Strongly Support\"): 1,\n        (\"Strongly Oppose\", \"Support\"): 7,\n        (\"Strongly Oppose\", \"Neutral\"): 21,\n        (\"Strongly Oppose\", \"Oppose\"): 5,\n        (\"Strongly Oppose\", \"Strongly Oppose\"): 106,\n    },\n]\n\n# Figure: main alluvial panel + net change sidebar\nfig = plt.figure(figsize=(8, 4.5), dpi=400)\nfig.patch.set_facecolor(PAGE_BG)\ngs = fig.add_gridspec(1, 2, width_ratios=[5, 1])\nax = fig.add_subplot(gs[0, 0])\nax_net = fig.add_subplot(gs[0, 1])\nax.set_facecolor(PAGE_BG)\nax_net.set_facecolor(PAGE_BG)\n\n# Main alluvial diagram\nn_waves = len(waves)\nx_positions = np.linspace(0, 10, n_waves)\nbar_width = 0.55\ntotal_height = 100\nnode_positions = {}\n\nfor wave_idx, wave in enumerate(waves):\n    x = x_positions[wave_idx]\n    wave_total = counts[:, wave_idx].sum()\n    y_bottom = 0\n\n    for cat_idx, category in enumerate(categories):\n        height = (counts[cat_idx, wave_idx] / wave_total) * total_height\n        y_top = y_bottom + height\n        node_positions[(wave_idx, category)] = (y_bottom, y_top)\n\n        ax.add_patch(\n            mpatches.Rectangle(\n                (x - bar_width / 2, y_bottom),\n                bar_width,\n                height,\n                facecolor=category_colors[category],\n                edgecolor=PAGE_BG,\n                linewidth=1.0,\n            )\n        )\n\n        count_val = counts[cat_idx, wave_idx]\n        if wave_idx == 0:\n            # Single-line for short nodes (≤ 8% of total) to prevent crowding\n            label_text = f\"{category} (n={count_val})\" if height <= 8 else f\"{category}\\n(n={count_val})\"\n            ax.text(\n                x - bar_width / 2 - 0.15,\n                (y_bottom + y_top) / 2,\n                label_text,\n                ha=\"right\",\n                va=\"center\",\n                fontsize=6.5,\n                fontweight=\"bold\",\n                color=label_colors[category],\n            )\n        elif wave_idx == n_waves - 1:\n            ax.text(\n                x + bar_width / 2 + 0.15,\n                (y_bottom + y_top) / 2,\n                f\"n={count_val}\",\n                ha=\"left\",\n                va=\"center\",\n                fontsize=6.5,\n                fontweight=\"bold\",\n                color=label_colors[category],\n            )\n        elif height > 9:\n            ax.text(\n                x,\n                (y_bottom + y_top) / 2,\n                f\"n={count_val}\",\n                ha=\"center\",\n                va=\"center\",\n                fontsize=6.5,\n                fontweight=\"bold\",\n                color=PAGE_BG,\n            )\n        y_bottom = y_top\n\n    ax.text(x, total_height + 3, wave, ha=\"center\", va=\"bottom\", fontsize=8, fontweight=\"bold\", color=INK)\n\n# Draw Bezier flow bands — changers first (low z), stable flows on top (high z)\nfor flow_idx, flow_dict in enumerate(flows):\n    x0 = x_positions[flow_idx]\n    x1 = x_positions[flow_idx + 1]\n    wave0_total = counts[:, flow_idx].sum()\n    wave1_total = counts[:, flow_idx + 1].sum()\n\n    sorted_flows = sorted(flow_dict.items(), key=lambda item: item[0][0] == item[0][1])\n    source_offsets = {cat: node_positions[(flow_idx, cat)][0] for cat in categories}\n    target_offsets = {cat: node_positions[(flow_idx + 1, cat)][0] for cat in categories}\n\n    for (source_cat, target_cat), flow_value in sorted_flows:\n        if flow_value <= 0:\n            continue\n\n        source_height = (flow_value / wave0_total) * total_height\n        target_height = (flow_value / wave1_total) * total_height\n\n        y0_bot = source_offsets[source_cat]\n        y0_top = y0_bot + source_height\n        y1_bot = target_offsets[target_cat]\n        y1_top = y1_bot + target_height\n\n        band_x0 = x0 + bar_width / 2\n        band_x1 = x1 - bar_width / 2\n        cx0 = band_x0 + 0.4 * (band_x1 - band_x0)\n        cx1 = band_x0 + 0.6 * (band_x1 - band_x0)\n\n        verts = [\n            (band_x0, y0_bot),\n            (cx0, y0_bot),\n            (cx1, y1_bot),\n            (band_x1, y1_bot),\n            (band_x1, y1_top),\n            (cx1, y1_top),\n            (cx0, y0_top),\n            (band_x0, y0_top),\n            (band_x0, y0_bot),\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        is_stable = source_cat == target_cat\n        alpha = 0.58 if is_stable else 0.25\n        color = category_colors[source_cat] if is_stable else changer_colors[source_cat]\n        ax.add_patch(\n            mpatches.PathPatch(Path(verts, codes), facecolor=color, edgecolor=color, linewidth=0.3, alpha=alpha)\n        )\n\n        source_offsets[source_cat] = y0_top\n        target_offsets[target_cat] = y1_top\n\nax.set_xlim(-5.0, 12.5)\nax.set_ylim(-8, 115)\nax.set_aspect(\"auto\")\nax.set_xticks([])\nax.set_yticks([])\nsns.despine(ax=ax, left=True, bottom=True, top=True, right=True)\n\n# Net change sidebar — seaborn barplot colored by category\nnet_changes = counts[:, -1] - counts[:, 0]\ndf_net = pd.DataFrame({\"Category\": categories, \"Net Change\": net_changes.tolist()})\ncat_order = categories[::-1]\n\nsns.barplot(\n    data=df_net,\n    x=\"Net Change\",\n    y=\"Category\",\n    hue=\"Category\",\n    palette=category_colors,\n    legend=False,\n    order=cat_order,\n    ax=ax_net,\n)\n\nfor i, cat in enumerate(cat_order):\n    val = net_changes[categories.index(cat)]\n    sign = \"+\" if val > 0 else \"\"\n    offset = 3 if val >= 0 else -3\n    ha = \"left\" if val >= 0 else \"right\"\n    ax_net.text(\n        val + offset, i, f\"{sign}{val}\", ha=ha, va=\"center\", fontsize=7, fontweight=\"bold\", color=category_colors[cat]\n    )\n\nax_net.set_title(\"Net Shift\\nQ1→Q4\", fontsize=8, fontweight=\"bold\", pad=8, color=INK)\nax_net.set_ylabel(\"\")\nax_net.set_xlabel(\"\")\nax_net.tick_params(axis=\"y\", length=0)\nax_net.set_yticklabels([])\nax_net.tick_params(axis=\"x\", labelsize=7, colors=INK_SOFT)\nax_net.axvline(0, color=INK_SOFT, linewidth=0.8, zorder=0)\nax_net.set_xlim(-155, 210)\nax_net.xaxis.grid(True, alpha=0.15, linewidth=0.5, color=INK)\nsns.despine(ax=ax_net, left=True)\n\n# Title and footnote\ntitle = \"alluvial-opinion-flow · python · seaborn · anyplot.ai\"\nn_chars = len(title)\nratio = 67 / n_chars if n_chars > 67 else 1.0\ntitle_fontsize = max(round(12 * ratio), 8)\nfig.suptitle(title, fontsize=title_fontsize, fontweight=\"bold\", color=INK, y=0.97)\nfig.text(\n    0.42,\n    0.01,\n    \"AI-Assisted Diagnostics Survey · 1,000 respondents · Stable flows at higher opacity\",\n    ha=\"center\",\n    va=\"bottom\",\n    fontsize=6.5,\n    color=INK_MUTED,\n    style=\"italic\",\n)\n\nfig.subplots_adjust(left=0.12, right=0.98, top=0.88, bottom=0.06, wspace=0.08)\nplt.savefig(f\"plot-{THEME}.png\", dpi=400, facecolor=PAGE_BG)\n"}