{"spec_id":"alluvial-opinion-flow","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\nalluvial-opinion-flow: Opinion Flow Diagram\nLibrary: letsplot 4.10.1 | Python 3.13.13\nQuality: 89/100 | Updated: 2026-05-30\n\"\"\"\n\nimport os\n\nimport numpy as np\nimport pandas as pd\nfrom lets_plot import (\n    LetsPlot,\n    aes,\n    element_blank,\n    element_rect,\n    element_text,\n    geom_rect,\n    geom_ribbon,\n    geom_text,\n    ggplot,\n    ggsize,\n    labs,\n    layer_tooltips,\n    scale_fill_manual,\n    scale_x_continuous,\n    scale_y_continuous,\n    theme,\n    theme_minimal,\n)\nfrom lets_plot.export import ggsave\n\n\nLetsPlot.setup_html()\n\n# Theme tokens — Imprint palette chrome, theme-adaptive\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 sentiment categories\n# positive → green, leaning positive → blue, neutral → muted, negative → red\ncategory_colors = {\n    \"Strongly Agree\": \"#009E73\",  # Imprint pos 1: brand green\n    \"Agree\": \"#4467A3\",  # Imprint pos 3: blue\n    \"Neutral\": \"#7A7A72\",  # fixed mid-gray, consistent across both themes\n    \"Disagree\": \"#BD8233\",  # Imprint pos 4: ochre\n    \"Strongly Disagree\": \"#AE3030\",  # Imprint pos 5: matte red\n}\n\n# Survey data: 1000 respondents tracked across 4 quarterly waves (climate policy)\nnp.random.seed(42)\n\nwaves = [\"Q1 2025\", \"Q2 2025\", \"Q3 2025\", \"Q4 2025\"]\ncategories = [\"Strongly Agree\", \"Agree\", \"Neutral\", \"Disagree\", \"Strongly Disagree\"]\n\ninitial_counts = {\"Strongly Agree\": 120, \"Agree\": 250, \"Neutral\": 280, \"Disagree\": 220, \"Strongly Disagree\": 130}\n\n# Flows Q1 → Q2\nflows_q1_q2 = [\n    (\"Strongly Agree\", \"Strongly Agree\", 110),\n    (\"Strongly Agree\", \"Agree\", 8),\n    (\"Strongly Agree\", \"Neutral\", 2),\n    (\"Agree\", \"Strongly Agree\", 25),\n    (\"Agree\", \"Agree\", 195),\n    (\"Agree\", \"Neutral\", 20),\n    (\"Agree\", \"Disagree\", 10),\n    (\"Neutral\", \"Strongly Agree\", 10),\n    (\"Neutral\", \"Agree\", 45),\n    (\"Neutral\", \"Neutral\", 170),\n    (\"Neutral\", \"Disagree\", 40),\n    (\"Neutral\", \"Strongly Disagree\", 15),\n    (\"Disagree\", \"Agree\", 8),\n    (\"Disagree\", \"Neutral\", 18),\n    (\"Disagree\", \"Disagree\", 170),\n    (\"Disagree\", \"Strongly Disagree\", 24),\n    (\"Strongly Disagree\", \"Neutral\", 5),\n    (\"Strongly Disagree\", \"Disagree\", 10),\n    (\"Strongly Disagree\", \"Strongly Disagree\", 115),\n]\n\n# Flows Q2 → Q3\nflows_q2_q3 = [\n    (\"Strongly Agree\", \"Strongly Agree\", 135),\n    (\"Strongly Agree\", \"Agree\", 10),\n    (\"Agree\", \"Strongly Agree\", 30),\n    (\"Agree\", \"Agree\", 205),\n    (\"Agree\", \"Neutral\", 15),\n    (\"Agree\", \"Disagree\", 6),\n    (\"Neutral\", \"Strongly Agree\", 8),\n    (\"Neutral\", \"Agree\", 35),\n    (\"Neutral\", \"Neutral\", 130),\n    (\"Neutral\", \"Disagree\", 32),\n    (\"Neutral\", \"Strongly Disagree\", 10),\n    (\"Disagree\", \"Agree\", 5),\n    (\"Disagree\", \"Neutral\", 12),\n    (\"Disagree\", \"Disagree\", 185),\n    (\"Disagree\", \"Strongly Disagree\", 28),\n    (\"Strongly Disagree\", \"Neutral\", 3),\n    (\"Strongly Disagree\", \"Disagree\", 8),\n    (\"Strongly Disagree\", \"Strongly Disagree\", 143),\n]\n\n# Flows Q3 → Q4 (balanced: each category outflow equals its Q3 total 173/255/160/231/181)\nflows_q3_q4 = [\n    (\"Strongly Agree\", \"Strongly Agree\", 165),\n    (\"Strongly Agree\", \"Agree\", 8),\n    (\"Agree\", \"Strongly Agree\", 35),\n    (\"Agree\", \"Agree\", 203),  # +5 vs original to balance Agree outflow (255 total)\n    (\"Agree\", \"Neutral\", 12),\n    (\"Agree\", \"Disagree\", 5),\n    (\"Neutral\", \"Strongly Agree\", 5),\n    (\"Neutral\", \"Agree\", 23),  # -5 vs original to balance Neutral outflow (160 total)\n    (\"Neutral\", \"Neutral\", 100),\n    (\"Neutral\", \"Disagree\", 25),\n    (\"Neutral\", \"Strongly Disagree\", 7),\n    (\"Disagree\", \"Agree\", 4),\n    (\"Disagree\", \"Neutral\", 8),\n    (\"Disagree\", \"Disagree\", 185),\n    (\"Disagree\", \"Strongly Disagree\", 34),  # +1 to balance Disagree outflow (231 total)\n    (\"Strongly Disagree\", \"Neutral\", 2),\n    (\"Strongly Disagree\", \"Disagree\", 5),\n    (\"Strongly Disagree\", \"Strongly Disagree\", 174),\n]\n\nall_flows = [flows_q1_q2, flows_q2_q3, flows_q3_q4]\n\n# Calculate wave totals\nwave_totals = [initial_counts.copy()]\nfor wave_flows in all_flows:\n    totals = dict.fromkeys(categories, 0)\n    for _, to_cat, val in wave_flows:\n        totals[to_cat] += val\n    wave_totals.append(totals)\n\n# Layout parameters\nx_positions = [0.10, 0.37, 0.63, 0.90]\nnode_width = 0.025\nnode_gap = 0.015\ntotal_respondents = sum(initial_counts.values())\n\n# Node positions per wave\nnode_positions = []\nfor wave_idx, totals in enumerate(wave_totals):\n    positions = {}\n    y_offset = 0.06\n    for cat in categories:\n        height = totals.get(cat, 0) / total_respondents * 0.82\n        positions[cat] = {\"y0\": y_offset, \"y1\": y_offset + height, \"x\": x_positions[wave_idx]}\n        y_offset += height + node_gap\n    node_positions.append(positions)\n\n# Flow ribbons via smooth hermite interpolation\nribbon_data = []\nn_points = 40\n\nfor wave_idx, wave_flows in enumerate(all_flows):\n    src_positions = node_positions[wave_idx]\n    tgt_positions = node_positions[wave_idx + 1]\n    x_left = x_positions[wave_idx] + node_width / 2\n    x_right = x_positions[wave_idx + 1] - node_width / 2\n    src_offsets = dict.fromkeys(categories, 0.0)\n    tgt_offsets = dict.fromkeys(categories, 0.0)\n\n    for from_cat, to_cat, val in wave_flows:\n        flow_height = val / total_respondents * 0.82\n        is_stable = from_cat == to_cat\n\n        src_y0 = src_positions[from_cat][\"y0\"] + src_offsets[from_cat]\n        src_y1 = src_y0 + flow_height\n        src_offsets[from_cat] += flow_height\n\n        tgt_y0 = tgt_positions[to_cat][\"y0\"] + tgt_offsets[to_cat]\n        tgt_y1 = tgt_y0 + flow_height\n        tgt_offsets[to_cat] += flow_height\n\n        flow_id = f\"w{wave_idx}_{from_cat}_{to_cat}\"\n        stability = \"stable\" if is_stable else \"changed\"\n\n        for i in range(n_points + 1):\n            t = i / n_points\n            x = x_left + t * (x_right - x_left)\n            ease = t * t * (3 - 2 * t)\n            ymin_val = src_y0 + ease * (tgt_y0 - src_y0)\n            ymax_val = src_y1 + ease * (tgt_y1 - src_y1)\n            ribbon_data.append(\n                {\n                    \"x\": x,\n                    \"ymin\": ymin_val,\n                    \"ymax\": ymax_val,\n                    \"flow_id\": flow_id,\n                    \"category\": from_cat,\n                    \"stability\": stability,\n                    \"from_cat\": from_cat,\n                    \"to_cat\": to_cat,\n                    \"count\": str(val),\n                }\n            )\n\ndf_ribbons = pd.DataFrame(ribbon_data)\ndf_stable = df_ribbons[df_ribbons[\"stability\"] == \"stable\"]\ndf_changed = df_ribbons[df_ribbons[\"stability\"] == \"changed\"]\n\n# Node rectangles\nnode_rects = []\nfor wave_idx, positions in enumerate(node_positions):\n    for cat in categories:\n        pos = positions[cat]\n        node_rects.append(\n            {\n                \"xmin\": pos[\"x\"] - node_width / 2,\n                \"xmax\": pos[\"x\"] + node_width / 2,\n                \"ymin\": pos[\"y0\"],\n                \"ymax\": pos[\"y1\"],\n                \"category\": cat,\n                \"wave\": waves[wave_idx],\n                \"count\": str(wave_totals[wave_idx][cat]),\n            }\n        )\n\ndf_nodes = pd.DataFrame(node_rects)\n\n# Labels\nlabel_rows = []\n\nfor i, wave in enumerate(waves):\n    label_rows.append({\"x\": x_positions[i], \"y\": 0.97, \"label\": wave, \"type\": \"header\"})\n\nfor cat in categories:\n    pos = node_positions[0][cat]\n    count = initial_counts[cat]\n    label_rows.append(\n        {\n            \"x\": x_positions[0] - node_width - 0.015,\n            \"y\": (pos[\"y0\"] + pos[\"y1\"]) / 2,\n            \"label\": f\"{cat}\\n({count})\",\n            \"type\": \"left_label\",\n        }\n    )\n\nfor cat in categories:\n    pos = node_positions[3][cat]\n    count = wave_totals[3][cat]\n    change = count - initial_counts[cat]\n    change_str = f\", +{change}\" if change > 0 else (f\", {change}\" if change < 0 else \"\")\n    label_rows.append(\n        {\n            \"x\": x_positions[3] + node_width + 0.015,\n            \"y\": (pos[\"y0\"] + pos[\"y1\"]) / 2,\n            \"label\": f\"{cat}\\n({count}{change_str})\",\n            \"type\": \"right_label\",\n        }\n    )\n\nfor wave_idx in [1, 2]:\n    for cat in categories:\n        pos = node_positions[wave_idx][cat]\n        count = wave_totals[wave_idx][cat]\n        height = pos[\"y1\"] - pos[\"y0\"]\n        if height > 0.03:\n            label_rows.append(\n                {\n                    \"x\": x_positions[wave_idx],\n                    \"y\": (pos[\"y0\"] + pos[\"y1\"]) / 2,\n                    \"label\": str(count),\n                    \"type\": \"node_count\",\n                }\n            )\n\ndf_labels = pd.DataFrame(label_rows)\n\n# Title with length-scaled fontsize (default 16px for scale-based libs)\ntitle = \"alluvial-opinion-flow · python · letsplot · anyplot.ai\"\nn = len(title)\nratio = 67 / n if n > 67 else 1.0\ntitle_size = max(11, round(16 * ratio))\n\n# Plot\nplot = (\n    ggplot()\n    + geom_ribbon(\n        aes(x=\"x\", ymin=\"ymin\", ymax=\"ymax\", group=\"flow_id\", fill=\"category\"),\n        data=df_changed,\n        alpha=0.45,\n        color=PAGE_BG,\n        size=0.05,\n        tooltips=layer_tooltips().line(\"@from_cat -> @to_cat\").line(\"@count respondents (changed)\"),\n    )\n    + geom_ribbon(\n        aes(x=\"x\", ymin=\"ymin\", ymax=\"ymax\", group=\"flow_id\", fill=\"category\"),\n        data=df_stable,\n        alpha=0.6,\n        color=PAGE_BG,\n        size=0.05,\n        tooltips=layer_tooltips().line(\"@from_cat (stable)\").line(\"@count respondents\"),\n    )\n    + geom_rect(\n        aes(xmin=\"xmin\", xmax=\"xmax\", ymin=\"ymin\", ymax=\"ymax\", fill=\"category\"),\n        data=df_nodes,\n        color=INK,\n        size=0.8,\n        tooltips=layer_tooltips().line(\"@category\").line(\"Wave: @wave\").line(\"Count: @count\"),\n    )\n    + geom_text(\n        aes(x=\"x\", y=\"y\", label=\"label\"),\n        data=df_labels[df_labels[\"type\"] == \"header\"],\n        size=5.5,\n        fontface=\"bold\",\n        color=INK,\n    )\n    + geom_text(\n        aes(x=\"x\", y=\"y\", label=\"label\"),\n        data=df_labels[df_labels[\"type\"] == \"left_label\"],\n        size=3.6,\n        hjust=1,\n        color=INK_SOFT,\n    )\n    + geom_text(\n        aes(x=\"x\", y=\"y\", label=\"label\"),\n        data=df_labels[df_labels[\"type\"] == \"right_label\"],\n        size=3.6,\n        hjust=0,\n        color=INK_SOFT,\n    )\n    + geom_text(\n        aes(x=\"x\", y=\"y\", label=\"label\"),\n        data=df_labels[df_labels[\"type\"] == \"node_count\"],\n        size=4.0,\n        color=\"white\",\n        fontface=\"bold\",\n    )\n    + scale_fill_manual(values=category_colors)\n    + labs(title=title, subtitle=\"Survey trend: neutral stance declines 280→122 as opinions polarize\")\n    + theme_minimal()\n    + theme(\n        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),\n        panel_background=element_rect(fill=PAGE_BG),\n        plot_title=element_text(size=title_size, face=\"bold\", color=INK),\n        plot_subtitle=element_text(size=12, color=INK_SOFT),\n        axis_title=element_blank(),\n        axis_text=element_blank(),\n        axis_ticks=element_blank(),\n        panel_grid=element_blank(),\n        legend_position=\"none\",\n    )\n    + scale_x_continuous(limits=[-0.08, 1.08])\n    + scale_y_continuous(limits=[-0.02, 1.04])\n    + ggsize(800, 450)\n)\n\n# Save\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=4)\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}