Basic Alluvial Diagram — Bokeh

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 Bokeh

Python source (Bokeh)

""" anyplot.ai
alluvial-basic: Basic Alluvial Diagram
Library: bokeh 3.9.0 | Python 3.13.13
Quality: 96/100 | Updated: 2026-05-09
"""

import os
import time
from pathlib import Path

import numpy as np
from bokeh.io import output_file, save
from bokeh.models import Label, Legend, LegendItem
from bokeh.plotting import figure
from selenium import webdriver
from selenium.webdriver.chrome.options import Options


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"

BRAND = "#009E73"
OI_2 = "#C475FD"
OI_3 = "#4467A3"
OI_4 = "#BD8233"

np.random.seed(42)

time_points = ["2012", "2016", "2020", "2024"]
categories = ["Democratic", "Republican", "Independent", "Other"]
colors = {"Democratic": BRAND, "Republican": OI_2, "Independent": OI_3, "Other": OI_4}

flows_data = [
    [
        ("Democratic", "Democratic", 35),
        ("Democratic", "Independent", 5),
        ("Democratic", "Republican", 2),
        ("Republican", "Republican", 30),
        ("Republican", "Independent", 4),
        ("Republican", "Democratic", 3),
        ("Independent", "Democratic", 4),
        ("Independent", "Republican", 3),
        ("Independent", "Independent", 8),
        ("Other", "Other", 3),
        ("Other", "Independent", 2),
        ("Other", "Democratic", 1),
    ],
    [
        ("Democratic", "Democratic", 38),
        ("Democratic", "Independent", 3),
        ("Democratic", "Republican", 2),
        ("Republican", "Republican", 32),
        ("Republican", "Independent", 3),
        ("Republican", "Democratic", 2),
        ("Independent", "Democratic", 5),
        ("Independent", "Republican", 4),
        ("Independent", "Independent", 8),
        ("Other", "Other", 2),
        ("Other", "Independent", 2),
        ("Other", "Republican", 1),
    ],
    [
        ("Democratic", "Democratic", 40),
        ("Democratic", "Independent", 4),
        ("Democratic", "Republican", 1),
        ("Republican", "Republican", 34),
        ("Republican", "Independent", 2),
        ("Republican", "Democratic", 3),
        ("Independent", "Democratic", 4),
        ("Independent", "Republican", 5),
        ("Independent", "Independent", 6),
        ("Other", "Other", 2),
        ("Other", "Democratic", 1),
        ("Other", "Independent", 1),
    ],
]

node_heights = []
for t_idx, _t in enumerate(time_points):
    heights = {}
    if t_idx == 0:
        for cat in categories:
            heights[cat] = sum(f[2] for f in flows_data[0] if f[0] == cat)
    elif t_idx == len(time_points) - 1:
        for cat in categories:
            heights[cat] = sum(f[2] for f in flows_data[-1] if f[1] == cat)
    else:
        for cat in categories:
            heights[cat] = sum(f[2] for f in flows_data[t_idx - 1] if f[1] == cat)
    node_heights.append(heights)

x_positions = [0, 1, 2, 3]
node_width = 0.12
gap = 2

node_positions = []
for t_idx in range(len(time_points)):
    positions = {}
    y_cursor = 0
    for cat in categories:
        height = node_heights[t_idx][cat]
        positions[cat] = {"y_start": y_cursor, "y_end": y_cursor + height}
        y_cursor += height + gap
    node_positions.append(positions)

p = figure(
    width=4800,
    height=2700,
    title="alluvial-basic · bokeh · anyplot.ai",
    x_range=(-0.9, 4.3),
    y_range=(-8, max(sum(node_heights[0].values()) + gap * len(categories), 120)),
    tools="",
    toolbar_location=None,
)

p.title.text_font_size = "28pt"
p.title.text_color = INK
p.title.align = "center"
p.xgrid.visible = False
p.ygrid.visible = False
p.xaxis.visible = False
p.yaxis.visible = False
p.outline_line_color = None
p.background_fill_color = PAGE_BG
p.border_fill_color = PAGE_BG

subtitle = Label(
    x=1.5,
    y=115,
    text="Voter Migration Between Parties (values in millions)",
    text_font_size="22pt",
    text_align="center",
    text_baseline="top",
    text_color=INK_SOFT,
)
p.add_layout(subtitle)

n_points = 50
t_param = np.linspace(0, 1, n_points)

for t_idx, flows in enumerate(flows_data):
    x_start = x_positions[t_idx] + node_width / 2
    x_end = x_positions[t_idx + 1] - node_width / 2

    source_cursors = {cat: node_positions[t_idx][cat]["y_start"] for cat in categories}
    target_cursors = {cat: node_positions[t_idx + 1][cat]["y_start"] for cat in categories}

    for from_cat, to_cat, value in flows:
        if value == 0:
            continue

        y_src_bottom = source_cursors[from_cat]
        y_src_top = y_src_bottom + value
        source_cursors[from_cat] = y_src_top

        y_tgt_bottom = target_cursors[to_cat]
        y_tgt_top = y_tgt_bottom + value
        target_cursors[to_cat] = y_tgt_top

        cx0 = x_start + (x_end - x_start) / 3
        cx1 = x_start + 2 * (x_end - x_start) / 3

        x_top = (
            (1 - t_param) ** 3 * x_start
            + 3 * (1 - t_param) ** 2 * t_param * cx0
            + 3 * (1 - t_param) * t_param**2 * cx1
            + t_param**3 * x_end
        )
        y_top = (
            (1 - t_param) ** 3 * y_src_top
            + 3 * (1 - t_param) ** 2 * t_param * y_src_top
            + 3 * (1 - t_param) * t_param**2 * y_tgt_top
            + t_param**3 * y_tgt_top
        )

        x_bottom = (
            (1 - t_param) ** 3 * x_start
            + 3 * (1 - t_param) ** 2 * t_param * cx0
            + 3 * (1 - t_param) * t_param**2 * cx1
            + t_param**3 * x_end
        )
        y_bottom = (
            (1 - t_param) ** 3 * y_src_bottom
            + 3 * (1 - t_param) ** 2 * t_param * y_src_bottom
            + 3 * (1 - t_param) * t_param**2 * y_tgt_bottom
            + t_param**3 * y_tgt_bottom
        )

        xs = list(x_top) + list(x_bottom[::-1])
        ys = list(y_top) + list(y_bottom[::-1])

        color = colors[from_cat]
        p.patch(xs, ys, fill_color=color, fill_alpha=0.5, line_color=color, line_alpha=0.7, line_width=1)

legend_renderers = {}
for t_idx, _t in enumerate(time_points):
    x = x_positions[t_idx]
    for cat in categories:
        y_start = node_positions[t_idx][cat]["y_start"]
        y_end = node_positions[t_idx][cat]["y_end"]
        height = y_end - y_start

        if height > 0:
            renderer = p.quad(
                left=x - node_width / 2,
                right=x + node_width / 2,
                top=y_end,
                bottom=y_start,
                fill_color=colors[cat],
                line_color=PAGE_BG,
                line_width=2,
            )

            if cat not in legend_renderers:
                legend_renderers[cat] = renderer

            if t_idx == 0:
                label = Label(
                    x=x - node_width / 2 - 0.03,
                    y=(y_start + y_end) / 2,
                    text=f"{cat} ({int(height)}M)",
                    text_font_size="22pt",
                    text_baseline="middle",
                    text_align="right",
                    text_color=INK_SOFT,
                )
                p.add_layout(label)
            elif t_idx == len(time_points) - 1:
                label = Label(
                    x=x + node_width / 2 + 0.03,
                    y=(y_start + y_end) / 2,
                    text=f"{cat} ({int(height)}M)",
                    text_font_size="22pt",
                    text_baseline="middle",
                    text_color=INK_SOFT,
                )
                p.add_layout(label)

legend_items = [LegendItem(label=cat, renderers=[legend_renderers[cat]]) for cat in categories]
legend = Legend(
    items=legend_items,
    location="top_right",
    label_text_font_size="18pt",
    label_text_color=INK_SOFT,
    glyph_width=30,
    glyph_height=30,
    spacing=10,
    padding=15,
    background_fill_alpha=0.9,
    background_fill_color=ELEVATED_BG,
    border_line_color=INK_SOFT,
)
p.add_layout(legend, "right")

for t_idx, t in enumerate(time_points):
    label = Label(
        x=x_positions[t_idx],
        y=-4,
        text=t,
        text_font_size="24pt",
        text_align="center",
        text_baseline="top",
        text_color=INK,
        text_font_style="bold",
    )
    p.add_layout(label)

output_file(f"plot-{THEME}.html")
save(p)

W, H = 4800, 2700
opts = Options()
for arg in (
    "--headless=new",
    "--no-sandbox",
    "--disable-dev-shm-usage",
    "--disable-gpu",
    f"--window-size={W},{H}",
    "--hide-scrollbars",
):
    opts.add_argument(arg)
driver = webdriver.Chrome(options=opts)
driver.set_window_size(W, H)
driver.get(f"file://{Path(f'plot-{THEME}.html').resolve()}")
time.sleep(3)
driver.save_screenshot(f"plot-{THEME}.png")
driver.quit()

Part of Basic Alluvial Diagram on anyplot.ai.

Other implementations