Basic Parallel Categories Plot — plotnine

A parallel categories plot visualizes categorical data across multiple dimensions, with vertical axes representing each categorical variable and ribbons connecting categories to show observation flow. Unlike parallel coordinates (which use lines for numeric data), parallel categories use width-proportional ribbons to show counts or frequencies, making it ideal for understanding how categorical values co-occur and flow across multiple classification dimensions.

Basic Parallel Categories Plot rendered with plotnine

Python source (plotnine)

""" anyplot.ai
parallel-categories-basic: Basic Parallel Categories Plot
Library: plotnine 0.15.4 | Python 3.13.13
Quality: 84/100 | Updated: 2026-05-13
"""

import os
import sys


# Prevent current directory from shadowing the plotnine package
sys.path = [p for p in sys.path if not p.endswith("implementations") and not p.endswith("python")]

import numpy as np
import pandas as pd
from plotnine import (
    aes,
    annotate,
    element_blank,
    element_text,
    geom_polygon,
    geom_rect,
    geom_text,
    ggplot,
    labs,
    scale_fill_manual,
    theme,
    theme_minimal,
)


# Theme tokens
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 for categorical data
IMPRINT = [
    "#009E73",  # Brand green - first series
    "#C475FD",  # Vermillion
    "#4467A3",  # Blue
    "#BD8233",  # Reddish purple
    "#AE3030",  # Orange
    "#2ABCCD",  # Sky blue
    "#954477",  # Yellow
]

# Data - Customer journey data with multiple categorical dimensions
np.random.seed(42)

# Define category combinations and realistic counts
path_data = [
    # Channel -> Product Category -> Customer Type -> Outcome
    ("Online", "Electronics", "New", "Purchased", 145),
    ("Online", "Electronics", "New", "Abandoned", 98),
    ("Online", "Electronics", "Returning", "Purchased", 187),
    ("Online", "Electronics", "Returning", "Abandoned", 42),
    ("Online", "Clothing", "New", "Purchased", 112),
    ("Online", "Clothing", "New", "Abandoned", 76),
    ("Online", "Clothing", "Returning", "Purchased", 156),
    ("Online", "Clothing", "Returning", "Abandoned", 38),
    ("Online", "Home", "New", "Purchased", 67),
    ("Online", "Home", "New", "Abandoned", 54),
    ("Online", "Home", "Returning", "Purchased", 89),
    ("Online", "Home", "Returning", "Abandoned", 23),
    ("Store", "Electronics", "New", "Purchased", 78),
    ("Store", "Electronics", "New", "Abandoned", 32),
    ("Store", "Electronics", "Returning", "Purchased", 124),
    ("Store", "Electronics", "Returning", "Abandoned", 18),
    ("Store", "Clothing", "New", "Purchased", 95),
    ("Store", "Clothing", "New", "Abandoned", 28),
    ("Store", "Clothing", "Returning", "Purchased", 142),
    ("Store", "Clothing", "Returning", "Abandoned", 15),
    ("Store", "Home", "New", "Purchased", 56),
    ("Store", "Home", "New", "Abandoned", 21),
    ("Store", "Home", "Returning", "Purchased", 78),
    ("Store", "Home", "Returning", "Abandoned", 12),
    ("Mobile", "Electronics", "New", "Purchased", 89),
    ("Mobile", "Electronics", "New", "Abandoned", 112),
    ("Mobile", "Electronics", "Returning", "Purchased", 134),
    ("Mobile", "Electronics", "Returning", "Abandoned", 67),
    ("Mobile", "Clothing", "New", "Purchased", 76),
    ("Mobile", "Clothing", "New", "Abandoned", 94),
    ("Mobile", "Clothing", "Returning", "Purchased", 118),
    ("Mobile", "Clothing", "Returning", "Abandoned", 52),
    ("Mobile", "Home", "New", "Purchased", 45),
    ("Mobile", "Home", "New", "Abandoned", 58),
    ("Mobile", "Home", "Returning", "Purchased", 67),
    ("Mobile", "Home", "Returning", "Abandoned", 34),
]

path_counts = pd.DataFrame(path_data, columns=["channel", "product", "customer_type", "outcome", "count"])

# Define dimensions and their category orders
dimensions = [
    {"name": "channel", "label": "Channel", "categories": ["Online", "Store", "Mobile"]},
    {"name": "product", "label": "Product", "categories": ["Electronics", "Clothing", "Home"]},
    {"name": "customer_type", "label": "Customer", "categories": ["Returning", "New"]},
    {"name": "outcome", "label": "Outcome", "categories": ["Purchased", "Abandoned"]},
]

# Color by outcome - using Okabe-Ito palette
outcome_colors = {
    "Purchased": IMPRINT[0],  # Brand green
    "Abandoned": IMPRINT[2],  # Blue
}

# Layout parameters
n_dims = len(dimensions)
x_positions = np.linspace(0.1, 0.9, n_dims)
node_width = 0.04
node_gap = 0.03
total_height = 0.82
y_start = 0.92

# Calculate node positions for each dimension
node_positions = {}
for dim_idx, dim in enumerate(dimensions):
    x_pos = x_positions[dim_idx]
    categories = dim["categories"]
    col_name = dim["name"]

    # Calculate totals for this dimension
    totals = path_counts.groupby(col_name)["count"].sum()
    grand_total = totals.sum()
    current_y = y_start

    for cat in categories:
        count = totals.get(cat, 0)
        height = (count / grand_total) * total_height if grand_total > 0 else 0

        node_positions[(dim_idx, cat)] = {
            "x": x_pos,
            "y_top": current_y,
            "y_bottom": current_y - height,
            "height": height,
            "count": count,
            "flow_offset_out": 0,
            "flow_offset_in": 0,
        }
        current_y = current_y - height - node_gap

# Build node rectangles dataframe
node_data = []
for (dim_idx, cat), pos in node_positions.items():
    node_data.append(
        {
            "dim_idx": dim_idx,
            "category": cat,
            "xmin": pos["x"] - node_width / 2,
            "xmax": pos["x"] + node_width / 2,
            "ymin": pos["y_bottom"],
            "ymax": pos["y_top"],
            "label_y": (pos["y_top"] + pos["y_bottom"]) / 2,
            "count": pos["count"],
            "display_label": str(cat),
            "fill_color": INK_SOFT,
        }
    )
nodes_df = pd.DataFrame(node_data)

# Build flow polygons between adjacent dimensions
flow_polygons = []
flow_id_counter = 0

for _, path_row in path_counts.iterrows():
    path_values = [path_row["channel"], path_row["product"], path_row["customer_type"], path_row["outcome"]]
    count = path_row["count"]
    outcome = path_row["outcome"]

    # Draw flows between each adjacent pair of dimensions
    for dim_idx in range(n_dims - 1):
        from_cat = path_values[dim_idx]
        to_cat = path_values[dim_idx + 1]

        src_pos = node_positions[(dim_idx, from_cat)]
        tgt_pos = node_positions[(dim_idx + 1, to_cat)]

        # Calculate flow height proportional to count
        src_total = sum(path_counts[path_counts[dimensions[dim_idx]["name"]] == from_cat]["count"])
        flow_height_src = (count / src_total) * src_pos["height"] if src_total > 0 else 0

        tgt_total = sum(path_counts[path_counts[dimensions[dim_idx + 1]["name"]] == to_cat]["count"])
        flow_height_tgt = (count / tgt_total) * tgt_pos["height"] if tgt_total > 0 else 0

        # Source connection point (right side of node)
        src_y_top = src_pos["y_top"] - src_pos["flow_offset_out"]
        src_y_bottom = src_y_top - flow_height_src
        src_pos["flow_offset_out"] += flow_height_src

        # Target connection point (left side of node)
        tgt_y_top = tgt_pos["y_top"] - tgt_pos["flow_offset_in"]
        tgt_y_bottom = tgt_y_top - flow_height_tgt
        tgt_pos["flow_offset_in"] += flow_height_tgt

        # Create curved flow polygon using cubic interpolation
        flow_x_left = x_positions[dim_idx] + node_width / 2
        flow_x_right = x_positions[dim_idx + 1] - node_width / 2
        n_points = 30

        t_param = np.linspace(0, 1, n_points)
        # Smooth cubic easing for natural flow appearance
        x_top = flow_x_left + (flow_x_right - flow_x_left) * t_param
        y_top = src_y_top + (tgt_y_top - src_y_top) * (3 * t_param**2 - 2 * t_param**3)

        x_bottom = flow_x_right + (flow_x_left - flow_x_right) * t_param
        y_bottom = tgt_y_bottom + (src_y_bottom - tgt_y_bottom) * (3 * t_param**2 - 2 * t_param**3)

        # Combine into polygon
        x_polygon = np.concatenate([x_top, x_bottom])
        y_polygon = np.concatenate([y_top, y_bottom])

        flow_id = f"flow_{flow_id_counter}"
        flow_id_counter += 1

        for i in range(len(x_polygon)):
            flow_polygons.append({"x": x_polygon[i], "y": y_polygon[i], "flow_id": flow_id, "outcome": outcome})

flows_df = pd.DataFrame(flow_polygons)

# Create background rectangle data
bg_rect = pd.DataFrame({"xmin": [0], "xmax": [1], "ymin": [-0.05], "ymax": [1.05]})

# Create the plot
plot = (
    ggplot()
    # Background rectangle
    + geom_rect(bg_rect, aes(xmin="xmin", xmax="xmax", ymin="ymin", ymax="ymax"), fill=PAGE_BG, color=PAGE_BG)
    # Flow polygons with transparency - colored by outcome
    + geom_polygon(flows_df, aes(x="x", y="y", group="flow_id", fill="outcome"), alpha=0.5)
    # Node rectangles
    + geom_rect(
        nodes_df, aes(xmin="xmin", xmax="xmax", ymin="ymin", ymax="ymax"), fill=INK_SOFT, color=PAGE_BG, size=0.8
    )
    # Count labels on nodes
    + geom_text(
        nodes_df[nodes_df["count"] >= 20],
        aes(x=(nodes_df["xmin"] + nodes_df["xmax"]) / 2, y="label_y", label="count"),
        ha="center",
        va="center",
        size=10,
        color=PAGE_BG,
        fontweight="bold",
    )
    + scale_fill_manual(values=outcome_colors, name="Outcome", breaks=["Purchased", "Abandoned"])
    + labs(title="parallel-categories-basic · plotnine · anyplot.ai", x="", y="")
    + theme_minimal()
    + theme(
        figure_size=(16, 9),
        plot_background=element_blank(),
        panel_background=element_blank(),
        plot_title=element_text(size=24, ha="center", weight="bold", color=INK),
        axis_text=element_blank(),
        axis_ticks=element_blank(),
        panel_grid=element_blank(),
        legend_background=element_blank(),
        legend_title=element_text(size=16, weight="bold", color=INK),
        legend_text=element_text(size=14, color=INK_SOFT),
        legend_position="right",
    )
)

# Add dimension labels at top
for dim_idx, dim in enumerate(dimensions):
    plot = plot + annotate(
        "text", x=x_positions[dim_idx], y=0.98, label=dim["label"], size=14, color=INK, fontweight="bold", ha="center"
    )

# Add category labels beside each node
for (dim_idx, cat), pos in node_positions.items():
    label = str(cat)
    label_y = (pos["y_top"] + pos["y_bottom"]) / 2

    # For first dimension, place label on left side of node
    if dim_idx == 0:
        plot = plot + annotate(
            "text",
            x=x_positions[dim_idx] - node_width / 2 - 0.01,
            y=label_y,
            label=label,
            size=11,
            color=INK_SOFT,
            ha="right",
            va="center",
        )
    # For last dimension, place label on right side of node
    elif dim_idx == n_dims - 1:
        plot = plot + annotate(
            "text",
            x=x_positions[dim_idx] + node_width / 2 + 0.01,
            y=label_y,
            label=label,
            size=11,
            color=INK_SOFT,
            ha="left",
            va="center",
        )
    # For middle dimensions, place label below the node
    else:
        plot = plot + annotate(
            "text",
            x=x_positions[dim_idx],
            y=pos["y_bottom"] - 0.015,
            label=label,
            size=11,
            color=INK_SOFT,
            ha="center",
            va="top",
        )

plot.save(f"plot-{THEME}.png", dpi=300, verbose=False)

Part of Basic Parallel Categories Plot on anyplot.ai.

Other implementations