Basic Parallel Categories Plot — lets-plot

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

Python source (lets-plot)

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

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 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 (first 3 colors for the 3 channels)
IMPRINT = ["#009E73", "#C475FD", "#4467A3"]

# Customer journey data with multiple categorical dimensions
# Dimensions: Channel (acquisition), Product Category, Purchase Size, Outcome
data = [
    # Online channel journeys
    ("Online", "Electronics", "Large", "Completed", 45),
    ("Online", "Electronics", "Small", "Completed", 32),
    ("Online", "Electronics", "Large", "Abandoned", 18),
    ("Online", "Electronics", "Small", "Abandoned", 12),
    ("Online", "Clothing", "Large", "Completed", 28),
    ("Online", "Clothing", "Small", "Completed", 55),
    ("Online", "Clothing", "Large", "Abandoned", 8),
    ("Online", "Clothing", "Small", "Abandoned", 15),
    ("Online", "Home", "Large", "Completed", 22),
    ("Online", "Home", "Small", "Completed", 18),
    ("Online", "Home", "Large", "Abandoned", 10),
    ("Online", "Home", "Small", "Abandoned", 7),
    # Store channel journeys
    ("Store", "Electronics", "Large", "Completed", 35),
    ("Store", "Electronics", "Small", "Completed", 20),
    ("Store", "Electronics", "Large", "Abandoned", 5),
    ("Store", "Electronics", "Small", "Abandoned", 3),
    ("Store", "Clothing", "Large", "Completed", 40),
    ("Store", "Clothing", "Small", "Completed", 65),
    ("Store", "Clothing", "Large", "Abandoned", 4),
    ("Store", "Clothing", "Small", "Abandoned", 6),
    ("Store", "Home", "Large", "Completed", 30),
    ("Store", "Home", "Small", "Completed", 25),
    ("Store", "Home", "Large", "Abandoned", 3),
    ("Store", "Home", "Small", "Abandoned", 2),
    # Mobile channel journeys
    ("Mobile", "Electronics", "Large", "Completed", 25),
    ("Mobile", "Electronics", "Small", "Completed", 42),
    ("Mobile", "Electronics", "Large", "Abandoned", 22),
    ("Mobile", "Electronics", "Small", "Abandoned", 18),
    ("Mobile", "Clothing", "Large", "Completed", 15),
    ("Mobile", "Clothing", "Small", "Completed", 48),
    ("Mobile", "Clothing", "Large", "Abandoned", 10),
    ("Mobile", "Clothing", "Small", "Abandoned", 20),
    ("Mobile", "Home", "Large", "Completed", 12),
    ("Mobile", "Home", "Small", "Completed", 22),
    ("Mobile", "Home", "Large", "Abandoned", 8),
    ("Mobile", "Home", "Small", "Abandoned", 12),
]

# Define dimensions and their categories
dimensions = ["Channel", "Product", "Size", "Outcome"]
categories = {
    "Channel": ["Online", "Store", "Mobile"],
    "Product": ["Electronics", "Clothing", "Home"],
    "Size": ["Large", "Small"],
    "Outcome": ["Completed", "Abandoned"],
}

# Colors for the first dimension (Channel) - using Okabe-Ito palette
channel_colors = {"Online": IMPRINT[0], "Store": IMPRINT[1], "Mobile": IMPRINT[2]}

# Calculate totals for each dimension-category combination
dimension_totals = {dim: {} for dim in dimensions}
for channel, product, size, outcome, count in data:
    dimension_totals["Channel"][channel] = dimension_totals["Channel"].get(channel, 0) + count
    dimension_totals["Product"][product] = dimension_totals["Product"].get(product, 0) + count
    dimension_totals["Size"][size] = dimension_totals["Size"].get(size, 0) + count
    dimension_totals["Outcome"][outcome] = dimension_totals["Outcome"].get(outcome, 0) + count

total_flow = sum(count for _, _, _, _, count in data)

# Layout parameters - increased spacing for better label readability
x_positions = [0.10, 0.37, 0.63, 0.90]
node_width = 0.030
node_gap = 0.035

# Calculate node positions for all dimensions (flat structure)
node_positions = []
for dim_idx in range(len(dimensions)):
    dim = dimensions[dim_idx]
    positions = {}
    y_offset = 0.10
    for cat in categories[dim]:
        height = dimension_totals[dim].get(cat, 0) / total_flow * 0.72
        positions[cat] = {"y0": y_offset, "y1": y_offset + height, "x": x_positions[dim_idx]}
        y_offset += height + node_gap
    node_positions.append(positions)

# Build flow polygons between adjacent dimensions
flow_data = []

# Process each pair of adjacent dimensions
for dim_from_idx in range(len(dimensions) - 1):
    dim_to_idx = dim_from_idx + 1
    dim_from = dimensions[dim_from_idx]
    dim_to = dimensions[dim_to_idx]

    # Aggregate flows between categories
    flow_counts = {}
    for channel, product, size, outcome, count in data:
        values = {"Channel": channel, "Product": product, "Size": size, "Outcome": outcome}
        from_cat = values[dim_from]
        to_cat = values[dim_to]
        source_channel = channel
        key = (from_cat, to_cat, source_channel)
        flow_counts[key] = flow_counts.get(key, 0) + count

    # Track offsets for positioning flows within nodes
    from_offsets = dict.fromkeys(categories[dim_from], 0)
    to_offsets = dict.fromkeys(categories[dim_to], 0)

    from_positions = node_positions[dim_from_idx]
    to_positions = node_positions[dim_to_idx]

    x_left = x_positions[dim_from_idx] + node_width / 2
    x_right = x_positions[dim_to_idx] - node_width / 2

    # Sort flows for consistent ordering
    sorted_flows = sorted(
        flow_counts.items(),
        key=lambda x: (
            categories[dim_from].index(x[0][0]),
            categories[dim_to].index(x[0][1]),
            list(channel_colors.keys()).index(x[0][2]),
        ),
    )

    for (from_cat, to_cat, source_channel), count in sorted_flows:
        flow_height = count / total_flow * 0.72

        src_y0 = from_positions[from_cat]["y0"] + from_offsets[from_cat]
        src_y1 = src_y0 + flow_height
        from_offsets[from_cat] += flow_height

        tgt_y0 = to_positions[to_cat]["y0"] + to_offsets[to_cat]
        tgt_y1 = tgt_y0 + flow_height
        to_offsets[to_cat] += flow_height

        # Create smooth curve polygon with easing
        n_points = 30
        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)

        # Combine into closed polygon
        x_polygon = x_vals_top + x_vals_bottom[::-1]
        y_polygon = y_vals_top + y_vals_bottom[::-1]

        flow_id = f"d{dim_from_idx}_{from_cat}_{to_cat}_{source_channel}"
        for x, y in zip(x_polygon, y_polygon, strict=False):
            flow_data.append(
                {"x": x, "y": y, "flow_id": flow_id, "channel": source_channel, "from_cat": from_cat, "to_cat": to_cat}
            )

df_flows = pd.DataFrame(flow_data)

# Build node rectangles
node_rects = []
for dim_idx, dim in enumerate(dimensions):
    for cat in categories[dim]:
        pos = node_positions[dim_idx][cat]
        node_rects.append(
            {
                "xmin": pos["x"] - node_width / 2,
                "xmax": pos["x"] + node_width / 2,
                "ymin": pos["y0"],
                "ymax": pos["y1"],
                "category": cat,
                "dimension": dim,
            }
        )

df_nodes = pd.DataFrame(node_rects)

# Build labels
labels = []

# Dimension headers at top
for i, dim in enumerate(dimensions):
    labels.append({"x": x_positions[i], "y": 0.96, "label": dim, "type": "header", "hjust": 0.5})

# Category labels with counts - positioned with more spacing
for dim_idx, dim in enumerate(dimensions):
    for cat in categories[dim]:
        pos = node_positions[dim_idx][cat]
        count = dimension_totals[dim][cat]

        # Position labels on outer sides for first/last dimensions, alternating for middle
        if dim_idx == 0:
            x_label = pos["x"] - node_width / 2 - 0.02
            hjust = 1
        elif dim_idx == len(dimensions) - 1:
            x_label = pos["x"] + node_width / 2 + 0.02
            hjust = 0
        elif dim_idx % 2 == 0:
            x_label = pos["x"] - node_width / 2 - 0.02
            hjust = 1
        else:
            x_label = pos["x"] + node_width / 2 + 0.02
            hjust = 0

        labels.append(
            {
                "x": x_label,
                "y": (pos["y0"] + pos["y1"]) / 2,
                "label": f"{cat} ({count})",
                "type": "category",
                "hjust": hjust,
            }
        )

df_labels = pd.DataFrame(labels)

# Create the plot
plot = (
    ggplot()
    + geom_polygon(
        aes(x="x", y="y", group="flow_id", fill="channel"), data=df_flows, alpha=0.5, color="white", size=0.08
    )
    + geom_rect(
        aes(xmin="xmin", xmax="xmax", ymin="ymin", ymax="ymax"), data=df_nodes, fill=INK_SOFT, color=INK, size=1.2
    )
    + 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"] == "category"], size=16, color=INK_SOFT
    )
    + scale_fill_manual(
        values={
            "Online": channel_colors["Online"],
            "Store": channel_colors["Store"],
            "Mobile": channel_colors["Mobile"],
        },
        name="Acquisition Channel",
    )
    + labs(title="parallel-categories-basic · letsplot · anyplot.ai")
    + theme_minimal()
    + theme(
        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        panel_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        plot_title=element_text(size=26, face="bold", color=INK),
        axis_title=element_blank(),
        axis_text=element_blank(),
        axis_ticks=element_blank(),
        panel_grid=element_blank(),
        legend_text=element_text(size=16, color=INK_SOFT),
        legend_title=element_text(size=18, face="bold", color=INK),
        legend_position="bottom",
        legend_background=element_blank(),
    )
    + scale_x_continuous(limits=[-0.02, 1.02])
    + scale_y_continuous(limits=[-0.02, 1.02])
    + ggsize(1600, 900)
)

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

Part of Basic Parallel Categories Plot on anyplot.ai.

Other implementations