Skip to content

Commit d51d211

Browse files
committed
Add radial coordinate system
1 parent 3560758 commit d51d211

3 files changed

Lines changed: 161 additions & 0 deletions

File tree

plotnine/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
coord_fixed,
2222
coord_flip,
2323
coord_polar,
24+
coord_radial,
2425
coord_trans,
2526
)
2627
from .facets import (
@@ -291,6 +292,7 @@
291292
"coord_fixed",
292293
"coord_flip",
293294
"coord_polar",
295+
"coord_radial",
294296
"coord_trans",
295297
"element_blank",
296298
"element_line",

plotnine/coords/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from .coord_fixed import coord_equal, coord_fixed
77
from .coord_flip import coord_flip
88
from .coord_polar import coord_polar
9+
from .coord_radial import coord_radial
910
from .coord_trans import coord_trans
1011

1112
__all__ = (
@@ -14,5 +15,6 @@
1415
"coord_equal",
1516
"coord_flip",
1617
"coord_polar",
18+
"coord_radial",
1719
"coord_trans",
1820
)

plotnine/coords/coord_radial.py

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
5+
import numpy as np
6+
7+
from .coord_polar import coord_polar
8+
9+
if TYPE_CHECKING:
10+
import pandas as pd
11+
from matplotlib.axes import Axes
12+
from plotnine.iapi import panel_view
13+
14+
15+
class coord_radial(coord_polar):
16+
"""
17+
Radial coordinate system.
18+
19+
A modernised polar coordinate system that adds support for partial arcs,
20+
inner radius (donut/gauge charts), configurable radial-axis placement, and
21+
automatic rotation of the ``angle`` aesthetic to align with theta.
22+
23+
Inherits from :class:`coord_polar`; all standard geoms work without
24+
modification.
25+
26+
Parameters
27+
----------
28+
theta :
29+
Which variable maps to the angle axis, ``"x"`` (default) or ``"y"``.
30+
start :
31+
Starting angle in radians, measured clockwise from 12 o'clock.
32+
Default 0.
33+
end :
34+
Ending angle in radians, measured clockwise from 12 o'clock.
35+
``None`` (default) gives a full circle (``start + 2π * direction``).
36+
direction :
37+
``1`` = clockwise (default), ``-1`` = counter-clockwise.
38+
Only used when *end* is ``None``.
39+
expand :
40+
Add a small buffer around the data on the radius axis. Default ``True``.
41+
inner_radius :
42+
Size of the inner hole as a fraction of the outer radius, in
43+
``[0, 1)``. ``0`` (default) means no hole; ``0.3`` creates a 30 %
44+
donut hole, useful for gauge and donut charts.
45+
r_axis_inside :
46+
Where to place the radial (r) axis tick labels.
47+
48+
* ``None`` (default) — let Matplotlib decide (usually outside).
49+
* ``True`` — force inside, aligned just past the *start* angle.
50+
* ``False`` — force outside (Matplotlib default).
51+
* *float* — place at this theta angle in radians (clockwise from North).
52+
rotate_angle :
53+
If ``True``, automatically add the local theta angle (in degrees) to
54+
the ``angle`` aesthetic so that text or other rotated marks align with
55+
the spoke direction. Default ``False``.
56+
"""
57+
58+
def __init__(
59+
self,
60+
theta: str = "x",
61+
start: float = 0,
62+
end: float | None = None,
63+
direction: int = 1,
64+
expand: bool = True,
65+
inner_radius: float = 0,
66+
r_axis_inside: bool | float | None = None,
67+
rotate_angle: bool = False,
68+
) -> None:
69+
super().__init__(
70+
theta=theta,
71+
start=start,
72+
direction=direction,
73+
expand=expand,
74+
)
75+
self.end = end
76+
self.inner_radius = inner_radius
77+
self.r_axis_inside = r_axis_inside
78+
self.rotate_angle = rotate_angle
79+
80+
# ------------------------------------------------------------------
81+
# Helpers
82+
# ------------------------------------------------------------------
83+
84+
@property
85+
def _arc(self) -> float:
86+
"""Total arc in radians (signed: positive when going clockwise for direction=1)."""
87+
if self.end is not None:
88+
return self.end - self.start
89+
return self.direction * 2.0 * np.pi
90+
91+
def _to_radians(self, vals: np.ndarray) -> np.ndarray:
92+
"""Normalize theta values to [start, start + arc]."""
93+
t_min, t_max = self.params["theta_range"]
94+
denom = float(t_max) - float(t_min)
95+
if denom == 0:
96+
return np.zeros_like(vals, dtype=float)
97+
norm = (np.asarray(vals, dtype=float) - float(t_min)) / denom
98+
return self.start + norm * self._arc
99+
100+
# ------------------------------------------------------------------
101+
# Data transformation
102+
# ------------------------------------------------------------------
103+
104+
def transform(
105+
self,
106+
data: pd.DataFrame,
107+
panel_params: panel_view,
108+
munch: bool = False,
109+
) -> pd.DataFrame:
110+
data = super().transform(data, panel_params, munch=munch)
111+
# After super().transform(), data["x"] is always theta in radians.
112+
if self.rotate_angle and "angle" in data.columns and "x" in data.columns:
113+
data = data.copy()
114+
data["angle"] = data["angle"] + np.degrees(data["x"])
115+
return data
116+
117+
# ------------------------------------------------------------------
118+
# Draw decorations on PolarAxes
119+
# ------------------------------------------------------------------
120+
121+
def draw(self, axs: list[Axes]) -> None:
122+
"""Configure PolarAxes: arc limits, inner radius, axis placement."""
123+
super().draw(axs)
124+
125+
r_min, r_max = self.params.get("r_range", (0.0, 1.0))
126+
arc = self._arc
127+
128+
for ax in axs:
129+
# Restrict visible theta range for partial arcs.
130+
if self.end is not None:
131+
theta_lo = min(self.start, self.start + arc)
132+
theta_hi = max(self.start, self.start + arc)
133+
ax.set_thetalim(theta_lo, theta_hi)
134+
135+
# Inner radius: push the data away from the centre by setting a
136+
# virtual r-origin below r_min. Formula: solve
137+
# inner_radius = (r_min - r_origin) / (r_max - r_origin)
138+
if (
139+
self.inner_radius > 0
140+
and np.isfinite(r_min)
141+
and np.isfinite(r_max)
142+
and r_max > r_min
143+
and self.inner_radius < 1.0
144+
):
145+
r_origin = (r_min - self.inner_radius * r_max) / (
146+
1.0 - self.inner_radius
147+
)
148+
ax.set_rorigin(r_origin)
149+
150+
# Radial axis label placement.
151+
if self.r_axis_inside is not None:
152+
if isinstance(self.r_axis_inside, bool):
153+
if self.r_axis_inside:
154+
# Just inside the start angle keeps it out of the data.
155+
ax.set_rlabel_position(np.degrees(self.start) + 10)
156+
else:
157+
ax.set_rlabel_position(np.degrees(float(self.r_axis_inside)))

0 commit comments

Comments
 (0)