Skip to content

Commit 731e5cc

Browse files
committed
Document polar coordinates
1 parent 849820c commit 731e5cc

4 files changed

Lines changed: 126 additions & 28 deletions

File tree

doc/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,5 @@ objects.json
1313
objects.txt
1414
objects.inv
1515
gallery/thumbnails
16+
17+
**/*.quarto_ipynb

doc/_quartodoc.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,8 @@ quartodoc:
535535
- coord_equal
536536
- coord_fixed
537537
- coord_flip
538+
- coord_polar
539+
- coord_radial
538540
- coord_trans
539541

540542
- title: Composing Plots

plotnine/coords/coord_polar.py

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,18 @@
1111
if TYPE_CHECKING:
1212
import pandas as pd
1313
from matplotlib.axes import Axes
14+
1415
from plotnine.iapi import panel_view
1516
from plotnine.scales.scale import scale
1617

1718

1819
class coord_polar(coord):
1920
"""
20-
Polar coordinate system.
21+
Polar coordinate system
2122
22-
Uses Matplotlib's native ``PolarAxes`` so every standard geom
23-
(including bar → pie/bullseye) renders correctly without manual
24-
Cartesian conversion. Concentric-circle and radial-spoke grid
25-
lines are drawn automatically by Matplotlib.
23+
`coord_polar` maps one position aesthetic to the angle and the other
24+
to the radius. It is commonly used for pie charts, which are stacked
25+
bar charts in polar coordinates.
2626
2727
Parameters
2828
----------
@@ -34,7 +34,33 @@ class coord_polar(coord):
3434
direction :
3535
``1`` = clockwise (default), ``-1`` = counter-clockwise.
3636
expand :
37-
Add a small buffer around the data on the radius axis. Default ``True``.
37+
Add a small buffer around the data on the radius axis.
38+
Default ``True``.
39+
40+
Notes
41+
-----
42+
Unlike ggplot2, plotnine coordinate systems do not currently expose a
43+
``clip`` argument.
44+
45+
For partial arcs, donut charts, and theta/radius limits, use
46+
``coord_radial``.
47+
48+
Examples
49+
--------
50+
A pie chart is a stacked bar chart with the y position mapped to angle.
51+
52+
```python
53+
import pandas as pd
54+
from plotnine import aes, coord_polar, geom_col, ggplot
55+
56+
df = pd.DataFrame({
57+
"x": [1, 1, 1],
58+
"y": [2, 3, 5],
59+
"group": ["a", "b", "c"],
60+
})
61+
62+
ggplot(df, aes("x", "y", fill="group")) + geom_col() + coord_polar("y")
63+
```
3864
"""
3965

4066
is_linear = False
@@ -56,9 +82,7 @@ def __init__(
5682
# Panel params
5783
# ------------------------------------------------------------------
5884

59-
def setup_panel_params(
60-
self, scale_x: scale, scale_y: scale
61-
) -> panel_view:
85+
def setup_panel_params(self, scale_x: scale, scale_y: scale) -> panel_view:
6286
from .coord_cartesian import coord_cartesian
6387

6488
# Theta fills exactly one full revolution — no expansion on that axis.
@@ -95,7 +119,8 @@ def setup_panel_params(
95119
labels=[],
96120
)
97121

98-
# y → r axis: use the scale for the r dimension with its natural breaks.
122+
# y → r axis: use the scale for the r dimension with its natural
123+
# breaks.
99124
new_y = replace(r_sv)
100125

101126
return replace(pv_exp, x=new_x, y=new_y)
@@ -140,7 +165,9 @@ def transform(
140165

141166
data = data.copy()
142167
data[theta_col] = self._to_radians(data[theta_col].to_numpy())
143-
has_endpoints = theta_end_col in data.columns and r_end_col in data.columns
168+
has_endpoints = (
169+
theta_end_col in data.columns and r_end_col in data.columns
170+
)
144171
if has_endpoints:
145172
data[theta_end_col] = self._to_radians(
146173
data[theta_end_col].to_numpy()
@@ -204,7 +231,7 @@ def draw(self, axs: list[Axes]) -> None:
204231
mpl_direction = -1 if self.direction == 1 else 1
205232

206233
for ax in axs:
207-
ax.set_theta_zero_location("N") # 12 o'clock = 0
234+
ax.set_theta_zero_location("N") # 12 o'clock = 0
208235
ax.set_theta_direction(mpl_direction)
209236
if np.isfinite(r_min) and np.isfinite(r_max) and r_min != r_max:
210237
ax.set_rlim(float(r_min), float(r_max))

plotnine/coords/coord_radial.py

Lines changed: 83 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,20 +10,19 @@
1010
if TYPE_CHECKING:
1111
import pandas as pd
1212
from matplotlib.axes import Axes
13+
1314
from plotnine.iapi import panel_view
1415
from plotnine.scales.scale import scale
1516

1617

1718
class coord_radial(coord_polar):
1819
"""
19-
Radial coordinate system.
20-
21-
A modernised polar coordinate system that adds support for partial arcs,
22-
inner radius (donut/gauge charts), configurable radial-axis placement, and
23-
automatic rotation of the ``angle`` aesthetic to align with theta.
20+
Radial coordinate system
2421
25-
Inherits from :class:`coord_polar`; all standard geoms work without
26-
modification.
22+
`coord_radial` maps one position aesthetic to the angle and the other
23+
to the radius. Compared with ``coord_polar``, it adds support for
24+
partial arcs, inner radius holes, theta/radius limits, radial-axis
25+
placement, and rotation of the ``angle`` aesthetic.
2726
2827
Parameters
2928
----------
@@ -39,7 +38,8 @@ class coord_radial(coord_polar):
3938
``1`` = clockwise (default), ``-1`` = counter-clockwise.
4039
Only used when *end* is ``None``.
4140
expand :
42-
Add a small buffer around the data on the radius axis. Default ``True``.
41+
Add a small buffer around the data on the radius axis.
42+
Default ``True``.
4343
inner_radius :
4444
Size of the inner hole as a fraction of the outer radius, in
4545
``[0, 1)``. ``0`` (default) means no hole; ``0.3`` creates a 30 %
@@ -50,7 +50,11 @@ class coord_radial(coord_polar):
5050
* ``None`` (default) — let Matplotlib decide (usually outside).
5151
* ``True`` — force inside, aligned just past the *start* angle.
5252
* ``False`` — force outside (Matplotlib default).
53-
* *float* — place at this theta angle in radians (clockwise from North).
53+
* *float* — place at this theta angle in radians (clockwise from
54+
North).
55+
56+
Unlike ggplot2's ``r.axis.inside``, a length-2 value for separate
57+
primary and secondary axis placement is not supported.
5458
rotate_angle :
5559
If ``True``, automatically add the local theta angle (in degrees) to
5660
the ``angle`` aesthetic so that text or other rotated marks align with
@@ -71,6 +75,52 @@ class coord_radial(coord_polar):
7175
theta_label_pad :
7276
Distance in points between the outer circle spine and the theta tick
7377
labels. Default ``8``. Only applied when theta labels are shown.
78+
79+
Notes
80+
-----
81+
The Python API uses snake_case names for arguments that are dotted in
82+
ggplot2: ``inner_radius``, ``r_axis_inside``, and ``rotate_angle``.
83+
84+
Unlike ggplot2, plotnine coordinate systems do not currently expose a
85+
``clip`` argument. The ggplot2 ``reverse`` argument is not currently
86+
implemented.
87+
88+
Examples
89+
--------
90+
A donut chart is a stacked bar chart with an inner radius.
91+
92+
```python
93+
import pandas as pd
94+
from plotnine import aes, coord_radial, geom_col, ggplot
95+
96+
df = pd.DataFrame({
97+
"x": [1, 1, 1],
98+
"y": [2, 3, 5],
99+
"group": ["a", "b", "c"],
100+
})
101+
102+
(
103+
ggplot(df, aes("x", "y", fill="group"))
104+
+ geom_col()
105+
+ coord_radial(theta="y", inner_radius=0.4)
106+
)
107+
```
108+
109+
Partial arcs can be used for gauge-like displays.
110+
111+
```python
112+
import numpy as np
113+
import pandas as pd
114+
from plotnine import aes, coord_radial, geom_point, ggplot
115+
116+
df = pd.DataFrame({"x": [1, 2, 3], "y": [2, 4, 3]})
117+
118+
ggplot(df, aes("x", "y")) + geom_point() + coord_radial(
119+
start=-0.4 * np.pi,
120+
end=0.4 * np.pi,
121+
inner_radius=0.3,
122+
)
123+
```
74124
"""
75125

76126
def __init__(
@@ -111,7 +161,9 @@ def setup_panel_params(self, scale_x: scale, scale_y: scale) -> panel_view:
111161
from .coord_cartesian import coord_cartesian
112162

113163
# Capture data-space theta breaks before super() clears them.
114-
pv_data = coord_cartesian(expand=False).setup_panel_params(scale_x, scale_y)
164+
pv_data = coord_cartesian(expand=False).setup_panel_params(
165+
scale_x, scale_y
166+
)
115167
if self.theta == "x":
116168
theta_breaks = list(pv_data.x.breaks)
117169
theta_labels = list(pv_data.x.labels)
@@ -121,7 +173,8 @@ def setup_panel_params(self, scale_x: scale, scale_y: scale) -> panel_view:
121173

122174
pv = super().setup_panel_params(scale_x, scale_y)
123175

124-
# thetalim: zoom the theta data range — only this slice maps to the arc.
176+
# thetalim: zoom the theta data range — only this slice maps to the
177+
# arc.
125178
if self.thetalim is not None:
126179
self.params["theta_range"] = tuple(self.thetalim)
127180

@@ -155,7 +208,9 @@ def setup_panel_params(self, scale_x: scale, scale_y: scale) -> panel_view:
155208
# pac-man / coxcomb charts keep breaks=[] as set by super()).
156209
x_updates: dict = {}
157210
if theta_breaks and (arc_lo is not None or self.theta_labels):
158-
radian_pos = list(self._to_radians(np.asarray(theta_breaks, dtype=float)))
211+
radian_pos = list(
212+
self._to_radians(np.asarray(theta_breaks, dtype=float))
213+
)
159214
if arc_lo is not None:
160215
keep = [arc_lo <= r <= arc_hi for r in radian_pos]
161216
radian_pos = [r for r, k in zip(radian_pos, keep) if k]
@@ -181,7 +236,11 @@ def setup_panel_params(self, scale_x: scale, scale_y: scale) -> panel_view:
181236

182237
@property
183238
def _arc(self) -> float:
184-
"""Total arc in radians (signed: positive when going clockwise for direction=1)."""
239+
"""
240+
Total arc in radians.
241+
242+
A positive value represents clockwise movement when ``direction=1``.
243+
"""
185244
if self.end is not None:
186245
return self.end - self.start
187246
return self.direction * 2.0 * np.pi
@@ -207,7 +266,11 @@ def transform(
207266
) -> pd.DataFrame:
208267
data = super().transform(data, panel_params, munch=munch)
209268
# After super().transform(), data["x"] is always theta in radians.
210-
if self.rotate_angle and "angle" in data.columns and "x" in data.columns:
269+
if (
270+
self.rotate_angle
271+
and "angle" in data.columns
272+
and "x" in data.columns
273+
):
211274
data = data.copy()
212275
data["angle"] = data["angle"] + np.degrees(data["x"])
213276
return data
@@ -252,10 +315,14 @@ def draw(self, axs: list[Axes]) -> None:
252315
# Just inside the start angle keeps it out of the data.
253316
ax.set_rlabel_position(np.degrees(self.start) + 10)
254317
else:
255-
ax.set_rlabel_position(np.degrees(float(self.r_axis_inside)))
318+
ax.set_rlabel_position(
319+
np.degrees(float(self.r_axis_inside))
320+
)
256321

257322
def post_setup_ax(self, ax: Axes) -> None:
258-
"""Apply theta label pad after facet has set tick positions and padding."""
323+
"""
324+
Apply theta label pad after facet has set tick positions and padding.
325+
"""
259326
if self.theta_labels or self.end is not None:
260327
ax.tick_params(axis="x", pad=self.theta_label_pad)
261328
# Allow geom_text labels to extend past the polar axes bounding box

0 commit comments

Comments
 (0)