Multi-Series Radar Chart — Bokeh

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 Bokeh

Python source (Bokeh)

""" anyplot.ai
radar-multi: Multi-Series Radar Chart
Library: bokeh 3.9.0 | Python 3.13.13
Quality: 91/100 | Updated: 2026-05-07
"""

import os
import time
from pathlib import Path

import numpy as np
from bokeh.io import output_file, save
from bokeh.models import ColumnDataSource, LabelSet, Legend
from bokeh.plotting import figure
from selenium import webdriver
from selenium.webdriver.chrome.options import Options


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"

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

# Data - Product comparison across 6 attributes
categories = ["Performance", "Reliability", "Features", "Support", "Price Value", "Ease of Use"]
n_categories = len(categories)

# Three products to compare
products = {
    "Product A": [85, 90, 75, 80, 70, 88],
    "Product B": [70, 75, 95, 85, 80, 72],
    "Product C": [92, 65, 80, 70, 95, 78],
}

# Calculate angles for each axis (starting from top, going clockwise)
angles = np.linspace(0, 2 * np.pi, n_categories, endpoint=False)
angles = angles + np.pi / 2

# Create figure with square aspect for radar chart
p = figure(
    width=3600,
    height=3600,
    title="radar-multi · bokeh · anyplot.ai",
    x_range=(-145, 145),
    y_range=(-145, 145),
    tools="",
    toolbar_location=None,
)

p.background_fill_color = PAGE_BG
p.border_fill_color = PAGE_BG
p.outline_line_color = None

# Remove axes and grid
p.axis.visible = False
p.grid.visible = False

# Draw circular gridlines at 20, 40, 60, 80, 100
grid_values = [20, 40, 60, 80, 100]
for gv in grid_values:
    theta = np.linspace(0, 2 * np.pi, 100)
    x_grid = gv * np.cos(theta)
    y_grid = gv * np.sin(theta)
    p.line(x_grid, y_grid, line_color=INK_SOFT, line_width=2, line_alpha=0.4)

# Draw radial lines from center to each axis
for angle in angles:
    x_line = [0, 105 * np.cos(angle)]
    y_line = [0, 105 * np.sin(angle)]
    p.line(x_line, y_line, line_color=INK_SOFT, line_width=2, line_alpha=0.3)

# Add axis labels at the outer edge
label_radius = 125
label_x = [label_radius * np.cos(a) for a in angles]
label_y = [label_radius * np.sin(a) for a in angles]
label_source = ColumnDataSource(data={"x": label_x, "y": label_y, "text": categories})
labels = LabelSet(
    x="x",
    y="y",
    text="text",
    source=label_source,
    text_font_size="24pt",
    text_align="center",
    text_baseline="middle",
    text_color=INK,
    text_font_style="bold",
)
p.add_layout(labels)

# Add grid value labels on one axis (shifted for visibility)
for gv in grid_values:
    p.text(
        x=[gv * np.cos(angles[0]) + 10],
        y=[gv * np.sin(angles[0]) + 10],
        text=[str(gv)],
        text_font_size="20pt",
        text_color=INK_SOFT,
        text_alpha=0.9,
    )

# Plot each product series
legend_items = []
for idx, (product_name, values) in enumerate(products.items()):
    # Convert values to x, y coordinates
    x_vals = [v * np.cos(a) for v, a in zip(values, angles, strict=True)]
    y_vals = [v * np.sin(a) for v, a in zip(values, angles, strict=True)]

    # Close the polygon
    x_vals.append(x_vals[0])
    y_vals.append(y_vals[0])

    # Draw filled polygon
    fill_renderer = p.patch(
        x_vals,
        y_vals,
        fill_color=IMPRINT[idx],
        fill_alpha=0.25,
        line_color=IMPRINT[idx],
        line_width=4,
        line_alpha=0.9,
    )

    # Draw points at vertices
    p.scatter(x_vals[:-1], y_vals[:-1], size=22, fill_color=IMPRINT[idx], line_color=PAGE_BG, line_width=3, alpha=0.9)

    legend_items.append((product_name, [fill_renderer]))

# Add legend
legend = Legend(
    items=legend_items,
    location="top_right",
    label_text_font_size="22pt",
    glyph_height=40,
    glyph_width=40,
    spacing=20,
    padding=25,
    background_fill_color=PAGE_BG,
    background_fill_alpha=0.85,
    border_line_color=INK_SOFT,
    label_text_color=INK_SOFT,
)
p.add_layout(legend, "right")

# Style title
p.title.text_font_size = "32pt"
p.title.align = "center"
p.title.text_color = INK

# Save HTML
output_file(f"plot-{THEME}.html")
save(p)

# Screenshot with headless Chrome
W, H = 3600, 3600
opts = Options()
for arg in (
    "--headless=new",
    "--no-sandbox",
    "--disable-dev-shm-usage",
    "--disable-gpu",
    f"--window-size={W},{H}",
    "--hide-scrollbars",
):
    opts.add_argument(arg)
driver = webdriver.Chrome(options=opts)
driver.set_window_size(W, H)
driver.get(f"file://{Path(f'plot-{THEME}.html').resolve()}")
time.sleep(3)
driver.save_screenshot(f"plot-{THEME}.png")
driver.quit()

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

Other implementations