Polar Scatter Plot — Altair

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 Altair

Python source (Altair)

""" anyplot.ai
polar-scatter: Polar Scatter Plot
Library: altair 6.1.0 | Python 3.13.13
Quality: 92/100 | Updated: 2026-05-09
"""

import os

import altair as alt
import numpy as np
import pandas as pd


# 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 (positions 1, 2, 3 for three categories)
IMPRINT = ["#009E73", "#C475FD", "#4467A3"]

# Data - Synthetic wind measurements with prevailing directions
np.random.seed(42)
n_points = 120

# Create realistic wind data with prevailing directions (NW and SE)
angles_nw = np.random.normal(315, 25, n_points // 3)  # NW winds
angles_se = np.random.normal(135, 30, n_points // 3)  # SE winds
angles_other = np.random.uniform(0, 360, n_points - 2 * (n_points // 3))  # Other directions
angles_deg = np.concatenate([angles_nw, angles_se, angles_other]) % 360

# Wind speeds (m/s) - higher speeds for prevailing directions
speeds = np.zeros(n_points)
speeds[: n_points // 3] = np.random.gamma(4, 2.5, n_points // 3)  # NW - stronger
speeds[n_points // 3 : 2 * n_points // 3] = np.random.gamma(3, 2, n_points // 3)  # SE - moderate
speeds[2 * n_points // 3 :] = np.random.gamma(2, 1.5, n_points - 2 * (n_points // 3))  # Other - weaker
speeds = np.clip(speeds, 1, 20)

# Time of day categories
time_of_day = np.array(
    ["Morning"] * (n_points // 3) + ["Afternoon"] * (n_points // 3) + ["Evening"] * (n_points - 2 * (n_points // 3))
)

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

df = pd.DataFrame({"x": x, "y": y, "speed": speeds, "direction": angles_deg, "time_of_day": time_of_day})

# Create polar gridlines (circles)
max_radius = 20
radii = [5, 10, 15, 20]
circle_points = []
for r in radii:
    theta = np.linspace(0, 2 * np.pi, 100)
    for i, t in enumerate(theta):
        circle_points.append({"x": r * np.cos(t), "y": r * np.sin(t), "radius": r, "order": i})
circles_df = pd.DataFrame(circle_points)

# Create angular gridlines (spokes)
spoke_angles = [0, 45, 90, 135, 180, 225, 270, 315]
spoke_points = []
for angle in spoke_angles:
    rad = np.deg2rad(angle)
    spoke_points.append({"x": 0, "y": 0, "angle": angle, "group": angle, "order": 0})
    spoke_points.append(
        {"x": max_radius * np.cos(rad), "y": max_radius * np.sin(rad), "angle": angle, "group": angle, "order": 1}
    )
spokes_df = pd.DataFrame(spoke_points)

# Create angle labels
label_offset = max_radius * 1.12
angle_labels = []
direction_names = {
    0: "E (0°)",
    45: "NE (45°)",
    90: "N (90°)",
    135: "NW (135°)",
    180: "W (180°)",
    225: "SW (225°)",
    270: "S (270°)",
    315: "SE (315°)",
}
for angle, name in direction_names.items():
    rad = np.deg2rad(angle)
    angle_labels.append({"x": label_offset * np.cos(rad), "y": label_offset * np.sin(rad), "label": name})
labels_df = pd.DataFrame(angle_labels)

# Create radius labels
radius_labels = [{"x": r + 0.5, "y": 0.5, "label": f"{r} m/s"} for r in radii]
radius_labels_df = pd.DataFrame(radius_labels)

# Circular gridlines
grid_circles = (
    alt.Chart(circles_df)
    .mark_line(strokeWidth=1, opacity=0.10)
    .encode(
        x=alt.X("x:Q", axis=None),
        y=alt.Y("y:Q", axis=None),
        detail="radius:N",
        order="order:Q",
        color=alt.value(INK_SOFT),
    )
)

# Radial spokes
grid_spokes = (
    alt.Chart(spokes_df)
    .mark_line(strokeWidth=1, opacity=0.10)
    .encode(
        x=alt.X("x:Q", axis=None),
        y=alt.Y("y:Q", axis=None),
        detail="group:N",
        order="order:Q",
        color=alt.value(INK_SOFT),
    )
)

# Angle labels
angle_text = (
    alt.Chart(labels_df)
    .mark_text(fontSize=18, fontWeight="bold")
    .encode(x=alt.X("x:Q", axis=None), y=alt.Y("y:Q", axis=None), text="label:N", color=alt.value(INK_SOFT))
)

# Radius labels
radius_text = (
    alt.Chart(radius_labels_df)
    .mark_text(fontSize=14, align="left")
    .encode(x=alt.X("x:Q", axis=None), y=alt.Y("y:Q", axis=None), text="label:N", color=alt.value(INK_SOFT))
)

# Data points with Okabe-Ito colors
points = (
    alt.Chart(df)
    .mark_point(size=220, filled=True, opacity=0.8)
    .encode(
        x=alt.X("x:Q", axis=None, scale=alt.Scale(domain=[-25, 25])),
        y=alt.Y("y:Q", axis=None, scale=alt.Scale(domain=[-25, 25])),
        color=alt.Color(
            "time_of_day:N",
            scale=alt.Scale(domain=["Morning", "Afternoon", "Evening"], range=IMPRINT),
            title="Time of Day",
        ),
        tooltip=[
            alt.Tooltip("direction:Q", title="Direction (°)", format=".1f"),
            alt.Tooltip("speed:Q", title="Wind Speed (m/s)", format=".1f"),
            alt.Tooltip("time_of_day:N", title="Time of Day"),
        ],
    )
)

# Combine all layers with theme-adaptive styling
chart = (
    alt.layer(grid_circles, grid_spokes, angle_text, radius_text, points)
    .properties(
        width=1600,
        height=1600,
        background=PAGE_BG,
        title=alt.Title("polar-scatter · altair · anyplot.ai", fontSize=28, anchor="middle", color=INK),
    )
    .configure_view(strokeWidth=0, fill=PAGE_BG)
    .configure_legend(
        titleFontSize=22,
        labelFontSize=18,
        symbolSize=220,
        orient="right",
        fillColor=ELEVATED_BG,
        strokeColor=INK_SOFT,
        titleColor=INK,
        labelColor=INK_SOFT,
    )
    .interactive()
)

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

Part of Polar Scatter Plot on anyplot.ai.

Other implementations