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.

""" anyplot.ai
parallel-categories-basic: Basic Parallel Categories Plot
Library: pygal 3.1.0 | Python 3.13.13
Quality: 82/100 | Updated: 2026-05-13
"""
import os
import cairosvg
import numpy as np
import pygal
from pygal.style import Style
# Theme tokens
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"
INK_MUTED = "#6B6A63" if THEME == "light" else "#A8A79F"
# Set seed for reproducibility
np.random.seed(42)
# Okabe-Ito palette (first series is always #009E73)
IMPRINT = ("#009E73", "#C475FD", "#4467A3", "#BD8233")
# Data: Product journey from category through distribution channel to outcome
# Changed from Online/Store/Mobile to Distribution Channels (Direct/Wholesale/Retail)
categories = ["Category", "Distribution", "Payment", "Outcome"]
dimension_values = {
"Category": ["Electronics", "Clothing", "Home & Garden", "Sports"],
"Distribution": ["Direct", "Wholesale", "Retail"],
"Payment": ["Credit Card", "Debit Card", "Digital Wallet"],
"Outcome": ["Completed", "Returned", "Cancelled"],
}
# Generate realistic distribution channel data
np.random.seed(42)
base_counts = {
# Electronics - balanced across channels
("Electronics", "Direct", "Credit Card", "Completed"): 450,
("Electronics", "Direct", "Credit Card", "Returned"): 85,
("Electronics", "Direct", "Digital Wallet", "Completed"): 280,
("Electronics", "Direct", "Digital Wallet", "Returned"): 45,
("Electronics", "Wholesale", "Credit Card", "Completed"): 320,
("Electronics", "Wholesale", "Debit Card", "Completed"): 180,
("Electronics", "Retail", "Digital Wallet", "Completed"): 220,
("Electronics", "Retail", "Digital Wallet", "Cancelled"): 75,
("Electronics", "Direct", "Credit Card", "Cancelled"): 40,
# Clothing - higher retail presence
("Clothing", "Direct", "Credit Card", "Completed"): 320,
("Clothing", "Direct", "Credit Card", "Returned"): 95,
("Clothing", "Direct", "Debit Card", "Completed"): 150,
("Clothing", "Direct", "Debit Card", "Returned"): 50,
("Clothing", "Retail", "Credit Card", "Completed"): 480,
("Clothing", "Retail", "Debit Card", "Completed"): 290,
("Clothing", "Retail", "Debit Card", "Returned"): 45,
("Clothing", "Wholesale", "Digital Wallet", "Completed"): 200,
("Clothing", "Wholesale", "Credit Card", "Completed"): 155,
("Clothing", "Direct", "Digital Wallet", "Cancelled"): 35,
# Home & Garden - more wholesale/retail
("Home & Garden", "Retail", "Credit Card", "Completed"): 420,
("Home & Garden", "Retail", "Debit Card", "Completed"): 320,
("Home & Garden", "Retail", "Debit Card", "Returned"): 60,
("Home & Garden", "Direct", "Credit Card", "Completed"): 190,
("Home & Garden", "Direct", "Credit Card", "Returned"): 35,
("Home & Garden", "Direct", "Digital Wallet", "Completed"): 120,
("Home & Garden", "Wholesale", "Digital Wallet", "Completed"): 110,
# Sports - strong direct/retail mix
("Sports", "Direct", "Digital Wallet", "Completed"): 280,
("Sports", "Direct", "Credit Card", "Completed"): 200,
("Sports", "Retail", "Credit Card", "Completed"): 310,
("Sports", "Retail", "Debit Card", "Completed"): 195,
("Sports", "Wholesale", "Credit Card", "Completed"): 240,
("Sports", "Wholesale", "Debit Card", "Completed"): 180,
("Sports", "Wholesale", "Debit Card", "Returned"): 35,
}
# Category colors use Okabe-Ito palette (first series is brand green #009E73)
category_colors = {
"Electronics": IMPRINT[0], # #009E73 (bluish green - brand)
"Clothing": IMPRINT[1], # #C475FD (vermillion)
"Home & Garden": IMPRINT[2], # #4467A3 (blue)
"Sports": IMPRINT[3], # #BD8233 (reddish purple)
}
# Secondary colors for middle dimensions - distinct but complementary
dimension_colors = {
"Distribution": {"Direct": "#7B68EE", "Wholesale": "#20B2AA", "Retail": "#FF69B4"},
"Payment": {"Credit Card": "#9370DB", "Debit Card": "#3CB371", "Digital Wallet": "#FF6347"},
"Outcome": {"Completed": "#32CD32", "Returned": "#FFA500", "Cancelled": "#DC143C"},
}
# Custom style for pygal with theme-adaptive colors
custom_style = Style(
background=PAGE_BG,
plot_background=PAGE_BG,
foreground=INK,
foreground_strong=INK,
foreground_subtle=INK_MUTED,
title_font_size=72,
)
# Create minimal chart for title rendering
chart = pygal.XY(
width=4800,
height=2700,
style=custom_style,
title="parallel-categories-basic · pygal · pyplots.ai",
show_legend=False,
show_x_guides=False,
show_y_guides=False,
show_x_labels=False,
show_y_labels=False,
dots_size=0,
stroke=False,
range=(0, 100),
xrange=(0, 100),
)
# Add empty data to avoid "No data" message
chart.add("", [(50, 50)])
# Render base SVG
base_svg = chart.render().decode("utf-8")
# SVG coordinate mapping
margin_left = 450
margin_right = 350
margin_top = 350
margin_bottom = 250
chart_width = 4800 - margin_left - margin_right
chart_height = 2700 - margin_top - margin_bottom
# Calculate positions for each dimension axis
n_dims = len(categories)
x_positions = [margin_left + i * chart_width / (n_dims - 1) for i in range(n_dims)]
bar_width = 120
gap_ratio = 0.05
# Calculate totals for each category in each dimension
dim_totals = {}
for dim_idx, dim_name in enumerate(categories):
dim_totals[dim_idx] = {}
for cat in dimension_values[dim_name]:
total = 0
for path, count in base_counts.items():
if path[dim_idx] == cat:
total += count
dim_totals[dim_idx][cat] = total
# Calculate node positions
node_positions = {}
for dim_idx, dim_name in enumerate(categories):
x = x_positions[dim_idx]
dim_total = sum(dim_totals[dim_idx].values())
total_gap = gap_ratio * chart_height
available_height = chart_height - total_gap
n_cats = len(dimension_values[dim_name])
gap_size = total_gap / max(1, n_cats - 1) if n_cats > 1 else 0
y_top = margin_top
for _cat_idx, cat in enumerate(dimension_values[dim_name]):
height = (dim_totals[dim_idx][cat] / dim_total) * available_height if dim_total > 0 else 0
y_bottom = y_top + height
node_positions[(dim_idx, cat)] = (y_top, y_bottom, x)
y_top = y_bottom + gap_size
# Build SVG elements
parallel_svg = '<g id="parallel-categories">'
# Draw nodes (category bars) for each dimension
for dim_idx, dim_name in enumerate(categories):
x = x_positions[dim_idx]
for cat in dimension_values[dim_name]:
y_top, y_bottom, _ = node_positions[(dim_idx, cat)]
height = y_bottom - y_top
if height < 1:
continue
# Color based on dimension
if dim_idx == 0:
fill_color = category_colors[cat]
else:
fill_color = dimension_colors[dim_name][cat]
parallel_svg += f'''
<rect x="{x - bar_width / 2:.0f}" y="{y_top:.0f}" width="{bar_width:.0f}" height="{height:.0f}"
fill="{fill_color}" stroke="white" stroke-width="2" opacity="0.9"/>'''
# Add dimension label at top
dim_name_escaped = dim_name.replace("&", "&")
parallel_svg += f'''
<text x="{x:.0f}" y="{margin_top - 60:.0f}" text-anchor="middle"
font-size="48" font-weight="bold" font-family="DejaVu Sans, sans-serif"
fill="{INK}">{dim_name_escaped}</text>'''
# Add category labels for each dimension with improved legibility
for dim_idx, dim_name in enumerate(categories):
x = x_positions[dim_idx]
for cat in dimension_values[dim_name]:
y_top, y_bottom, _ = node_positions[(dim_idx, cat)]
y_center = (y_top + y_bottom) / 2
height = y_bottom - y_top
# Position label based on dimension
if dim_idx == 0: # Left side - outside bar
label_x = x - bar_width / 2 - 20
anchor = "end"
elif dim_idx == n_dims - 1: # Right side - outside bar
label_x = x + bar_width / 2 + 20
anchor = "start"
else: # Middle dimensions - below the bar
label_x = x
anchor = "middle"
# Use consistent readable font size (minimum 32px for better legibility)
font_size = max(32, min(40, height * 0.35))
# Escape special characters
cat_escaped = cat.replace("&", "&")
if dim_idx in [0, n_dims - 1]:
# Side labels - next to bars
parallel_svg += f'''
<text x="{label_x:.0f}" y="{y_center:.0f}" text-anchor="{anchor}"
font-size="{font_size:.0f}" font-family="DejaVu Sans, sans-serif"
fill="{INK}" dominant-baseline="middle">{cat_escaped}</text>'''
else:
# Middle dimension labels - below each bar segment
label_y = y_bottom + 40
parallel_svg += f'''
<text x="{label_x:.0f}" y="{label_y:.0f}" text-anchor="{anchor}"
font-size="{font_size:.0f}" font-family="DejaVu Sans, sans-serif"
fill="{INK}">{cat_escaped}</text>'''
# Calculate flow offsets for drawing ribbons
source_offsets = {}
target_offsets = {}
for dim_idx in range(n_dims):
for cat in dimension_values[categories[dim_idx]]:
y_top, y_bottom, _ = node_positions[(dim_idx, cat)]
source_offsets[(dim_idx, cat)] = y_top
target_offsets[(dim_idx, cat)] = y_top
# Draw flows between consecutive dimensions
for dim_idx in range(n_dims - 1):
dim1_name = categories[dim_idx]
dim2_name = categories[dim_idx + 1]
x0 = x_positions[dim_idx]
x1 = x_positions[dim_idx + 1]
# Calculate total for normalization
dim1_total = sum(dim_totals[dim_idx].values())
dim2_total = sum(dim_totals[dim_idx + 1].values())
# Aggregate flows between consecutive dimensions
flow_aggregates = {}
for path, count in base_counts.items():
key = (path[dim_idx], path[dim_idx + 1], path[0])
if key not in flow_aggregates:
flow_aggregates[key] = 0
flow_aggregates[key] += count
# Sort flows for consistent drawing
sorted_flows = sorted(
flow_aggregates.items(),
key=lambda x: (dimension_values[dim1_name].index(x[0][0]), dimension_values[dim2_name].index(x[0][1])),
)
# Draw each flow
for (source_cat, target_cat, first_cat), flow_value in sorted_flows:
if flow_value <= 0:
continue
source_y_top, source_y_bottom, _ = node_positions[(dim_idx, source_cat)]
target_y_top, target_y_bottom, _ = node_positions[(dim_idx + 1, target_cat)]
source_dim_total = dim_totals[dim_idx][source_cat]
target_dim_total = dim_totals[dim_idx + 1][target_cat]
source_height = (
(flow_value / source_dim_total) * (source_y_bottom - source_y_top) if source_dim_total > 0 else 0
)
target_height = (
(flow_value / target_dim_total) * (target_y_bottom - target_y_top) if target_dim_total > 0 else 0
)
# Get current positions
y0_top = source_offsets[(dim_idx, source_cat)]
y0_bottom = y0_top + source_height
y1_top = target_offsets[(dim_idx + 1, target_cat)]
y1_bottom = y1_top + target_height
# Bezier curve control points
band_x0 = x0 + bar_width / 2
band_x1 = x1 - bar_width / 2
cx0 = band_x0 + 0.4 * (band_x1 - band_x0)
cx1 = band_x0 + 0.6 * (band_x1 - band_x0)
# Create path for the curved ribbon
path_d = (
f"M {band_x0:.0f},{y0_top:.0f} "
f"C {cx0:.0f},{y0_top:.0f} {cx1:.0f},{y1_top:.0f} {band_x1:.0f},{y1_top:.0f} "
f"L {band_x1:.0f},{y1_bottom:.0f} "
f"C {cx1:.0f},{y1_bottom:.0f} {cx0:.0f},{y0_bottom:.0f} {band_x0:.0f},{y0_bottom:.0f} "
f"Z"
)
# Color by first category
ribbon_color = category_colors[first_cat]
parallel_svg += f'''
<path d="{path_d}" fill="{ribbon_color}" fill-opacity="0.4" stroke="none"/>'''
# Update offsets
source_offsets[(dim_idx, source_cat)] = y0_bottom
target_offsets[(dim_idx + 1, target_cat)] = y1_bottom
# Add legend for categories
legend_x = margin_left
legend_y = chart_height + margin_top + 100
legend_spacing = 400
for idx, (cat, color) in enumerate(category_colors.items()):
lx = legend_x + idx * legend_spacing
cat_escaped = cat.replace("&", "&")
parallel_svg += f'''
<rect x="{lx:.0f}" y="{legend_y:.0f}" width="50" height="50" fill="{color}" stroke="none"/>
<text x="{lx + 70:.0f}" y="{legend_y + 38:.0f}" text-anchor="start"
font-size="40" font-family="DejaVu Sans, sans-serif" fill="{INK}">{cat_escaped}</text>'''
# Add subtitle
parallel_svg += f'''
<text x="2400" y="{chart_height + margin_top + 200:.0f}" text-anchor="middle"
font-size="36" font-style="italic" font-family="DejaVu Sans, sans-serif"
fill="{INK_SOFT}">Product Category Distribution Flows</text>'''
parallel_svg += "\n</g>"
# Insert elements before closing </svg> tag
svg_with_parallel = base_svg.replace("</svg>", f"{parallel_svg}\n</svg>")
# Save SVG
with open(f"plot-{THEME}.svg", "w") as f:
f.write(svg_with_parallel)
# Render to PNG
cairosvg.svg2png(bytestring=svg_with_parallel.encode("utf-8"), write_to=f"plot-{THEME}.png")
# Save HTML for interactive version
with open(f"plot-{THEME}.html", "w") as f:
f.write(f"""<!DOCTYPE html>
<html>
<head>
<title>parallel-categories-basic · pygal · pyplots.ai</title>
<style>
body {{ margin: 0; padding: 20px; background: {PAGE_BG}; font-family: sans-serif; }}
.container {{ max-width: 100%; margin: 0 auto; }}
object {{ width: 100%; height: auto; }}
</style>
</head>
<body>
<div class="container">
<object type="image/svg+xml" data="plot-{THEME}.svg">
Parallel categories diagram not supported
</object>
</div>
</body>
</html>""")
Part of Basic Parallel Categories Plot on anyplot.ai.