{"spec_id":"alluvial-basic","library":"altair","language":"python","code":"\"\"\" anyplot.ai\nalluvial-basic: Basic Alluvial Diagram\nLibrary: altair 6.1.0 | Python 3.13.13\nQuality: 92/100 | Updated: 2026-05-09\n\"\"\"\n\nimport os\n\nimport altair as alt\nimport pandas as pd\n\n\n# Theme-adaptive colors\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# Okabe-Ito palette (first series must be #009E73)\nIMPRINT = [\"#009E73\", \"#C475FD\", \"#4467A3\", \"#BD8233\"]\n\n# Data: Voter migration between political parties across 4 election cycles\n# Time points: 2012, 2016, 2020, 2024\n# Categories: Conservative, Liberal, Progressive, Independent\n\ntime_points = [\"2012\", \"2016\", \"2020\", \"2024\"]\ncategories = [\"Conservative\", \"Liberal\", \"Progressive\", \"Independent\"]\n\n# Define flows between consecutive time points\n# Format: (source_time_idx, source_cat_idx, target_cat_idx, value)\nflows_data = [\n    # 2012 -> 2016 transitions\n    (0, 0, 0, 280),  # Conservative stays Conservative\n    (0, 0, 3, 20),  # Conservative to Independent\n    (0, 1, 1, 250),  # Liberal stays Liberal\n    (0, 1, 2, 30),  # Liberal to Progressive\n    (0, 1, 3, 15),  # Liberal to Independent\n    (0, 2, 2, 120),  # Progressive stays Progressive\n    (0, 2, 1, 25),  # Progressive to Liberal\n    (0, 3, 3, 80),  # Independent stays Independent\n    (0, 3, 0, 30),  # Independent to Conservative\n    (0, 3, 1, 20),  # Independent to Liberal\n    # 2016 -> 2020 transitions\n    (1, 0, 0, 260),  # Conservative stays Conservative\n    (1, 0, 3, 50),  # Conservative to Independent\n    (1, 1, 1, 240),  # Liberal stays Liberal\n    (1, 1, 2, 45),  # Liberal to Progressive\n    (1, 2, 2, 140),  # Progressive stays Progressive\n    (1, 2, 1, 35),  # Progressive to Liberal\n    (1, 3, 3, 90),  # Independent stays Independent\n    (1, 3, 0, 25),  # Independent to Conservative\n    (1, 3, 2, 15),  # Independent to Progressive\n    # 2020 -> 2024 transitions\n    (2, 0, 0, 250),  # Conservative stays Conservative\n    (2, 0, 3, 35),  # Conservative to Independent\n    (2, 1, 1, 255),  # Liberal stays Liberal\n    (2, 1, 2, 40),  # Liberal to Progressive\n    (2, 2, 2, 160),  # Progressive stays Progressive\n    (2, 2, 1, 40),  # Progressive to Liberal\n    (2, 3, 3, 100),  # Independent stays Independent\n    (2, 3, 0, 20),  # Independent to Conservative\n    (2, 3, 1, 10),  # Independent to Liberal\n]\n\n# Canvas dimensions: 1600x900 for 4800x2700 at scale_factor=3.0\nwidth = 1600\nheight = 900\nnode_width = 60\nnode_padding = 20\n\n# Colors for each category (using Okabe-Ito palette)\ncategory_colors = {\n    \"Conservative\": IMPRINT[0],  # #009E73 (brand green)\n    \"Liberal\": IMPRINT[1],  # #C475FD (vermillion)\n    \"Progressive\": IMPRINT[2],  # #4467A3 (blue)\n    \"Independent\": IMPRINT[3],  # #BD8233 (reddish purple)\n}\n\n# Calculate totals at each time point for each category\ntotals = {}\nfor t in range(len(time_points)):\n    totals[t] = dict.fromkeys(categories, 0)\n\n# Accumulate incoming flows for each node (except first column uses outgoing)\nfor src_t, src_cat_idx, tgt_cat_idx, val in flows_data:\n    tgt_t = src_t + 1\n    if src_t == 0:\n        totals[0][categories[src_cat_idx]] += val\n    totals[tgt_t][categories[tgt_cat_idx]] += val\n\n# Margins for layout\ntop_margin = 130\nbottom_margin = 50\nleft_margin = 100\nright_margin = 100\navailable_height = height - top_margin - bottom_margin\navailable_width = width - left_margin - right_margin\n\n# X positions for each time point (evenly spaced)\nx_positions = []\nfor t in range(len(time_points)):\n    x_positions.append(left_margin + t * (available_width / (len(time_points) - 1)))\n\n# Calculate node positions for each time point\nnode_positions = {}\nfor t in range(len(time_points)):\n    time_total = sum(totals[t].values())\n    if time_total == 0:\n        continue\n\n    # Calculate heights proportionally\n    total_height = available_height * 0.85\n    padding_total = node_padding * (len(categories) - 1)\n    usable_height = total_height - padding_total\n\n    current_y = top_margin + (available_height - total_height) / 2\n    node_positions[t] = {}\n\n    for cat in categories:\n        cat_total = totals[t][cat]\n        if cat_total > 0:\n            node_height = (cat_total / time_total) * usable_height\n            node_positions[t][cat] = {\n                \"y\": current_y,\n                \"height\": node_height,\n                \"x\": x_positions[t] - node_width / 2,\n                \"total\": cat_total,\n            }\n            current_y += node_height + node_padding\n        else:\n            node_positions[t][cat] = {\"y\": current_y, \"height\": 0, \"x\": x_positions[t], \"total\": 0}\n\n# Create node rectangles data\nnodes_data = []\nfor t in range(len(time_points)):\n    for cat in categories:\n        pos = node_positions[t][cat]\n        if pos[\"height\"] > 0:\n            nodes_data.append(\n                {\n                    \"name\": cat,\n                    \"time_point\": time_points[t],\n                    \"x\": pos[\"x\"],\n                    \"y\": pos[\"y\"],\n                    \"x2\": pos[\"x\"] + node_width,\n                    \"y2\": pos[\"y\"] + pos[\"height\"],\n                    \"color\": category_colors[cat],\n                    \"total\": pos[\"total\"],\n                    \"label_x\": pos[\"x\"] + node_width / 2,\n                    \"label_y\": pos[\"y\"] + pos[\"height\"] / 2,\n                }\n            )\n\nnodes_df = pd.DataFrame(nodes_data)\n\n# Track y offsets for stacking flows within nodes\nsource_offsets = {}\ntarget_offsets = {}\nfor t in range(len(time_points)):\n    source_offsets[t] = {cat: node_positions[t][cat][\"y\"] for cat in categories}\n    target_offsets[t] = {cat: node_positions[t][cat][\"y\"] for cat in categories}\n\n# Generate flow polygon data\nall_flow_data = []\nnum_curve_points = 30\n\nfor flow_idx, (src_t, src_cat_idx, tgt_cat_idx, val) in enumerate(flows_data):\n    tgt_t = src_t + 1\n    src_cat = categories[src_cat_idx]\n    tgt_cat = categories[tgt_cat_idx]\n\n    src_pos = node_positions[src_t][src_cat]\n    tgt_pos = node_positions[tgt_t][tgt_cat]\n\n    if src_pos[\"height\"] == 0 or tgt_pos[\"height\"] == 0:\n        continue\n\n    # Calculate flow heights proportional to value\n    src_height = (val / totals[src_t][src_cat]) * src_pos[\"height\"]\n    tgt_height = (val / totals[tgt_t][tgt_cat]) * tgt_pos[\"height\"]\n\n    # Get current offset positions\n    src_y_top = source_offsets[src_t][src_cat]\n    src_y_bottom = src_y_top + src_height\n    tgt_y_top = target_offsets[tgt_t][tgt_cat]\n    tgt_y_bottom = tgt_y_top + tgt_height\n\n    # Update offsets for next flow\n    source_offsets[src_t][src_cat] += src_height\n    target_offsets[tgt_t][tgt_cat] += tgt_height\n\n    # X coordinates for flow start and end\n    x_start = x_positions[src_t] + node_width / 2\n    x_end = x_positions[tgt_t] - node_width / 2\n\n    # Generate top curve points using smoothstep\n    top_points = []\n    for i in range(num_curve_points):\n        t_param = i / (num_curve_points - 1)\n        x = x_start + t_param * (x_end - x_start)\n        bezier_t = t_param * t_param * (3 - 2 * t_param)\n        y = src_y_top + bezier_t * (tgt_y_top - src_y_top)\n        top_points.append((x, y))\n\n    # Generate bottom curve points (reverse order for closed polygon)\n    bottom_points = []\n    for i in range(num_curve_points - 1, -1, -1):\n        t_param = i / (num_curve_points - 1)\n        x = x_start + t_param * (x_end - x_start)\n        bezier_t = t_param * t_param * (3 - 2 * t_param)\n        y = src_y_bottom + bezier_t * (tgt_y_bottom - src_y_bottom)\n        bottom_points.append((x, y))\n\n    # Combine into closed polygon\n    all_points = top_points + bottom_points\n    flow_id = f\"{time_points[src_t]}-{src_cat}-{tgt_cat}-{flow_idx}\"\n\n    for pt_idx, (x, y) in enumerate(all_points):\n        all_flow_data.append(\n            {\n                \"flow_id\": flow_id,\n                \"source_cat\": src_cat,\n                \"target_cat\": tgt_cat,\n                \"value\": val,\n                \"x\": x,\n                \"y\": y,\n                \"order\": pt_idx,\n            }\n        )\n\nflows_df = pd.DataFrame(all_flow_data)\n\n# Create flow polygons layer\nlinks_chart = (\n    alt.Chart(flows_df)\n    .mark_line(filled=True, opacity=0.5, strokeWidth=0)\n    .encode(\n        x=alt.X(\"x:Q\", scale=alt.Scale(domain=[0, width]), axis=None),\n        y=alt.Y(\"y:Q\", scale=alt.Scale(domain=[0, height]), axis=None),\n        color=alt.Color(\n            \"source_cat:N\",\n            scale=alt.Scale(domain=list(category_colors.keys()), range=list(category_colors.values())),\n            legend=alt.Legend(title=\"Party\", titleFontSize=22, labelFontSize=20, orient=\"right\"),\n        ),\n        detail=\"flow_id:N\",\n        order=\"order:Q\",\n    )\n)\n\n# Create node rectangles layer\nnodes_chart = (\n    alt.Chart(nodes_df)\n    .mark_rect(stroke=INK_SOFT, strokeWidth=2)\n    .encode(\n        x=alt.X(\"x:Q\", scale=alt.Scale(domain=[0, width])),\n        y=alt.Y(\"y:Q\", scale=alt.Scale(domain=[0, height])),\n        x2=\"x2:Q\",\n        y2=\"y2:Q\",\n        color=alt.Color(\"color:N\", scale=None),\n        tooltip=[\n            alt.Tooltip(\"name:N\", title=\"Party\"),\n            alt.Tooltip(\"time_point:N\", title=\"Year\"),\n            alt.Tooltip(\"total:Q\", title=\"Voters (thousands)\"),\n        ],\n    )\n)\n\n# Create node labels (full names, centered on nodes)\nnode_labels = (\n    alt.Chart(nodes_df)\n    .mark_text(fontSize=16, fontWeight=\"bold\", color=INK, baseline=\"middle\", align=\"center\")\n    .encode(\n        x=alt.X(\"label_x:Q\", scale=alt.Scale(domain=[0, width])),\n        y=alt.Y(\"label_y:Q\", scale=alt.Scale(domain=[0, height])),\n        text=\"name:N\",\n    )\n)\n\n# Create time point labels (column headers)\ntime_labels_data = []\nfor t, tp in enumerate(time_points):\n    time_labels_data.append({\"x\": x_positions[t], \"y\": top_margin - 40, \"text\": tp})\ntime_labels_df = pd.DataFrame(time_labels_data)\n\ntime_labels = (\n    alt.Chart(time_labels_df)\n    .mark_text(fontSize=24, fontWeight=\"bold\", color=INK, baseline=\"bottom\")\n    .encode(\n        x=alt.X(\"x:Q\", scale=alt.Scale(domain=[0, width])),\n        y=alt.Y(\"y:Q\", scale=alt.Scale(domain=[0, height])),\n        text=\"text:N\",\n    )\n)\n\n# Combine all layers with interactivity\nchart = (\n    alt.layer(links_chart, nodes_chart, node_labels, time_labels)\n    .properties(\n        width=width,\n        height=height,\n        background=PAGE_BG,\n        title=alt.Title(\n            text=\"alluvial-basic · altair · anyplot.ai\", fontSize=28, anchor=\"middle\", color=INK, offset=20\n        ),\n    )\n    .configure_view(fill=PAGE_BG, stroke=INK_SOFT, strokeWidth=0)\n    .configure_legend(\n        padding=15, cornerRadius=5, fillColor=ELEVATED_BG, strokeColor=INK_SOFT, labelColor=INK_SOFT, titleColor=INK\n    )\n    .configure_axis(\n        domainColor=INK_SOFT, tickColor=INK_SOFT, gridColor=INK, gridOpacity=0.10, labelColor=INK_SOFT, titleColor=INK\n    )\n    .interactive()\n)\n\n# Save as PNG and HTML\nchart.save(f\"plot-{THEME}.png\", scale_factor=3.0)\nchart.save(f\"plot-{THEME}.html\")\n"}