Basic Parallel Categories Plot — Altair

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 Altair

Python source (Altair)

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

import os
import sys


# Remove script directory from sys.path to avoid importing local altair.py
script_dir = os.path.dirname(os.path.abspath(__file__))
if script_dir in sys.path:
    sys.path.remove(script_dir)

import altair as alt
import numpy as np
import pandas as pd


# 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"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"

# Okabe-Ito palette (colorblind-safe)
IMPRINT = [
    "#009E73",  # brand green (position 1)
    "#C475FD",  # vermillion (position 2)
    "#4467A3",  # blue (position 3)
    "#BD8233",  # reddish purple (position 4)
    "#AE3030",  # orange (position 5)
    "#2ABCCD",  # sky blue (position 6)
    "#954477",  # yellow (position 7)
]

# Data - Customer journey through product categories
np.random.seed(42)

n_customers = 200
channels = np.random.choice(["Direct", "Search", "Social", "Email"], n_customers, p=[0.3, 0.35, 0.2, 0.15])
categories = np.random.choice(["Electronics", "Clothing", "Home", "Sports"], n_customers, p=[0.25, 0.35, 0.25, 0.15])
outcomes = np.random.choice(["Purchase", "Abandon", "Browse"], n_customers, p=[0.4, 0.35, 0.25])

df = pd.DataFrame({"Channel": channels, "Category": categories, "Outcome": outcomes})
agg_df = df.groupby(["Channel", "Category", "Outcome"]).size().reset_index(name="count")

# Dimension x-positions
x_pos = {"Channel": 0, "Category": 250, "Outcome": 500}

# Color maps using Okabe-Ito palette
channel_colors = {
    "Direct": IMPRINT[0],  # brand green
    "Search": IMPRINT[1],  # vermillion
    "Social": IMPRINT[2],  # blue
    "Email": IMPRINT[3],  # reddish purple
}
category_colors = {
    "Electronics": IMPRINT[0],  # green
    "Clothing": IMPRINT[1],  # vermillion
    "Home": IMPRINT[2],  # blue
    "Sports": IMPRINT[3],  # reddish purple
}
outcome_colors = {
    "Purchase": IMPRINT[0],  # green
    "Abandon": IMPRINT[1],  # vermillion
    "Browse": IMPRINT[2],  # blue
}

scale_factor = 3.5

# Calculate y-positions for each category in each dimension
channel_totals = agg_df.groupby("Channel")["count"].sum().sort_values(ascending=False)
category_totals = agg_df.groupby("Category")["count"].sum().sort_values(ascending=False)
outcome_totals = agg_df.groupby("Outcome")["count"].sum().sort_values(ascending=False)

channel_pos = {}
y = 0
for cat in channel_totals.index:
    h = channel_totals[cat] * scale_factor
    channel_pos[cat] = {"y0": y, "y1": y + h, "total": channel_totals[cat]}
    y += h + 12

category_pos = {}
y = 0
for cat in category_totals.index:
    h = category_totals[cat] * scale_factor
    category_pos[cat] = {"y0": y, "y1": y + h, "total": category_totals[cat]}
    y += h + 12

outcome_pos = {}
y = 0
for cat in outcome_totals.index:
    h = outcome_totals[cat] * scale_factor
    outcome_pos[cat] = {"y0": y, "y1": y + h, "total": outcome_totals[cat]}
    y += h + 12

# Build flow connections
ch_offsets = dict.fromkeys(channel_pos, 0)
cat_left_offsets = dict.fromkeys(category_pos, 0)
cat_right_offsets = dict.fromkeys(category_pos, 0)
out_offsets = dict.fromkeys(outcome_pos, 0)

ch_cat_flows = agg_df.groupby(["Channel", "Category"])["count"].sum().reset_index()
flow_lines = []

for _, row in ch_cat_flows.iterrows():
    ch, cat, cnt = row["Channel"], row["Category"], row["count"]
    height = cnt * scale_factor
    src_y = channel_pos[ch]["y0"] + ch_offsets[ch] + height / 2
    ch_offsets[ch] += height
    tgt_y = category_pos[cat]["y0"] + cat_left_offsets[cat] + height / 2
    cat_left_offsets[cat] += height
    flow_lines.append(
        {
            "x0": x_pos["Channel"] + 50,
            "y0": src_y,
            "x1": x_pos["Category"],
            "y1": tgt_y,
            "strokeWidth": max(5, cnt * 2.0),
            "color": channel_colors[ch],
        }
    )

cat_out_flows = agg_df.groupby(["Category", "Outcome"])["count"].sum().reset_index()
for _, row in cat_out_flows.iterrows():
    cat, out, cnt = row["Category"], row["Outcome"], row["count"]
    height = cnt * scale_factor
    dom_ch = agg_df[agg_df["Category"] == cat].groupby("Channel")["count"].sum().idxmax()
    src_y = category_pos[cat]["y0"] + cat_right_offsets[cat] + height / 2
    cat_right_offsets[cat] += height
    tgt_y = outcome_pos[out]["y0"] + out_offsets[out] + height / 2
    out_offsets[out] += height
    flow_lines.append(
        {
            "x0": x_pos["Category"] + 50,
            "y0": src_y,
            "x1": x_pos["Outcome"],
            "y1": tgt_y,
            "strokeWidth": max(5, cnt * 2.0),
            "color": channel_colors[dom_ch],
        }
    )

# Create bezier curve points for smooth ribbons
bezier_pts = []
for flow_id, fl in enumerate(flow_lines):
    for t in np.linspace(0, 1, 20):
        x = (
            fl["x0"] * (1 - t) ** 3
            + (fl["x0"] + 60) * 3 * (1 - t) ** 2 * t
            + (fl["x1"] - 60) * 3 * (1 - t) * t**2
            + fl["x1"] * t**3
        )
        y = fl["y0"] * (1 - t) + fl["y1"] * t
        bezier_pts.append(
            {"x": x, "y": y, "flow_id": flow_id, "color": fl["color"], "strokeWidth": fl["strokeWidth"], "order": t}
        )

bezier_df = pd.DataFrame(bezier_pts)

# Create category box data with distinct colors for each dimension
box_data = []
color_maps = {"Channel": channel_colors, "Category": category_colors, "Outcome": outcome_colors}
for dim, pos_dict in [("Channel", channel_pos), ("Category", category_pos), ("Outcome", outcome_pos)]:
    for cat, pos in pos_dict.items():
        box_data.append(
            {
                "category": cat,
                "x": x_pos[dim],
                "x2": x_pos[dim] + 50,
                "y0": pos["y0"],
                "y1": pos["y1"],
                "y_mid": (pos["y0"] + pos["y1"]) / 2,
                "total": pos["total"],
                "color": color_maps[dim].get(cat, INK_SOFT),
            }
        )

box_df = pd.DataFrame(box_data)
max_y = box_df["y1"].max() + 80

# Visualization layers
flows = (
    alt.Chart(bezier_df)
    .mark_line(opacity=0.65, strokeCap="round")
    .encode(
        x=alt.X("x:Q", axis=None, scale=alt.Scale(domain=[-50, 680])),
        y=alt.Y("y:Q", axis=None, scale=alt.Scale(domain=[-70, max_y])),
        detail="flow_id:N",
        order="order:Q",
        color=alt.Color("color:N", scale=None),
        strokeWidth=alt.StrokeWidth("strokeWidth:Q", scale=None),
    )
)

boxes = (
    alt.Chart(box_df)
    .mark_rect(stroke=INK_SOFT, strokeWidth=2, cornerRadius=4)
    .encode(
        x=alt.X("x:Q", axis=None),
        x2="x2:Q",
        y=alt.Y("y0:Q", axis=None),
        y2="y1:Q",
        color=alt.Color("color:N", scale=None),
    )
)

labels = (
    alt.Chart(box_df)
    .mark_text(align="left", baseline="middle", fontSize=18, fontWeight="bold", dx=58)
    .encode(x="x:Q", y="y_mid:Q", text="category:N", color=alt.value(INK))
)

counts = (
    alt.Chart(box_df)
    .mark_text(align="left", baseline="middle", fontSize=15, dx=58, dy=24)
    .encode(x="x:Q", y="y_mid:Q", text=alt.Text("total:Q", format="d"), color=alt.value(INK_SOFT))
)

# Headers positioned above the boxes
headers_df = pd.DataFrame(
    {
        "x": [x_pos["Channel"] + 25, x_pos["Category"] + 25, x_pos["Outcome"] + 25],
        "y": [-45, -45, -45],
        "header": ["Channel", "Category", "Outcome"],
    }
)
headers = (
    alt.Chart(headers_df)
    .mark_text(fontSize=24, fontWeight="bold")
    .encode(x="x:Q", y="y:Q", text="header:N", color=alt.value(INK))
)

# Legend for Channel colors (flow color coding) using square marks
legend_items = []
for i, (ch, color) in enumerate(channel_colors.items()):
    legend_items.append({"label": ch, "color": color, "x": 600, "y": i * 32 + 10})
legend_df = pd.DataFrame(legend_items)

legend_marks = (
    alt.Chart(legend_df).mark_square(size=400).encode(x="x:Q", y="y:Q", color=alt.Color("color:N", scale=None))
)

legend_labels = (
    alt.Chart(legend_df)
    .mark_text(align="left", baseline="middle", fontSize=16, dx=18)
    .encode(x="x:Q", y="y:Q", text="label:N", color=alt.value(INK_SOFT))
)

legend_title_df = pd.DataFrame({"x": [600], "y": [-25], "text": ["Flow Colors"]})
legend_title = (
    alt.Chart(legend_title_df)
    .mark_text(fontSize=18, fontWeight="bold", align="left")
    .encode(x="x:Q", y="y:Q", text="text:N", color=alt.value(INK))
)

# Combine layers
chart = (
    alt.layer(flows, boxes, labels, counts, headers, legend_title, legend_marks, legend_labels)
    .properties(
        width=1600,
        height=900,
        background=PAGE_BG,
        title=alt.Title(
            "parallel-categories-basic · altair · anyplot.ai", fontSize=28, anchor="middle", color=INK, offset=30
        ),
    )
    .configure_view(fill=PAGE_BG, stroke=INK_SOFT, strokeWidth=0)
)

# Save
chart.save(f"plot-{THEME}.png", scale_factor=3.0)
chart.save(f"plot-{THEME}.html")

Part of Basic Parallel Categories Plot on anyplot.ai.

Other implementations