Multi-Series Radar Chart — Altair

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 Altair

Renders

Python source (Altair)

""" anyplot.ai
radar-multi: Multi-Series Radar Chart
Library: altair 6.2.2 | Python 3.13.15
Quality: 90/100 | Updated: 2026-08-17
"""

import importlib.util
import os
import sys

import numpy as np
import pandas as pd
from PIL import Image


# Explicitly import altair from site-packages to avoid shadowing
spec = importlib.util.find_spec("altair")
if spec and spec.origin and "site-packages" in spec.origin:
    alt = importlib.util.module_from_spec(spec)
    sys.modules["altair"] = alt
    spec.loader.exec_module(alt)
else:
    # Fallback: remove the directory containing this script from path
    script_dir = os.path.dirname(os.path.abspath(__file__))
    sys.path[:] = [p for p in sys.path if os.path.abspath(p) != script_dir]
    import altair as alt


# 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"

# Imprint palette (first series ALWAYS #009E73)
IMPRINT = ["#009E73", "#C475FD", "#4467A3"]

# Data: Product comparison across key attributes
categories = ["Price", "Quality", "Durability", "Support", "Features", "Design"]
n_categories = len(categories)

data = {
    "Product A": [85, 70, 90, 65, 80, 75],
    "Product B": [60, 85, 75, 90, 70, 80],
    "Product C": [75, 65, 80, 70, 95, 85],
}

# Calculate angles for each axis (in radians) - start from top
angles = [np.pi / 2 - i * 2 * np.pi / n_categories for i in range(n_categories)]

# Build records for each series
records = []
for series_name, values in data.items():
    for i, (cat, val, angle) in enumerate(zip(categories, values, angles, strict=True)):
        # Convert polar to cartesian for plotting
        x = val * np.cos(angle)
        y = val * np.sin(angle)
        records.append(
            {"series": series_name, "category": cat, "value": val, "angle": angle, "x": x, "y": y, "order": i}
        )
    # Close the polygon by adding the first point again
    first_val = values[0]
    first_angle = angles[0]
    records.append(
        {
            "series": series_name,
            "category": categories[0],
            "value": first_val,
            "angle": first_angle,
            "x": first_val * np.cos(first_angle),
            "y": first_val * np.sin(first_angle),
            "order": n_categories,
        }
    )

df = pd.DataFrame(records)

# Create gridlines (hexagonal matching the axes)
grid_records = []
for r in [20, 40, 60, 80, 100]:
    for i, angle in enumerate(angles):
        grid_records.append({"radius": r, "x": r * np.cos(angle), "y": r * np.sin(angle), "order": i})
    # Close the hexagon
    grid_records.append({"radius": r, "x": r * np.cos(angles[0]), "y": r * np.sin(angles[0]), "order": n_categories})
grid_df = pd.DataFrame(grid_records)

# Create axis lines (spokes from center to edge)
spoke_records = []
for i, (cat, angle) in enumerate(zip(categories, angles, strict=True)):
    spoke_records.append({"category": cat, "x": 0, "y": 0, "order": 0, "spoke_id": i})
    spoke_records.append(
        {"category": cat, "x": 105 * np.cos(angle), "y": 105 * np.sin(angle), "order": 1, "spoke_id": i}
    )
spoke_df = pd.DataFrame(spoke_records)

# Create axis labels (positioned beyond the outer gridline)
label_records = []
for cat, angle in zip(categories, angles, strict=True):
    label_x = 125 * np.cos(angle)
    label_y = 125 * np.sin(angle)
    label_records.append({"category": cat, "x": label_x, "y": label_y})
label_df = pd.DataFrame(label_records)

# Create grid value labels on all spokes
value_label_records = []
for r in [20, 40, 60, 80, 100]:
    for angle in angles:
        x = r * np.cos(angle) + 8
        y = r * np.sin(angle) + 2
        value_label_records.append({"value": str(r), "x": x, "y": y})
value_label_df = pd.DataFrame(value_label_records)

# Series list and colors
series_list = ["Product A", "Product B", "Product C"]
color_scale = alt.Scale(domain=series_list, range=IMPRINT)

# Domain for axes, sized to the hexagon's own geometry (not a generic square):
# label radius 125 reaches the full radius only at the top/bottom vertices
# (Price/Support); the left/right vertices (Quality/Durability/Features/
# Design, at +-30 deg off horizontal) only reach 125*cos(30deg). Deriving
# separate x/y half-ranges from that geometry (plus a fixed text buffer)
# keeps the margin tight and the view free of the dead space a flat +-160
# square domain would leave on the hexagon's shorter horizontal axis.
LABEL_R = 125
BUFFER = 18
x_half = LABEL_R * np.cos(np.pi / 6) + BUFFER
y_half = LABEL_R + BUFFER
axis_domain_x = [-x_half, x_half]
axis_domain_y = [-y_half, y_half]

# Chart dimensions — square inner view (see prompts/library/altair.md "Canvas"),
# aspect-matched to x_half:y_half so the hexagon renders undistorted.
chart_width = 480
chart_height = round(chart_width * y_half / x_half)

# Base encoding for x and y
x_enc = alt.X("x:Q", scale=alt.Scale(domain=axis_domain_x), axis=None)
y_enc = alt.Y("y:Q", scale=alt.Scale(domain=axis_domain_y), axis=None)

# Legend-bound selection: click a series to isolate it, click again to
# release. A distinctly altair/vega-lite interaction — not reproducible in
# a static PNG library — that shows up in the saved interactive HTML.
legend_selection = alt.selection_point(fields=["series"], bind="legend")
fill_opacity = alt.condition(legend_selection, alt.value(0.25), alt.value(0.05))
stroke_opacity = alt.condition(legend_selection, alt.value(0.9), alt.value(0.15))
point_opacity = alt.condition(legend_selection, alt.value(0.9), alt.value(0.15))

# Grid hexagons
grid_lines = (
    alt.Chart(grid_df)
    .mark_line(strokeWidth=1.5, opacity=0.15)
    .encode(x=x_enc, y=y_enc, detail="radius:N", order="order:Q", stroke=alt.value(INK_SOFT))
)

# Spokes (axis lines)
spokes = (
    alt.Chart(spoke_df)
    .mark_line(strokeWidth=1.5, opacity=0.25)
    .encode(x=x_enc, y=y_enc, detail="spoke_id:N", order="order:Q", stroke=alt.value(INK_SOFT))
)

# Axis labels
labels = (
    alt.Chart(label_df)
    .mark_text(fontSize=13, fontWeight="bold")
    .encode(x="x:Q", y="y:Q", text="category:N", color=alt.value(INK))
)

# Grid value labels
value_labels = (
    alt.Chart(value_label_df)
    .mark_text(fontSize=10, align="left", baseline="middle")
    .encode(x="x:Q", y="y:Q", text="value:N", color=alt.value(INK_SOFT))
)

# Create filled polygons for each series. `mark_area()` fills toward an
# implicit baseline (it is designed for y=f(x) functions), so feeding it a
# closed, non-monotonic radar-polygon path produces spurious fill spikes. A
# `mark_line` with `interpolate="linear-closed"` instead closes the path as
# a true polygon and fills it directly -- the standard Vega-Lite technique
# for radar/spider charts.
fill_layers = []
for series_name, fill_color in zip(series_list, IMPRINT, strict=True):
    series_df = df[df["series"] == series_name].copy()

    fill_layer = (
        alt.Chart(series_df)
        .mark_line(interpolate="linear-closed", fill=fill_color, fillOpacity=0.25, strokeWidth=0)
        .encode(x=x_enc, y=y_enc, opacity=fill_opacity, order="order:Q")
    )
    fill_layers.append(fill_layer)

# Polygon outlines
polygon_outline = (
    alt.Chart(df)
    .mark_line(strokeWidth=3.5)
    .encode(
        x=x_enc,
        y=y_enc,
        color=alt.Color(
            "series:N",
            scale=color_scale,
            legend=alt.Legend(
                title="Series",
                titleFontSize=10,
                labelFontSize=10,
                orient="right",
                offset=10,
                symbolSize=120,
                symbolStrokeWidth=2,
                fillColor=ELEVATED_BG,
                strokeColor=INK_SOFT,
                labelColor=INK_SOFT,
                titleColor=INK,
            ),
        ),
        opacity=stroke_opacity,
        detail="series:N",
        order="order:Q",
    )
)

# Data points (exclude the closing point)
points_df = df[df["order"] < n_categories].copy()
points = (
    alt.Chart(points_df)
    .mark_circle(size=160)
    .encode(
        x=x_enc,
        y=y_enc,
        color=alt.Color("series:N", scale=color_scale, legend=None),
        opacity=point_opacity,
        tooltip=["series:N", "category:N", "value:Q"],
    )
)

# Combine all layers
all_layers = [grid_lines, spokes] + fill_layers + [polygon_outline, points, labels, value_labels]

title_text = "radar-multi · python · altair · anyplot.ai"

chart = (
    alt.layer(*all_layers)
    .add_params(legend_selection)
    .properties(
        width=chart_width,
        height=chart_height,
        background=PAGE_BG,
        title=alt.Title(title_text, fontSize=16, anchor="middle", offset=20, color=INK),
    )
    .configure_view(strokeWidth=0, fill=PAGE_BG)
    .configure_legend(strokeColor=INK_SOFT, padding=15, labelColor=INK_SOFT, titleColor=INK)
)

# Save as PNG and HTML with theme suffix
chart.save(f"plot-{THEME}.png", scale_factor=4.0)

# PAD-only to the exact canonical target — never crop (see
# prompts/library/altair.md "Canvas — hard rule, no deviation").
TW, TH = 2400, 2400
_img = Image.open(f"plot-{THEME}.png").convert("RGB")
_w, _h = _img.size
if _w > TW or _h > TH:
    raise SystemExit(
        f"altair vl-convert produced {_w}x{_h}, exceeds target {TW}x{TH}. Shrink chart dims and re-render."
    )
if _w < TW or _h < TH:
    _canvas = Image.new("RGB", (TW, TH), PAGE_BG)
    _canvas.paste(_img, ((TW - _w) // 2, (TH - _h) // 2))
    _canvas.save(f"plot-{THEME}.png")

chart.save(f"plot-{THEME}.html")

Retrieve this implementation

Runnable source as JSON, for any HTTP client: https://api.anyplot.ai/specs/radar-multi/altair/code. Any spec id and library id listed in llms-full.txt fit the same URL shape; every URL below is complete and callable.

{
  "spec_id": "radar-multi",
  "language": "python",
  "library": "altair",
  "page": "https://anyplot.ai/radar-multi/python/altair",
  "hub": "https://anyplot.ai/radar-multi",
  "code_json": "https://api.anyplot.ai/specs/radar-multi/altair/code",
  "spec_json": "https://api.anyplot.ai/specs/radar-multi",
  "render_light_png": "https://storage.googleapis.com/anyplot-images/plots/radar-multi/python/altair/plot-light.png",
  "render_dark_png": "https://storage.googleapis.com/anyplot-images/plots/radar-multi/python/altair/plot-dark.png",
  "interactive_light_html": "https://storage.googleapis.com/anyplot-images/plots/radar-multi/python/altair/plot-light.html",
  "interactive_dark_html": "https://storage.googleapis.com/anyplot-images/plots/radar-multi/python/altair/plot-dark.html",
  "quality_score": 90.0,
  "license": "MIT",
  "guide": "https://anyplot.ai/llms.txt"
}

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

Other implementations