Polar Scatter Plot — Bokeh

A scatter plot displayed in polar coordinates where data points are positioned using angle (theta) and radius (r) rather than Cartesian x and y coordinates. This visualization is particularly effective for cyclical or directional data where the angular component carries meaningful information.

Polar Scatter Plot rendered with Bokeh

Python source (Bokeh)

""" anyplot.ai
polar-scatter: Polar Scatter Plot
Library: bokeh 3.9.0 | Python 3.13.13
Quality: 93/100 | Updated: 2026-05-09
"""

import os
import time
from pathlib import Path

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


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

# Okabe-Ito palette (categorical)
IMPRINT = ["#009E73", "#C475FD", "#4467A3"]

# Data - Wind measurements with prevailing directions
np.random.seed(42)

n_points = 120

# Create realistic wind data with prevailing directions (NE and SW)
# Morning winds (prevailing from NE ~45°)
n_morning = 50
morning_angles = np.random.normal(45, 30, n_morning) % 360
morning_speeds = np.random.gamma(3, 3, n_morning) + 5

# Afternoon winds (prevailing from SW ~225°)
n_afternoon = 45
afternoon_angles = np.random.normal(225, 35, n_afternoon) % 360
afternoon_speeds = np.random.gamma(2.5, 4, n_afternoon) + 3

# Evening winds (variable)
n_evening = 25
evening_angles = np.random.uniform(0, 360, n_evening)
evening_speeds = np.random.gamma(2, 2, n_evening) + 2

# Combine data
angles_deg = np.concatenate([morning_angles, afternoon_angles, evening_angles])
speeds = np.concatenate([morning_speeds, afternoon_speeds, evening_speeds])
categories = ["Morning"] * n_morning + ["Afternoon"] * n_afternoon + ["Evening"] * n_evening

# Convert to radians for polar coordinates
angles_rad = np.radians(angles_deg)

# Convert polar to Cartesian for Bokeh (Bokeh doesn't have native polar support)
x = speeds * np.cos(angles_rad)
y = speeds * np.sin(angles_rad)

# Calculate max radius for gridlines
max_speed = np.ceil(np.max(speeds) / 5) * 5

# Create figure
p = figure(
    width=3600,
    height=3600,
    title="polar-scatter · bokeh · anyplot.ai",
    x_range=(-max_speed * 1.2, max_speed * 1.2),
    y_range=(-max_speed * 1.35, max_speed * 1.2),
    tools="",
    toolbar_location=None,
)

# Draw polar grid - radial circles
for r in np.arange(5, max_speed + 1, 5):
    theta_circle = np.linspace(0, 2 * np.pi, 100)
    circle_x = r * np.cos(theta_circle)
    circle_y = r * np.sin(theta_circle)
    p.line(circle_x, circle_y, line_color=INK_SOFT, line_width=2, line_alpha=0.15)
    # Add radius labels at 45 degrees
    label_x = r * np.cos(np.radians(45)) + 0.5
    label_y = r * np.sin(np.radians(45)) + 0.5
    p.text([label_x], [label_y], text=[f"{int(r)}"], text_font_size="20pt", text_color=INK_SOFT, text_alpha=0.8)

# Draw angular gridlines (spokes) at 30° intervals
for angle in range(0, 360, 30):
    rad = np.radians(angle)
    p.line(
        [0, max_speed * 1.05 * np.cos(rad)],
        [0, max_speed * 1.05 * np.sin(rad)],
        line_color=INK_SOFT,
        line_width=2,
        line_alpha=0.15,
    )

# Add cardinal direction labels
directions = {0: "E", 90: "N", 180: "W", 270: "S"}
for angle, label in directions.items():
    rad = np.radians(angle)
    label_r = max_speed * 1.1
    p.text(
        [label_r * np.cos(rad)],
        [label_r * np.sin(rad)],
        text=[label],
        text_font_size="24pt",
        text_font_style="bold",
        text_color=INK,
        text_align="center",
        text_baseline="middle",
    )

# Add intermediate direction labels
intermediate = {45: "NE", 135: "NW", 225: "SW", 315: "SE"}
for angle, label in intermediate.items():
    rad = np.radians(angle)
    label_r = max_speed * 1.1
    p.text(
        [label_r * np.cos(rad)],
        [label_r * np.sin(rad)],
        text=[label],
        text_font_size="20pt",
        text_color=INK_SOFT,
        text_align="center",
        text_baseline="middle",
    )

# Add label for radius axis
p.text(
    [0],
    [-max_speed * 1.25],
    text=["Wind Speed (m/s)"],
    text_font_size="22pt",
    text_color=INK_SOFT,
    text_align="center",
    text_baseline="top",
)

# Create scatter plot for each category with hover tooltips
legend_items = []
renderers = []
for i, cat in enumerate(["Morning", "Afternoon", "Evening"]):
    mask = np.array(categories) == cat
    source = ColumnDataSource(
        data={
            "x": x[mask],
            "y": y[mask],
            "angle": angles_deg[mask],
            "speed": speeds[mask],
            "category": [cat] * np.sum(mask),
        }
    )

    # Add hover tool for this renderer
    hover = HoverTool(tooltips=[("Direction", "@angle{0.0}°"), ("Speed", "@speed{0.0} m/s"), ("Category", "@category")])

    r = p.scatter("x", "y", source=source, size=24, color=IMPRINT[i], alpha=0.75, line_color=PAGE_BG, line_width=2)
    p.add_tools(hover)
    renderers.append(r)
    legend_items.append(LegendItem(label=cat, renderers=[r]))

# Add legend
legend = Legend(
    items=legend_items,
    location="top_right",
    label_text_font_size="20pt",
    spacing=15,
    glyph_height=28,
    glyph_width=28,
    background_fill_color=ELEVATED_BG,
    background_fill_alpha=0.9,
    border_line_color=INK_SOFT,
    padding=15,
    margin=15,
    border_line_width=2,
)
p.add_layout(legend, "right")

# Styling
p.title.text_font_size = "28pt"
p.title.text_color = INK
p.title.align = "center"
p.background_fill_color = PAGE_BG
p.border_fill_color = PAGE_BG
p.outline_line_color = INK_SOFT

# Hide axes (using polar grid instead)
p.xaxis.visible = False
p.yaxis.visible = False
p.xgrid.visible = False
p.ygrid.visible = False

# Save as 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 Polar Scatter Plot on anyplot.ai.

Other implementations