{"spec_id":"alluvial-opinion-flow","library":"plotnine","language":"python","code":"\"\"\" anyplot.ai\nalluvial-opinion-flow: Opinion Flow Diagram\nLibrary: plotnine 0.15.4 | Python 3.13.13\nQuality: 89/100 | Updated: 2026-05-30\n\"\"\"\n\nimport os\nimport sys\n\n\nsys.path = [p for p in sys.path if \"implementations\" not in p]\n\nimport numpy as np\nimport pandas as pd\nfrom plotnine import (\n    aes,\n    annotate,\n    coord_cartesian,\n    element_blank,\n    element_rect,\n    element_text,\n    geom_label,\n    geom_rect,\n    geom_ribbon,\n    geom_text,\n    ggplot,\n    guide_legend,\n    guides,\n    labs,\n    scale_alpha_identity,\n    scale_color_manual,\n    scale_fill_manual,\n    theme,\n    theme_minimal,\n)\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\"\n\n# Imprint palette — semantic mapping for opinion scale (positive → neutral → negative)\ncategories = [\"Strongly Agree\", \"Agree\", \"Neutral\", \"Disagree\", \"Strongly Disagree\"]\ncat_colors = {\n    \"Strongly Agree\": \"#009E73\",  # brand green — positive\n    \"Agree\": \"#4467A3\",  # blue — somewhat positive\n    \"Neutral\": \"#6B6A63\",  # warm gray — neutral (Imprint muted, light value)\n    \"Disagree\": \"#BD8233\",  # ochre — somewhat negative\n    \"Strongly Disagree\": \"#AE3030\",  # matte red — negative\n}\ncat_order = {cat: i for i, cat in enumerate(categories)}\nwave_labels = [\"Wave 1\", \"Wave 2\", \"Wave 3\", \"Wave 4\"]\n\n# Data — opinion survey tracking 1000 respondents across 4 waves\n# Gradual shift: moderate positions erode toward extremes\nm12 = np.array([[154, 18, 5, 2, 1], [22, 195, 28, 5, 0], [3, 20, 138, 22, 7], [0, 5, 18, 155, 22], [1, 3, 5, 15, 156]])\nm23 = np.array([[153, 15, 8, 2, 2], [30, 175, 25, 8, 3], [5, 18, 128, 30, 13], [0, 5, 12, 150, 32], [2, 2, 5, 10, 167]])\nm34 = np.array([[166, 15, 5, 2, 2], [35, 148, 22, 8, 2], [3, 12, 98, 42, 23], [0, 3, 8, 142, 47], [2, 2, 3, 12, 198]])\n\nrows = []\nfor matrix, (fw, tw) in zip([m12, m23, m34], [(0, 1), (1, 2), (2, 3)], strict=True):\n    for i, from_cat in enumerate(categories):\n        for j, to_cat in enumerate(categories):\n            count = int(matrix[i, j])\n            if count > 0:\n                rows.append(\n                    {\n                        \"from_wave\": fw,\n                        \"to_wave\": tw,\n                        \"from_cat\": from_cat,\n                        \"to_cat\": to_cat,\n                        \"count\": count,\n                        \"is_stable\": i == j,\n                    }\n                )\n\ntransitions = pd.DataFrame(rows)\ntransitions[\"from_ord\"] = transitions[\"from_cat\"].map(cat_order)\ntransitions[\"to_ord\"] = transitions[\"to_cat\"].map(cat_order)\ntransitions = transitions.sort_values(\n    [\"from_wave\", \"from_ord\", \"is_stable\", \"to_ord\"], ascending=[True, True, False, True]\n).reset_index(drop=True)\n\n# Layout parameters\nx_positions = {0: 0.14, 1: 0.38, 2: 0.62, 3: 0.86}\nnode_width = 0.055\nnode_gap = 0.018\ntotal_height = 0.78\ny_start = 0.88\n\n# Node positions\nnode_positions = {}\nfor w in range(4):\n    if w == 0:\n        totals = transitions[transitions[\"from_wave\"] == 0].groupby(\"from_cat\")[\"count\"].sum()\n    else:\n        totals = transitions[transitions[\"to_wave\"] == w].groupby(\"to_cat\")[\"count\"].sum()\n    total_n = totals.sum()\n    current_y = y_start\n    for cat in categories:\n        n = totals.get(cat, 0)\n        height = (n / total_n) * total_height\n        node_positions[(w, cat)] = {\n            \"x\": x_positions[w],\n            \"y_top\": current_y,\n            \"y_bottom\": current_y - height,\n            \"height\": height,\n            \"count\": int(n),\n            \"offset_out\": 0.0,\n            \"offset_in\": 0.0,\n        }\n        current_y -= height + node_gap\n\n# Node rectangles\nnode_data = []\nfor (w, cat), pos in node_positions.items():\n    node_data.append(\n        {\n            \"wave\": w,\n            \"category\": cat,\n            \"xmin\": pos[\"x\"] - node_width / 2,\n            \"xmax\": pos[\"x\"] + node_width / 2,\n            \"ymin\": pos[\"y_bottom\"],\n            \"ymax\": pos[\"y_top\"],\n            \"label_x\": pos[\"x\"],\n            \"label_y\": (pos[\"y_top\"] + pos[\"y_bottom\"]) / 2,\n            \"count\": pos[\"count\"],\n        }\n    )\nnodes_df = pd.DataFrame(node_data)\n\n# Net flows between categories per wave pair for highlighting\nnet_flows = {}\nfor _, row in transitions[~transitions[\"is_stable\"]].iterrows():\n    fw, tw = row[\"from_wave\"], row[\"to_wave\"]\n    fc, tc = row[\"from_cat\"], row[\"to_cat\"]\n    key = (fw, tw, min(fc, tc), max(fc, tc))\n    direction = 1 if fc < tc else -1\n    net_flows[key] = net_flows.get(key, 0) + direction * row[\"count\"]\n\n# Flow ribbons — min_flow=8 reduces visual density in the middle region\nflow_polys = []\nmin_flow = 8\n\nfor _, row in transitions.iterrows():\n    fw, tw = row[\"from_wave\"], row[\"to_wave\"]\n    fc, tc = row[\"from_cat\"], row[\"to_cat\"]\n    count = row[\"count\"]\n    is_stable = row[\"is_stable\"]\n\n    src = node_positions[(fw, fc)]\n    tgt = node_positions[(tw, tc)]\n\n    src_total = transitions[(transitions[\"from_wave\"] == fw) & (transitions[\"from_cat\"] == fc)][\"count\"].sum()\n    fh_src = (count / src_total) * src[\"height\"] if src_total > 0 else 0\n    tgt_total = transitions[(transitions[\"to_wave\"] == tw) & (transitions[\"to_cat\"] == tc)][\"count\"].sum()\n    fh_tgt = (count / tgt_total) * tgt[\"height\"] if tgt_total > 0 else 0\n\n    if count < min_flow:\n        src[\"offset_out\"] += fh_src\n        tgt[\"offset_in\"] += fh_tgt\n        continue\n\n    src_y_top = src[\"y_top\"] - src[\"offset_out\"]\n    src_y_bottom = src_y_top - fh_src\n    src[\"offset_out\"] += fh_src\n\n    tgt_y_top = tgt[\"y_top\"] - tgt[\"offset_in\"]\n    tgt_y_bottom = tgt_y_top - fh_tgt\n    tgt[\"offset_in\"] += fh_tgt\n\n    if is_stable:\n        alpha = 0.55\n    else:\n        key = (fw, tw, min(fc, tc), max(fc, tc))\n        net_mag = abs(net_flows.get(key, 0))\n        is_dominant = (fc < tc and net_flows.get(key, 0) > 0) or (fc > tc and net_flows.get(key, 0) < 0)\n        alpha = 0.40 if (is_dominant and net_mag > 10) else 0.22\n\n    x_left = x_positions[fw] + node_width / 2\n    x_right = x_positions[tw] - node_width / 2\n    n_pts = 40\n    t_param = np.linspace(0, 1, n_pts)\n    x_vals = x_left + (x_right - x_left) * t_param\n    y_top_curve = src_y_top + (tgt_y_top - src_y_top) * (3 * t_param**2 - 2 * t_param**3)\n    y_bot_curve = src_y_bottom + (tgt_y_bottom - src_y_bottom) * (3 * t_param**2 - 2 * t_param**3)\n\n    flow_id = f\"{fw}_{tw}_{fc}_{tc}\"\n    for k in range(n_pts):\n        flow_polys.append(\n            {\n                \"x\": x_vals[k],\n                \"ymin\": y_bot_curve[k],\n                \"ymax\": y_top_curve[k],\n                \"flow_id\": flow_id,\n                \"from_cat\": fc,\n                \"alpha\": alpha,\n            }\n        )\n\nflows_df = pd.DataFrame(flow_polys)\n\n# Delta labels for significant wave-over-wave category size changes\nwave_changes = []\nfor w in range(3):\n    for cat in categories:\n        n_from = node_positions[(w, cat)][\"count\"]\n        n_to = node_positions[(w + 1, cat)][\"count\"]\n        delta = n_to - n_from\n        if abs(delta) >= 15:\n            mid_x = (x_positions[w] + x_positions[w + 1]) / 2\n            tgt_mid = (node_positions[(w + 1, cat)][\"y_top\"] + node_positions[(w + 1, cat)][\"y_bottom\"]) / 2\n            wave_changes.append(\n                {\n                    \"x\": mid_x,\n                    \"y\": tgt_mid + 0.015,\n                    \"category\": cat,\n                    \"delta\": delta,\n                    \"label\": f\"{'+' if delta > 0 else ''}{delta}\",\n                }\n            )\nchanges_df = pd.DataFrame(wave_changes)\n\n# Background column bands — subtle INK overlay for visual framing\nband_data = [\n    {\"xmin\": x_positions[w] - 0.09, \"xmax\": x_positions[w] + 0.09, \"ymin\": 0.0, \"ymax\": 0.935} for w in range(4)\n]\nbands_df = pd.DataFrame(band_data)\n\n# Plot\ntitle = \"alluvial-opinion-flow · python · plotnine · anyplot.ai\"\nsubtitle = \"Tracking 1,000 respondents across 4 waves — Neutral erodes as views shift toward extremes\"\n\nplot = (\n    ggplot()\n    + geom_rect(\n        bands_df,\n        aes(xmin=\"xmin\", xmax=\"xmax\", ymin=\"ymin\", ymax=\"ymax\"),\n        fill=INK,\n        alpha=0.07,\n        color=None,\n        inherit_aes=False,\n        show_legend=False,\n    )\n    + geom_ribbon(\n        flows_df, aes(x=\"x\", ymin=\"ymin\", ymax=\"ymax\", group=\"flow_id\", fill=\"from_cat\", alpha=\"alpha\"), color=None\n    )\n    + scale_alpha_identity()\n    + geom_rect(\n        nodes_df, aes(xmin=\"xmin\", xmax=\"xmax\", ymin=\"ymin\", ymax=\"ymax\", fill=\"category\"), color=\"white\", size=0.6\n    )\n    + geom_text(\n        nodes_df,\n        aes(x=\"label_x\", y=\"label_y\", label=\"count\"),\n        ha=\"center\",\n        va=\"center\",\n        size=3.0,\n        color=\"white\",\n        fontweight=\"bold\",\n    )\n    + geom_label(\n        changes_df,\n        aes(x=\"x\", y=\"y\", label=\"label\", color=\"category\"),\n        size=3.3,\n        fontweight=\"bold\",\n        va=\"center\",\n        ha=\"center\",\n        show_legend=False,\n        fill=ELEVATED_BG,\n        label_size=0,\n        label_padding=0.12,\n    )\n    + scale_fill_manual(values=cat_colors, name=\"Opinion\", breaks=categories)\n    + scale_color_manual(values=cat_colors)\n    + guides(fill=guide_legend(override_aes={\"alpha\": 1}), color=None)\n    + labs(title=title, subtitle=subtitle, x=\"\", y=\"\")\n    + coord_cartesian(xlim=(-0.14, 1.08), ylim=(0.0, 0.98))\n    + theme_minimal()\n    + theme(\n        figure_size=(8, 4.5),\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_background=element_rect(fill=PAGE_BG),\n        panel_grid=element_blank(),\n        panel_border=element_blank(),\n        axis_text=element_blank(),\n        axis_ticks=element_blank(),\n        axis_title=element_blank(),\n        plot_title=element_text(size=12, ha=\"center\", weight=\"bold\", color=INK),\n        plot_subtitle=element_text(size=8, ha=\"center\", color=INK_SOFT, margin={\"b\": 8}),\n        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),\n        legend_text=element_text(size=8, color=INK_SOFT),\n        legend_title=element_text(size=9, weight=\"bold\", color=INK),\n        legend_position=\"right\",\n        plot_margin=0.05,\n    )\n)\n\n# Wave column headers\nfor w, label in enumerate(wave_labels):\n    plot = plot + annotate(\n        \"text\", x=x_positions[w], y=0.95, label=label, size=4.0, color=INK, fontweight=\"bold\", ha=\"center\"\n    )\n\n# Category labels left of wave 1\nfor cat in categories:\n    pos = node_positions[(0, cat)]\n    ly = (pos[\"y_top\"] + pos[\"y_bottom\"]) / 2\n    plot = plot + annotate(\n        \"text\",\n        x=x_positions[0] - node_width / 2 - 0.015,\n        y=ly,\n        label=cat,\n        size=3.2,\n        color=INK,\n        fontweight=\"bold\",\n        ha=\"right\",\n        va=\"center\",\n    )\n\n# Category labels right of wave 4\nfor cat in categories:\n    pos = node_positions[(3, cat)]\n    ly = (pos[\"y_top\"] + pos[\"y_bottom\"]) / 2\n    plot = plot + annotate(\n        \"text\",\n        x=x_positions[3] + node_width / 2 + 0.015,\n        y=ly,\n        label=cat,\n        size=3.2,\n        color=INK,\n        fontweight=\"bold\",\n        ha=\"left\",\n        va=\"center\",\n    )\n\n# Save\nplot.save(f\"plot-{THEME}.png\", dpi=400, width=8, height=4.5, units=\"in\", verbose=False)\n"}