Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
c68a347
Add coord_polar coordinate system
iangow May 1, 2026
c4ecd50
Rewrite coord_polar to use Matplotlib's native PolarAxes
iangow May 1, 2026
8ca6379
Add radial coordinate system
iangow May 2, 2026
14c26f6
Extend coord_radial: partial-arc fixes, thetalim/rlim, theta axis labels
iangow May 2, 2026
ab2d383
Add theta_labels parameter to coord_radial for full-circle theta axis…
iangow May 2, 2026
bd39a85
Add pad to theta tick labels to clear the outer circle spine
iangow May 2, 2026
1fdb523
Expose theta_label_pad parameter on coord_radial
iangow May 2, 2026
686d9e0
Fix theta_label_pad being overwritten by facet margin padding
iangow May 2, 2026
8e57fe3
Fix theta breaks with negative angles causing partial-arc regression
iangow May 3, 2026
56e0a8a
Fix start-angle rotation hiding bars for full-circle plots
iangow May 3, 2026
7b28681
Hide polar spine when panel_border=element_blank()
iangow May 3, 2026
24b48da
Set clip_on=False on geom_text artists in PolarAxes
iangow May 3, 2026
ac631b6
Transform polar segment endpoints
iangow May 3, 2026
5b44427
Document polar coordinates
iangow May 4, 2026
888b59f
Add coord polar and radial coverage tests
iangow May 5, 2026
ecc76b6
Fix polar coordinate type checking
iangow May 5, 2026
13223cf
Remove coord polar notebook
iangow May 5, 2026
87422c5
Move projection selection to coord classes
iangow May 27, 2026
7a65e28
Move axes setup to coord classes
iangow May 27, 2026
2b3e1cc
Move polar border blanking to themeable
iangow May 27, 2026
c493b3d
Document custom themeable extension path
iangow May 27, 2026
b2092d0
Added `guide_axis_theta()` (initial pass).
iangow May 27, 2026
d62df76
fix guide_axis_theta: use 'auto' mode so angle is relative to tangent
iangow May 27, 2026
cf8eb8f
Document guide_axis_theta semantics
iangow May 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions doc/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,5 @@ objects.json
objects.txt
objects.inv
gallery/thumbnails

**/*.quarto_ipynb
2 changes: 2 additions & 0 deletions doc/_quartodoc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -535,6 +535,8 @@ quartodoc:
- coord_equal
- coord_fixed
- coord_flip
- coord_polar
- coord_radial
- coord_trans

- title: Composing Plots
Expand Down
6 changes: 6 additions & 0 deletions plotnine/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
coord_equal,
coord_fixed,
coord_flip,
coord_polar,
coord_radial,
coord_trans,
)
from .facets import (
Expand Down Expand Up @@ -88,6 +90,7 @@
save_as_pdf_pages,
)
from .guides import (
guide_axis_theta,
guide_colorbar,
guide_colourbar,
guide_legend,
Expand Down Expand Up @@ -289,6 +292,8 @@
"coord_equal",
"coord_fixed",
"coord_flip",
"coord_polar",
"coord_radial",
"coord_trans",
"element_blank",
"element_line",
Expand Down Expand Up @@ -345,6 +350,7 @@
"ggplot",
"ggsave",
"ggtitle",
"guide_axis_theta",
"guide_colorbar",
"guide_colourbar",
"guide_legend",
Expand Down
4 changes: 4 additions & 0 deletions plotnine/coords/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@
from .coord_cartesian import coord_cartesian
from .coord_fixed import coord_equal, coord_fixed
from .coord_flip import coord_flip
from .coord_polar import coord_polar
from .coord_radial import coord_radial
from .coord_trans import coord_trans

__all__ = (
"coord_cartesian",
"coord_fixed",
"coord_equal",
"coord_flip",
"coord_polar",
"coord_radial",
"coord_trans",
)
59 changes: 58 additions & 1 deletion plotnine/coords/coord.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@
if typing.TYPE_CHECKING:
from typing import Any

from matplotlib.axes import Axes
import numpy.typing as npt
import pandas as pd

from plotnine import ggplot
from plotnine import ggplot, theme
from plotnine.iapi import labels_view, panel_view
from plotnine.scales.scale import scale
from plotnine.typing import (
Expand All @@ -28,6 +29,9 @@ class coord:
Base class for all coordinate systems
"""

# Matplotlib projection name to use when creating panel axes.
_projection: str | None = None

# If the coordinate system is linear
is_linear = False

Expand Down Expand Up @@ -104,6 +108,59 @@ def aspect(self, panel_params: panel_view) -> float | None:
"""
return None

def draw(self, axs: list) -> None:
"""
Draw coordinate-system decorations onto each panel axes.

Called after all layers are drawn. Subclasses override this to
add elements such as polar grid lines.
"""

def setup_ax(
self, ax: Axes, panel_params: panel_view, theme: theme
) -> None:
"""
Set limits, breaks and labels for one panel axes.

Subclasses can override this to customize axes setup, or call
`super().setup_ax(...)` and add coordinate-specific behavior.
"""
from .._mpl.ticker import MyFixedFormatter

def _inf_to_none(
t: tuple[float, float],
) -> tuple[float | None, float | None]:
"""
Replace infinities with None
"""
a = t[0] if np.isfinite(t[0]) else None
b = t[1] if np.isfinite(t[1]) else None
return (a, b)

# limits
ax.set_xlim(*_inf_to_none(panel_params.x.range))
ax.set_ylim(*_inf_to_none(panel_params.y.range))

# breaks, labels
ax.set_xticks(panel_params.x.breaks, panel_params.x.labels)
ax.set_yticks(panel_params.y.breaks, panel_params.y.labels)

# minor breaks
ax.set_xticks(panel_params.x.minor_breaks, minor=True)
ax.set_yticks(panel_params.y.minor_breaks, minor=True)

# When you manually set the tick labels MPL changes the locator
# so that it no longer reports the x & y positions
# Fixes https://github.com/has2k1/plotnine/issues/187
ax.xaxis.set_major_formatter(MyFixedFormatter(panel_params.x.labels))
ax.yaxis.set_major_formatter(MyFixedFormatter(panel_params.y.labels))

pad_x = theme.get_margin("axis_text_x").pt.t
pad_y = theme.get_margin("axis_text_y").pt.r

ax.tick_params(axis="x", which="major", pad=pad_x)
ax.tick_params(axis="y", which="major", pad=pad_y)

Comment thread
has2k1 marked this conversation as resolved.
def labels(self, cur_labels: labels_view) -> labels_view:
"""
Modify labels
Expand Down
247 changes: 247 additions & 0 deletions plotnine/coords/coord_polar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
from __future__ import annotations

from dataclasses import replace
from typing import TYPE_CHECKING, cast

import numpy as np

from ..iapi import panel_ranges
from .coord import coord, dist_euclidean

if TYPE_CHECKING:
import pandas as pd
from matplotlib.axes import Axes
from matplotlib.projections.polar import PolarAxes

from plotnine.iapi import panel_view
from plotnine.scales.scale import scale


class coord_polar(coord):
"""
Polar coordinate system

`coord_polar` maps one position aesthetic to the angle and the other
to the radius. It is commonly used for pie charts, which are stacked
bar charts in polar coordinates.

Parameters
----------
theta :
Which variable maps to the angle axis, ``"x"`` (default) or ``"y"``.
start :
Starting angle in radians, measured clockwise from 12 o'clock
(i.e. from the positive-y axis). Default 0.
direction :
``1`` = clockwise (default), ``-1`` = counter-clockwise.
expand :
Add a small buffer around the data on the radius axis.
Default ``True``.

Notes
-----
Unlike ggplot2, plotnine coordinate systems do not currently expose a
``clip`` argument.

For partial arcs, donut charts, and theta/radius limits, use
``coord_radial``.

Examples
--------
A pie chart is a stacked bar chart with the y position mapped to angle.

```python
import pandas as pd
from plotnine import aes, coord_polar, geom_col, ggplot

df = pd.DataFrame({
"x": [1, 1, 1],
"y": [2, 3, 5],
"group": ["a", "b", "c"],
})

ggplot(df, aes("x", "y", fill="group")) + geom_col() + coord_polar("y")
```
"""

is_linear = False
_projection = "polar"

def __init__(
self,
theta: str = "x",
start: float = 0,
direction: int = 1,
expand: bool = True,
) -> None:
self.theta = theta
self.start = start
self.direction = direction
self.expand = expand
self.params: dict = {}

# ------------------------------------------------------------------
# Panel params
# ------------------------------------------------------------------

def setup_panel_params(self, scale_x: scale, scale_y: scale) -> panel_view:
from .coord_cartesian import coord_cartesian

# Theta fills exactly one full revolution — no expansion on that axis.
# R uses the caller-controlled expand flag.
pv_no_exp = coord_cartesian(expand=False).setup_panel_params(
scale_x, scale_y
)
pv_exp = coord_cartesian(expand=self.expand).setup_panel_params(
scale_x, scale_y
)

if self.theta == "x":
theta_range = pv_no_exp.x.range
r_sv = pv_exp.y
else:
theta_range = pv_no_exp.y.range
r_sv = pv_exp.x

self.params["theta_range"] = theta_range
self.params["r_range"] = r_sv.range

empty = np.array([], dtype=float)

# x → theta axis: data ticks are in original units (not radians), so
# suppress them. Limits span [start, start+2π] so that bars rotated
# by a non-zero start angle stay within the displayed theta range.
theta_start = float(self.start)
new_x = replace(
pv_exp.x,
limits=(theta_start, theta_start + 2 * np.pi),
range=(theta_start, theta_start + 2 * np.pi),
breaks=[],
minor_breaks=empty,
labels=[],
)

# y → r axis: use the scale for the r dimension with its natural
# breaks.
new_y = replace(r_sv)

return replace(pv_exp, x=new_x, y=new_y)

# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------

def _to_radians(self, vals: np.ndarray) -> np.ndarray:
"""Normalise data-space theta values to [start, start + 2π]."""
t_min, t_max = self.params["theta_range"]
denom = float(t_max) - float(t_min)
if denom == 0:
return np.zeros_like(vals, dtype=float)
norm = (np.asarray(vals, dtype=float) - float(t_min)) / denom
return self.start + self.direction * norm * 2.0 * np.pi

# ------------------------------------------------------------------
# Data transformation
# ------------------------------------------------------------------

def transform(
self,
data: pd.DataFrame,
panel_params: panel_view,
munch: bool = False,
) -> pd.DataFrame:
# Munch first (in original data space) so curved edges get enough
# interpolation points before we convert theta → radians.
if munch:
data = self.munch(data, panel_params)

if self.theta == "x":
theta_col, r_col = "x", "y"
theta_end_col, r_end_col = "xend", "yend"
else:
theta_col, r_col = "y", "x"
theta_end_col, r_end_col = "yend", "xend"

if theta_col not in data.columns or r_col not in data.columns:
return data

data = data.copy()
data[theta_col] = self._to_radians(data[theta_col].to_numpy())
has_endpoints = (
theta_end_col in data.columns and r_end_col in data.columns
)
if has_endpoints:
data[theta_end_col] = self._to_radians(
data[theta_end_col].to_numpy()
)

# PolarAxes always expects x = theta (radians) and y = r.
# When theta = "y" we need to swap the columns.
if self.theta == "y":
data["x"], data["y"] = data["y"].copy(), data["x"].copy()
if has_endpoints:
data["xend"], data["yend"] = (
data["yend"].copy(),
data["xend"].copy(),
)

return data

# ------------------------------------------------------------------
# Distance (used by munch, called before transform)
# ------------------------------------------------------------------

def distance(
self,
x: pd.Series,
y: pd.Series,
panel_params: panel_view,
) -> np.ndarray:
# Normalise theta and r to [0, 1] then compute Euclidean distance.
t_min, t_max = self.params["theta_range"]
r_min, r_max = self.params["r_range"]
t_denom = float(t_max - t_min) or 1.0
r_denom = float(r_max - r_min) or 1.0

if self.theta == "x":
theta_vals = np.asarray(x, dtype=float)
r_vals = np.asarray(y, dtype=float)
else:
theta_vals = np.asarray(y, dtype=float)
r_vals = np.asarray(x, dtype=float)

theta_norm = (theta_vals - float(t_min)) / t_denom
r_norm = (r_vals - float(r_min)) / r_denom
return dist_euclidean(theta_norm, r_norm)

def backtransform_range(self, panel_params: panel_view) -> panel_ranges:
t_range = tuple(self.params["theta_range"])
r_range = tuple(self.params["r_range"])
if self.theta == "x":
return panel_ranges(x=t_range, y=r_range)
return panel_ranges(x=r_range, y=t_range)

# ------------------------------------------------------------------
# Draw decorations on PolarAxes
# ------------------------------------------------------------------

def draw(self, axs: list[Axes]) -> None:
"""Configure each PolarAxes: zero location, direction, r limits."""
r_min, r_max = self.params.get("r_range", (0.0, 1.0))

# Matplotlib PolarAxes theta_direction: -1 = clockwise, 1 = counter-CW.
mpl_direction = -1 if self.direction == 1 else 1

for ax in axs:
polar_ax = cast("PolarAxes", ax)
polar_ax.set_theta_zero_location("N") # 12 o'clock = 0
polar_ax.set_theta_direction(mpl_direction)
if np.isfinite(r_min) and np.isfinite(r_max) and r_min != r_max:
polar_ax.set_rlim(float(r_min), float(r_max))

# ------------------------------------------------------------------
# Misc
# ------------------------------------------------------------------

def aspect(self, panel_params: panel_view) -> float:
return 1.0
Loading