Basic Alluvial Diagram — Matplotlib

An alluvial diagram visualizes how entities flow or transition between discrete categories across multiple time points or ordered stages. Unlike general Sankey diagrams, alluvial diagrams enforce strict vertical ordering where each column represents a specific time step or category dimension. Bands connect related segments to show how proportions shift over time, making it ideal for tracking structural changes, migrations, and transitions in categorical data.

Basic Alluvial Diagram rendered with Matplotlib

Python source (Matplotlib)

""" anyplot.ai
alluvial-basic: Basic Alluvial Diagram
Library: matplotlib 3.10.9 | Python 3.13.13
Quality: 89/100 | Updated: 2026-05-09
"""

import os

import matplotlib.patches as mpatches
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.path import Path


# Theme tokens (see prompts/default-style-guide.md "Background" + "Theme-adaptive Chrome")
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"

# Okabe-Ito palette — use positions 1→N in order
IMPRINT = [
    "#009E73",  # 1: bluish green (brand)
    "#C475FD",  # 2: vermillion
    "#4467A3",  # 3: blue
    "#BD8233",  # 4: reddish purple
]

# Data: Voter migration across 4 election cycles
np.random.seed(42)

time_points = ["2012", "2016", "2020", "2024"]
categories = ["Party A", "Party B", "Party C", "Independent"]
colors = {cat: IMPRINT[i] for i, cat in enumerate(categories)}

# Node values at each time point (thousands of voters)
node_values = {
    "2012": {"Party A": 450, "Party B": 380, "Party C": 120, "Independent": 50},
    "2016": {"Party A": 420, "Party B": 350, "Party C": 150, "Independent": 80},
    "2020": {"Party A": 380, "Party B": 320, "Party C": 200, "Independent": 100},
    "2024": {"Party A": 350, "Party B": 280, "Party C": 250, "Independent": 120},
}

# Flow matrix between consecutive time points
flows = [
    # 2012 -> 2016
    {
        ("Party A", "Party A"): 380,
        ("Party A", "Party B"): 20,
        ("Party A", "Party C"): 30,
        ("Party A", "Independent"): 20,
        ("Party B", "Party A"): 25,
        ("Party B", "Party B"): 310,
        ("Party B", "Party C"): 25,
        ("Party B", "Independent"): 20,
        ("Party C", "Party A"): 10,
        ("Party C", "Party B"): 15,
        ("Party C", "Party C"): 80,
        ("Party C", "Independent"): 15,
        ("Independent", "Party A"): 5,
        ("Independent", "Party B"): 5,
        ("Independent", "Party C"): 15,
        ("Independent", "Independent"): 25,
    },
    # 2016 -> 2020
    {
        ("Party A", "Party A"): 340,
        ("Party A", "Party B"): 15,
        ("Party A", "Party C"): 45,
        ("Party A", "Independent"): 20,
        ("Party B", "Party A"): 20,
        ("Party B", "Party B"): 285,
        ("Party B", "Party C"): 30,
        ("Party B", "Independent"): 15,
        ("Party C", "Party A"): 15,
        ("Party C", "Party B"): 15,
        ("Party C", "Party C"): 105,
        ("Party C", "Independent"): 15,
        ("Independent", "Party A"): 5,
        ("Independent", "Party B"): 5,
        ("Independent", "Party C"): 20,
        ("Independent", "Independent"): 50,
    },
    # 2020 -> 2024
    {
        ("Party A", "Party A"): 310,
        ("Party A", "Party B"): 10,
        ("Party A", "Party C"): 40,
        ("Party A", "Independent"): 20,
        ("Party B", "Party A"): 20,
        ("Party B", "Party B"): 255,
        ("Party B", "Party C"): 30,
        ("Party B", "Independent"): 15,
        ("Party C", "Party A"): 15,
        ("Party C", "Party B"): 10,
        ("Party C", "Party C"): 160,
        ("Party C", "Independent"): 15,
        ("Independent", "Party A"): 5,
        ("Independent", "Party B"): 5,
        ("Independent", "Party C"): 20,
        ("Independent", "Independent"): 70,
    },
]

# Plot setup
fig, ax = plt.subplots(figsize=(16, 9), facecolor=PAGE_BG)
ax.set_facecolor(PAGE_BG)

# Layout parameters
x_positions = [2.0, 4.0, 6.0, 8.0]
node_width = 0.6
total_height = 7.5
node_gap = 0.25

# Calculate node positions and store boundaries
node_bounds = {}
for t_idx, tp in enumerate(time_points):
    values = [node_values[tp][cat] for cat in categories]
    total = sum(values)
    usable_height = total_height - (len(categories) - 1) * node_gap
    heights = [v / total * usable_height for v in values]

    y = 0.5
    for c_idx, cat in enumerate(categories):
        h = heights[c_idx]
        node_bounds[(tp, cat)] = {"x": x_positions[t_idx], "y_start": y, "height": h}
        y += h + node_gap

# Draw flows between consecutive time points
for t_idx in range(len(time_points) - 1):
    tp_from = time_points[t_idx]
    tp_to = time_points[t_idx + 1]

    from_offsets = dict.fromkeys(categories, 0)
    to_offsets = dict.fromkeys(categories, 0)

    flow_data = flows[t_idx]
    for from_cat in categories:
        for to_cat in categories:
            flow_val = flow_data.get((from_cat, to_cat), 0)
            if flow_val <= 0:
                continue

            from_node = node_bounds[(tp_from, from_cat)]
            to_node = node_bounds[(tp_to, to_cat)]

            from_total = sum(node_values[tp_from].values())
            to_total = sum(node_values[tp_to].values())

            usable_height = total_height - (len(categories) - 1) * node_gap
            from_height = flow_val / from_total * usable_height
            to_height = flow_val / to_total * usable_height

            x0 = from_node["x"] + node_width / 2
            x1 = to_node["x"] - node_width / 2
            mid_x = (x0 + x1) / 2

            y0_start = from_node["y_start"] + from_offsets[from_cat]
            y0_end = y0_start + from_height
            y1_start = to_node["y_start"] + to_offsets[to_cat]
            y1_end = y1_start + to_height

            # Draw curved flow band using bezier path
            verts = [
                (x0, y0_start),
                (mid_x, y0_start),
                (mid_x, y1_start),
                (x1, y1_start),
                (x1, y1_end),
                (mid_x, y1_end),
                (mid_x, y0_end),
                (x0, y0_end),
                (x0, y0_start),
            ]
            codes = [
                Path.MOVETO,
                Path.CURVE4,
                Path.CURVE4,
                Path.CURVE4,
                Path.LINETO,
                Path.CURVE4,
                Path.CURVE4,
                Path.CURVE4,
                Path.CLOSEPOLY,
            ]
            path = Path(verts, codes)
            patch = mpatches.PathPatch(path, facecolor=colors[from_cat], edgecolor="none", alpha=0.4)
            ax.add_patch(patch)

            from_offsets[from_cat] += from_height
            to_offsets[to_cat] += to_height

# Draw nodes (rectangles)
for tp in time_points:
    for cat in categories:
        node = node_bounds[(tp, cat)]
        y_start = node["y_start"]
        height = node["height"]
        x = node["x"]

        rect = mpatches.Rectangle(
            (x - node_width / 2, y_start), node_width, height, facecolor=colors[cat], edgecolor=INK_SOFT, linewidth=1.5
        )
        ax.add_patch(rect)

        # Add category label
        value = node_values[tp][cat]
        label = f"{cat}\n({value}K)"
        ax.text(x, y_start + height / 2, label, ha="center", va="center", fontsize=15, fontweight="bold", color=INK)

# Add time point labels
for t_idx, tp in enumerate(time_points):
    ax.text(
        x_positions[t_idx], total_height + 0.8, tp, ha="center", va="bottom", fontsize=22, fontweight="bold", color=INK
    )

# Create legend
legend_handles = [mpatches.Patch(color=colors[cat], label=cat) for cat in categories]
leg = ax.legend(handles=legend_handles, loc="lower left", bbox_to_anchor=(0.01, 0.02), fontsize=16, framealpha=0.95)
leg.get_frame().set_facecolor(ELEVATED_BG)
leg.get_frame().set_edgecolor(INK_SOFT)
leg.get_frame().set_linewidth(1)
plt.setp(leg.get_texts(), color=INK_SOFT)

# Styling
ax.set_xlim(0.8, 9.2)
ax.set_ylim(-0.5, total_height + 1.5)
ax.set_title(
    "Voter Migration 2012-2024 · alluvial-basic · matplotlib · anyplot.ai",
    fontsize=24,
    fontweight="medium",
    color=INK,
    pad=20,
)
ax.axis("off")

plt.tight_layout()
plt.savefig(f"plot-{THEME}.png", dpi=300, bbox_inches="tight", facecolor=PAGE_BG)

Part of Basic Alluvial Diagram on anyplot.ai.

Other implementations