Basic Alluvial Diagram — lets-plot

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 lets-plot

Python source (lets-plot)

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

import os

import pandas as pd
from lets_plot import (
    LetsPlot,
    aes,
    element_blank,
    element_rect,
    element_text,
    geom_polygon,
    geom_rect,
    geom_text,
    ggplot,
    ggsize,
    labs,
    scale_fill_manual,
    scale_x_continuous,
    scale_y_continuous,
    theme,
    theme_minimal,
)
from lets_plot.export import ggsave


LetsPlot.setup_html()

# Theme-adaptive colors
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"

# Okabe-Ito palette (positions 1-4)
IMPRINT = ["#009E73", "#C475FD", "#4467A3", "#BD8233"]

# Voter migration data across three election cycles
elections = ["2016", "2020", "2024"]
parties = ["Democrats", "Republicans", "Independents", "Non-Voters"]

# Initial distribution in 2016 (millions of voters)
initial_2016 = {"Democrats": 65, "Republicans": 63, "Independents": 8, "Non-Voters": 95}

# Flows from 2016 to 2020 (from_party -> to_party: millions)
flows_2016_2020 = [
    ("Democrats", "Democrats", 58),
    ("Democrats", "Republicans", 3),
    ("Democrats", "Independents", 2),
    ("Democrats", "Non-Voters", 2),
    ("Republicans", "Democrats", 5),
    ("Republicans", "Republicans", 54),
    ("Republicans", "Independents", 2),
    ("Republicans", "Non-Voters", 2),
    ("Independents", "Democrats", 3),
    ("Independents", "Republicans", 2),
    ("Independents", "Independents", 2),
    ("Independents", "Non-Voters", 1),
    ("Non-Voters", "Democrats", 15),
    ("Non-Voters", "Republicans", 12),
    ("Non-Voters", "Independents", 4),
    ("Non-Voters", "Non-Voters", 64),
]

# Flows from 2020 to 2024 (from_party -> to_party: millions)
flows_2020_2024 = [
    ("Democrats", "Democrats", 72),
    ("Democrats", "Republicans", 4),
    ("Democrats", "Independents", 3),
    ("Democrats", "Non-Voters", 2),
    ("Republicans", "Democrats", 3),
    ("Republicans", "Republicans", 63),
    ("Republicans", "Independents", 2),
    ("Republicans", "Non-Voters", 3),
    ("Independents", "Democrats", 4),
    ("Independents", "Republicans", 3),
    ("Independents", "Independents", 2),
    ("Independents", "Non-Voters", 1),
    ("Non-Voters", "Democrats", 6),
    ("Non-Voters", "Republicans", 8),
    ("Non-Voters", "Independents", 3),
    ("Non-Voters", "Non-Voters", 52),
]

# Calculate totals at each time point
totals_2016 = initial_2016.copy()
totals_2020 = dict.fromkeys(parties, 0)
for _, to_party, val in flows_2016_2020:
    totals_2020[to_party] += val

totals_2024 = dict.fromkeys(parties, 0)
for _, to_party, val in flows_2020_2024:
    totals_2024[to_party] += val

time_totals = [totals_2016, totals_2020, totals_2024]

# Layout parameters
x_positions = [0.15, 0.5, 0.85]
node_width = 0.03
node_gap = 0.02
total_flow = sum(totals_2016.values())

# Colors for parties - Okabe-Ito palette (positions 1-4)
party_colors = {
    "Democrats": IMPRINT[0],
    "Republicans": IMPRINT[1],
    "Independents": IMPRINT[2],
    "Non-Voters": IMPRINT[3],
}


# Calculate node positions at each time point
def calculate_node_positions(totals, x_pos):
    positions = {}
    y_offset = 0.05
    for party in parties:
        height = totals.get(party, 0) / total_flow * 0.85
        positions[party] = {"y0": y_offset, "y1": y_offset + height, "x": x_pos}
        y_offset += height + node_gap
    return positions


node_positions = [
    calculate_node_positions(totals_2016, x_positions[0]),
    calculate_node_positions(totals_2020, x_positions[1]),
    calculate_node_positions(totals_2024, x_positions[2]),
]

# Build flow polygons between time points
flow_data = []


def add_flows(flows, time_idx, src_positions, tgt_positions, x_left, x_right):
    src_offsets = dict.fromkeys(parties, 0)
    tgt_offsets = dict.fromkeys(parties, 0)

    for from_party, to_party, val in flows:
        flow_height = val / total_flow * 0.85

        src_y0 = src_positions[from_party]["y0"] + src_offsets[from_party]
        src_y1 = src_y0 + flow_height
        src_offsets[from_party] += flow_height

        tgt_y0 = tgt_positions[to_party]["y0"] + tgt_offsets[to_party]
        tgt_y1 = tgt_y0 + flow_height
        tgt_offsets[to_party] += flow_height

        n_points = 40
        x_vals_top = []
        y_vals_top = []
        x_vals_bottom = []
        y_vals_bottom = []

        for i in range(n_points + 1):
            t = i / n_points
            x = x_left + t * (x_right - x_left)
            ease = t * t * (3 - 2 * t)
            y_top = src_y1 + ease * (tgt_y1 - src_y1)
            y_bottom = src_y0 + ease * (tgt_y0 - src_y0)

            x_vals_top.append(x)
            y_vals_top.append(y_top)
            x_vals_bottom.append(x)
            y_vals_bottom.append(y_bottom)

        x_polygon = x_vals_top + x_vals_bottom[::-1]
        y_polygon = y_vals_top + y_vals_bottom[::-1]

        # Use destination party for coloring to show where voters went
        flow_id = f"t{time_idx}_{from_party}_{to_party}"
        flow_color_key = f"{from_party}_{to_party}"
        for x, y in zip(x_polygon, y_polygon, strict=False):
            flow_data.append(
                {
                    "x": x,
                    "y": y,
                    "flow_id": flow_id,
                    "flow_color": flow_color_key,
                    "from_party": from_party,
                    "to_party": to_party,
                }
            )


# Add flows for 2016->2020
add_flows(
    flows_2016_2020,
    0,
    node_positions[0],
    node_positions[1],
    x_positions[0] + node_width / 2,
    x_positions[1] - node_width / 2,
)

# Add flows for 2020->2024
add_flows(
    flows_2020_2024,
    1,
    node_positions[1],
    node_positions[2],
    x_positions[1] + node_width / 2,
    x_positions[2] - node_width / 2,
)

df_flows = pd.DataFrame(flow_data)

# Build node rectangles
node_rects = []
for time_idx, positions in enumerate(node_positions):
    for party in parties:
        pos = positions[party]
        node_rects.append(
            {
                "xmin": pos["x"] - node_width / 2,
                "xmax": pos["x"] + node_width / 2,
                "ymin": pos["y0"],
                "ymax": pos["y1"],
                "party": party,
                "time_idx": time_idx,
            }
        )

df_nodes = pd.DataFrame(node_rects)

# Build labels
labels = []

# Time point labels (column headers)
for i, election in enumerate(elections):
    labels.append({"x": x_positions[i], "y": 0.96, "label": election, "type": "header", "hjust": 0.5})

# Party labels at first column (left side)
for party in parties:
    pos = node_positions[0][party]
    labels.append(
        {
            "x": x_positions[0] - node_width - 0.02,
            "y": (pos["y0"] + pos["y1"]) / 2,
            "label": party,
            "type": "party_left",
            "hjust": 1,
        }
    )

# Value labels at last column (right side)
for party in parties:
    pos = node_positions[2][party]
    val = totals_2024[party]
    labels.append(
        {
            "x": x_positions[2] + node_width + 0.02,
            "y": (pos["y0"] + pos["y1"]) / 2,
            "label": f"{party}\n({val}M)",
            "type": "party_right",
            "hjust": 0,
        }
    )

df_labels = pd.DataFrame(labels)

# Create the plot - flows colored by destination to show where voters went
plot = (
    ggplot()
    + geom_polygon(
        aes(x="x", y="y", group="flow_id", fill="to_party"), data=df_flows, alpha=0.55, color="white", size=0.1
    )
    + geom_rect(aes(xmin="xmin", xmax="xmax", ymin="ymin", ymax="ymax", fill="party"), data=df_nodes, color=INK, size=1)
    + geom_text(
        aes(x="x", y="y", label="label"),
        data=df_labels[df_labels["type"] == "header"],
        size=18,
        hjust=0.5,
        fontface="bold",
        color=INK,
    )
    + geom_text(
        aes(x="x", y="y", label="label"),
        data=df_labels[df_labels["type"] == "party_left"],
        size=13,
        hjust=1,
        color=INK_SOFT,
    )
    + geom_text(
        aes(x="x", y="y", label="label"),
        data=df_labels[df_labels["type"] == "party_right"],
        size=12,
        hjust=0,
        color=INK_SOFT,
    )
    + scale_fill_manual(
        values={
            "Democrats": party_colors["Democrats"],
            "Republicans": party_colors["Republicans"],
            "Independents": party_colors["Independents"],
            "Non-Voters": party_colors["Non-Voters"],
        }
    )
    + labs(title="alluvial-basic · letsplot · pyplots.ai")
    + theme_minimal()
    + theme(
        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        panel_background=element_rect(fill=PAGE_BG),
        plot_title=element_text(size=28, face="bold", color=INK),
        axis_title=element_blank(),
        axis_text=element_blank(),
        axis_ticks=element_blank(),
        panel_grid=element_blank(),
        legend_position="none",
    )
    + scale_x_continuous(limits=[-0.05, 1.05])
    + scale_y_continuous(limits=[-0.02, 1.02])
    + ggsize(1600, 900)
)

# Save as PNG (scale 3x for 4800 × 2700 px)
ggsave(plot, f"plot-{THEME}.png", path=".", scale=3)

# Save as HTML for interactivity
ggsave(plot, f"plot-{THEME}.html", path=".")

Part of Basic Alluvial Diagram on anyplot.ai.

Other implementations