Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 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 @@ -289,6 +291,8 @@
"coord_equal",
"coord_fixed",
"coord_flip",
"coord_polar",
"coord_radial",
"coord_trans",
"element_blank",
"element_line",
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",
)
16 changes: 16 additions & 0 deletions plotnine/coords/coord.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,22 @@ 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 post_setup_ax(self, ax: Any) -> None:
"""
Hook called for each axes after set_limits_breaks_and_labels.

Override in subclasses to apply per-axes settings that must
run after the facet has set tick positions and label padding.
"""

def labels(self, cur_labels: labels_view) -> labels_view:
"""
Modify labels
Expand Down
246 changes: 246 additions & 0 deletions plotnine/coords/coord_polar.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
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

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
Loading