{"spec_id":"alluvial-basic","library":"letsplot","language":"python","code":"\"\"\" anyplot.ai\nalluvial-basic: Basic Alluvial Diagram\nLibrary: letsplot 4.9.0 | Python 3.13.13\nQuality: 89/100 | Updated: 2026-05-09\n\"\"\"\n\nimport os\n\nimport pandas as pd\nfrom lets_plot import (\n    LetsPlot,\n    aes,\n    element_blank,\n    element_rect,\n    element_text,\n    geom_polygon,\n    geom_rect,\n    geom_text,\n    ggplot,\n    ggsize,\n    labs,\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-adaptive colors\nTHEME = os.getenv(\"ANYPLOT_THEME\", \"light\")\nPAGE_BG = \"#FAF8F1\" if THEME == \"light\" else \"#1A1A17\"\nINK = \"#1A1A17\" if THEME == \"light\" else \"#F0EFE8\"\nINK_SOFT = \"#4A4A44\" if THEME == \"light\" else \"#B8B7B0\"\n\n# Okabe-Ito palette (positions 1-4)\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\"]\n\n# Voter migration data across three election cycles\nelections = [\"2016\", \"2020\", \"2024\"]\nparties = [\"Democrats\", \"Republicans\", \"Independents\", \"Non-Voters\"]\n\n# Initial distribution in 2016 (millions of voters)\ninitial_2016 = {\"Democrats\": 65, \"Republicans\": 63, \"Independents\": 8, \"Non-Voters\": 95}\n\n# Flows from 2016 to 2020 (from_party -> to_party: millions)\nflows_2016_2020 = [\n    (\"Democrats\", \"Democrats\", 58),\n    (\"Democrats\", \"Republicans\", 3),\n    (\"Democrats\", \"Independents\", 2),\n    (\"Democrats\", \"Non-Voters\", 2),\n    (\"Republicans\", \"Democrats\", 5),\n    (\"Republicans\", \"Republicans\", 54),\n    (\"Republicans\", \"Independents\", 2),\n    (\"Republicans\", \"Non-Voters\", 2),\n    (\"Independents\", \"Democrats\", 3),\n    (\"Independents\", \"Republicans\", 2),\n    (\"Independents\", \"Independents\", 2),\n    (\"Independents\", \"Non-Voters\", 1),\n    (\"Non-Voters\", \"Democrats\", 15),\n    (\"Non-Voters\", \"Republicans\", 12),\n    (\"Non-Voters\", \"Independents\", 4),\n    (\"Non-Voters\", \"Non-Voters\", 64),\n]\n\n# Flows from 2020 to 2024 (from_party -> to_party: millions)\nflows_2020_2024 = [\n    (\"Democrats\", \"Democrats\", 72),\n    (\"Democrats\", \"Republicans\", 4),\n    (\"Democrats\", \"Independents\", 3),\n    (\"Democrats\", \"Non-Voters\", 2),\n    (\"Republicans\", \"Democrats\", 3),\n    (\"Republicans\", \"Republicans\", 63),\n    (\"Republicans\", \"Independents\", 2),\n    (\"Republicans\", \"Non-Voters\", 3),\n    (\"Independents\", \"Democrats\", 4),\n    (\"Independents\", \"Republicans\", 3),\n    (\"Independents\", \"Independents\", 2),\n    (\"Independents\", \"Non-Voters\", 1),\n    (\"Non-Voters\", \"Democrats\", 6),\n    (\"Non-Voters\", \"Republicans\", 8),\n    (\"Non-Voters\", \"Independents\", 3),\n    (\"Non-Voters\", \"Non-Voters\", 52),\n]\n\n# Calculate totals at each time point\ntotals_2016 = initial_2016.copy()\ntotals_2020 = dict.fromkeys(parties, 0)\nfor _, to_party, val in flows_2016_2020:\n    totals_2020[to_party] += val\n\ntotals_2024 = dict.fromkeys(parties, 0)\nfor _, to_party, val in flows_2020_2024:\n    totals_2024[to_party] += val\n\ntime_totals = [totals_2016, totals_2020, totals_2024]\n\n# Layout parameters\nx_positions = [0.15, 0.5, 0.85]\nnode_width = 0.03\nnode_gap = 0.02\ntotal_flow = sum(totals_2016.values())\n\n# Colors for parties - Okabe-Ito palette (positions 1-4)\nparty_colors = {\n    \"Democrats\": IMPRINT[0],\n    \"Republicans\": IMPRINT[1],\n    \"Independents\": IMPRINT[2],\n    \"Non-Voters\": IMPRINT[3],\n}\n\n\n# Calculate node positions at each time point\ndef calculate_node_positions(totals, x_pos):\n    positions = {}\n    y_offset = 0.05\n    for party in parties:\n        height = totals.get(party, 0) / total_flow * 0.85\n        positions[party] = {\"y0\": y_offset, \"y1\": y_offset + height, \"x\": x_pos}\n        y_offset += height + node_gap\n    return positions\n\n\nnode_positions = [\n    calculate_node_positions(totals_2016, x_positions[0]),\n    calculate_node_positions(totals_2020, x_positions[1]),\n    calculate_node_positions(totals_2024, x_positions[2]),\n]\n\n# Build flow polygons between time points\nflow_data = []\n\n\ndef add_flows(flows, time_idx, src_positions, tgt_positions, x_left, x_right):\n    src_offsets = dict.fromkeys(parties, 0)\n    tgt_offsets = dict.fromkeys(parties, 0)\n\n    for from_party, to_party, val in flows:\n        flow_height = val / total_flow * 0.85\n\n        src_y0 = src_positions[from_party][\"y0\"] + src_offsets[from_party]\n        src_y1 = src_y0 + flow_height\n        src_offsets[from_party] += flow_height\n\n        tgt_y0 = tgt_positions[to_party][\"y0\"] + tgt_offsets[to_party]\n        tgt_y1 = tgt_y0 + flow_height\n        tgt_offsets[to_party] += flow_height\n\n        n_points = 40\n        x_vals_top = []\n        y_vals_top = []\n        x_vals_bottom = []\n        y_vals_bottom = []\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            y_top = src_y1 + ease * (tgt_y1 - src_y1)\n            y_bottom = src_y0 + ease * (tgt_y0 - src_y0)\n\n            x_vals_top.append(x)\n            y_vals_top.append(y_top)\n            x_vals_bottom.append(x)\n            y_vals_bottom.append(y_bottom)\n\n        x_polygon = x_vals_top + x_vals_bottom[::-1]\n        y_polygon = y_vals_top + y_vals_bottom[::-1]\n\n        # Use destination party for coloring to show where voters went\n        flow_id = f\"t{time_idx}_{from_party}_{to_party}\"\n        flow_color_key = f\"{from_party}_{to_party}\"\n        for x, y in zip(x_polygon, y_polygon, strict=False):\n            flow_data.append(\n                {\n                    \"x\": x,\n                    \"y\": y,\n                    \"flow_id\": flow_id,\n                    \"flow_color\": flow_color_key,\n                    \"from_party\": from_party,\n                    \"to_party\": to_party,\n                }\n            )\n\n\n# Add flows for 2016->2020\nadd_flows(\n    flows_2016_2020,\n    0,\n    node_positions[0],\n    node_positions[1],\n    x_positions[0] + node_width / 2,\n    x_positions[1] - node_width / 2,\n)\n\n# Add flows for 2020->2024\nadd_flows(\n    flows_2020_2024,\n    1,\n    node_positions[1],\n    node_positions[2],\n    x_positions[1] + node_width / 2,\n    x_positions[2] - node_width / 2,\n)\n\ndf_flows = pd.DataFrame(flow_data)\n\n# Build node rectangles\nnode_rects = []\nfor time_idx, positions in enumerate(node_positions):\n    for party in parties:\n        pos = positions[party]\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                \"party\": party,\n                \"time_idx\": time_idx,\n            }\n        )\n\ndf_nodes = pd.DataFrame(node_rects)\n\n# Build labels\nlabels = []\n\n# Time point labels (column headers)\nfor i, election in enumerate(elections):\n    labels.append({\"x\": x_positions[i], \"y\": 0.96, \"label\": election, \"type\": \"header\", \"hjust\": 0.5})\n\n# Party labels at first column (left side)\nfor party in parties:\n    pos = node_positions[0][party]\n    labels.append(\n        {\n            \"x\": x_positions[0] - node_width - 0.02,\n            \"y\": (pos[\"y0\"] + pos[\"y1\"]) / 2,\n            \"label\": party,\n            \"type\": \"party_left\",\n            \"hjust\": 1,\n        }\n    )\n\n# Value labels at last column (right side)\nfor party in parties:\n    pos = node_positions[2][party]\n    val = totals_2024[party]\n    labels.append(\n        {\n            \"x\": x_positions[2] + node_width + 0.02,\n            \"y\": (pos[\"y0\"] + pos[\"y1\"]) / 2,\n            \"label\": f\"{party}\\n({val}M)\",\n            \"type\": \"party_right\",\n            \"hjust\": 0,\n        }\n    )\n\ndf_labels = pd.DataFrame(labels)\n\n# Create the plot - flows colored by destination to show where voters went\nplot = (\n    ggplot()\n    + geom_polygon(\n        aes(x=\"x\", y=\"y\", group=\"flow_id\", fill=\"to_party\"), data=df_flows, alpha=0.55, color=\"white\", size=0.1\n    )\n    + geom_rect(aes(xmin=\"xmin\", xmax=\"xmax\", ymin=\"ymin\", ymax=\"ymax\", fill=\"party\"), data=df_nodes, color=INK, size=1)\n    + geom_text(\n        aes(x=\"x\", y=\"y\", label=\"label\"),\n        data=df_labels[df_labels[\"type\"] == \"header\"],\n        size=18,\n        hjust=0.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\"] == \"party_left\"],\n        size=13,\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\"] == \"party_right\"],\n        size=12,\n        hjust=0,\n        color=INK_SOFT,\n    )\n    + scale_fill_manual(\n        values={\n            \"Democrats\": party_colors[\"Democrats\"],\n            \"Republicans\": party_colors[\"Republicans\"],\n            \"Independents\": party_colors[\"Independents\"],\n            \"Non-Voters\": party_colors[\"Non-Voters\"],\n        }\n    )\n    + labs(title=\"alluvial-basic · letsplot · pyplots.ai\")\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=28, face=\"bold\", color=INK),\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.05, 1.05])\n    + scale_y_continuous(limits=[-0.02, 1.02])\n    + ggsize(1600, 900)\n)\n\n# Save as PNG (scale 3x for 4800 × 2700 px)\nggsave(plot, f\"plot-{THEME}.png\", path=\".\", scale=3)\n\n# Save as HTML for interactivity\nggsave(plot, f\"plot-{THEME}.html\", path=\".\")\n"}