Multi-Series Radar Chart — lets-plot

A multi-series radar chart overlays multiple data polygons on shared axes radiating from a center point, enabling direct comparison across several entities or categories. Each series is rendered as a distinct colored polygon, making it easy to identify relative strengths and weaknesses at a glance. This visualization excels at comparative analysis where multiple subjects are evaluated across the same set of metrics.

Multi-Series Radar Chart rendered with lets-plot

Python source (lets-plot)

""" anyplot.ai
radar-multi: Multi-Series Radar Chart
Library: letsplot 4.9.0 | Python 3.13.13
Quality: 99/100 | Updated: 2026-05-07
"""

import math
import os

import pandas as pd
from lets_plot import (
    LetsPlot,
    aes,
    element_blank,
    element_rect,
    element_text,
    geom_line,
    geom_point,
    geom_polygon,
    geom_text,
    ggplot,
    ggsave,
    ggsize,
    labs,
    scale_color_manual,
    scale_fill_manual,
    scale_x_continuous,
    scale_y_continuous,
    theme,
)


LetsPlot.setup_html()

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"

IMPRINT = ["#009E73", "#C475FD", "#4467A3", "#BD8233"]

# Data - Smartphone comparison across 6 key attributes (4 products)
categories = ["Battery", "Camera", "Display", "Performance", "Storage", "Price Value"]
products = {
    "Galaxy S24": [85, 92, 90, 88, 75, 70],
    "iPhone 15": [75, 95, 88, 92, 70, 65],
    "Pixel 8": [80, 90, 82, 85, 65, 85],
    "OnePlus 12": [90, 78, 85, 90, 80, 90],
}

n = len(categories)

# Create angles for each category (evenly spaced, starting from top)
angles = [i * 2 * math.pi / n for i in range(n)]

# Build dataframe with cartesian coordinates for each product
data_rows = []
for product_name, values in products.items():
    for i, (cat, val, angle) in enumerate(zip(categories, values, angles, strict=True)):
        # Convert polar to cartesian (0 degrees at top, clockwise)
        x = val * math.cos(angle - math.pi / 2)
        y = val * math.sin(angle - math.pi / 2)
        data_rows.append({"category": cat, "value": val, "x": x, "y": y, "series": product_name, "order": i})

    # Close the polygon by repeating first point
    x = values[0] * math.cos(angles[0] - math.pi / 2)
    y = values[0] * math.sin(angles[0] - math.pi / 2)
    data_rows.append(
        {"category": categories[0], "value": values[0], "x": x, "y": y, "series": product_name, "order": n}
    )

df = pd.DataFrame(data_rows)

# Create gridlines data (circles at 20, 40, 60, 80, 100)
grid_rows = []
grid_values = [20, 40, 60, 80, 100]
grid_angles = [i * 2 * math.pi / 72 for i in range(73)]  # 73 points for smooth circles
for radius in grid_values:
    for angle in grid_angles:
        x = radius * math.cos(angle - math.pi / 2)
        y = radius * math.sin(angle - math.pi / 2)
        grid_rows.append({"x": x, "y": y, "radius": radius})

grid_df = pd.DataFrame(grid_rows)

# Create axis lines (spokes from center to edge)
spoke_rows = []
for i, angle in enumerate(angles):
    x = 105 * math.cos(angle - math.pi / 2)
    y = 105 * math.sin(angle - math.pi / 2)
    spoke_rows.append({"x": 0, "y": 0, "group": i})
    spoke_rows.append({"x": x, "y": y, "group": i})

spoke_df = pd.DataFrame(spoke_rows)

# Create axis labels (category names at outer edge)
label_rows = []
for cat, angle in zip(categories, angles, strict=True):
    x = 125 * math.cos(angle - math.pi / 2)
    y = 125 * math.sin(angle - math.pi / 2)
    label_rows.append({"label": cat, "x": x, "y": y})

label_df = pd.DataFrame(label_rows)

# Create grid value labels (scale indicators on first spoke)
value_label_rows = []
for val in grid_values:
    x = val * math.cos(-math.pi / 2) + 10  # Offset right for readability
    y = val * math.sin(-math.pi / 2)
    value_label_rows.append({"label": str(val), "x": x, "y": y})

value_label_df = pd.DataFrame(value_label_rows)

# Build the plot
plot = (
    ggplot()
    # Gridlines (concentric circles)
    + geom_line(aes(x="x", y="y", group="radius"), data=grid_df, color=INK_SOFT, size=0.6, alpha=0.2, linetype="dashed")
    # Spokes (radial lines)
    + geom_line(aes(x="x", y="y", group="group"), data=spoke_df, color=INK_SOFT, size=0.6, alpha=0.3)
    # Filled polygons for each series (lower alpha for 4 overlapping series)
    + geom_polygon(aes(x="x", y="y", fill="series", group="series"), data=df, alpha=0.2)
    # Lines connecting points
    + geom_line(aes(x="x", y="y", color="series", group="series"), data=df, size=2.5)
    # Points at each vertex (exclude the closing point to avoid double dot)
    + geom_point(aes(x="x", y="y", color="series"), data=df[df["order"] < n], size=7)
    # Custom color palette (Okabe-Ito)
    + scale_fill_manual(values=IMPRINT)
    + scale_color_manual(values=IMPRINT)
    # Axis limits for square plot
    + scale_x_continuous(limits=(-160, 160))
    + scale_y_continuous(limits=(-160, 160))
    # Title and legend
    + labs(title="Smartphone Comparison · radar-multi · letsplot · anyplot.ai", fill="Product", color="Product")
    # Square format for symmetric radar chart
    + ggsize(1200, 1200)
    + theme(
        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        panel_background=element_rect(fill=PAGE_BG),
        plot_title=element_text(size=22, color=INK),
        legend_title=element_text(size=18, color=INK),
        legend_text=element_text(size=16, color=INK_SOFT),
        legend_background=element_rect(fill=ELEVATED_BG, color=INK_SOFT),
        legend_position="right",
        axis_title=element_blank(),
        axis_text=element_blank(),
        axis_ticks=element_blank(),
        axis_line=element_blank(),
        panel_grid=element_blank(),
    )
)

# Add category labels as text
plot = plot + geom_text(aes(x="x", y="y", label="label"), data=label_df, size=16, color=INK)

# Add grid value labels
plot = plot + geom_text(aes(x="x", y="y", label="label"), data=value_label_df, size=14, color=INK_SOFT)

# Save outputs
ggsave(plot, f"plot-{THEME}.png", path=".", scale=3)
ggsave(plot, f"plot-{THEME}.html", path=".")

Part of Multi-Series Radar Chart on anyplot.ai.

Other implementations