From 6fe2574303ea68d4baaddde9edfca4e083700415 Mon Sep 17 00:00:00 2001 From: Ian Gow Date: Fri, 1 May 2026 08:25:56 -0400 Subject: [PATCH 01/17] Add coord_polar coordinate system Implements polar coordinates by transforming x/y data to angle/radius at the Cartesian level, so all standard geoms work without modification. Adds a draw() hook to the coord base class for post-layer decorations; coord_polar uses it to draw concentric-circle and radial-spoke grid lines. --- notebooks/coord-polar.qmd | 89 +++++++++++++++ plotnine/__init__.py | 2 + plotnine/coords/__init__.py | 2 + plotnine/coords/coord.py | 8 ++ plotnine/coords/coord_polar.py | 202 +++++++++++++++++++++++++++++++++ plotnine/ggplot.py | 1 + 6 files changed, 304 insertions(+) create mode 100644 notebooks/coord-polar.qmd create mode 100644 plotnine/coords/coord_polar.py diff --git a/notebooks/coord-polar.qmd b/notebooks/coord-polar.qmd new file mode 100644 index 0000000000..6b999bbb19 --- /dev/null +++ b/notebooks/coord-polar.qmd @@ -0,0 +1,89 @@ +--- +title: "Polar Coordinates with `coord_polar()`" +execute: + warning: false +--- + +```{python} +from datetime import date + +import polars as pl +import plotnine_polars as p9 +import socviz_pl as sv +from plotnine_polars import aes +``` + +## FARS pedestrian data + +The `farsinvolved` dataset records daily counts of child pedestrians (aged 0–17) +involved in fatal motor vehicle crashes in the United States from 2009 to 2023. +We aggregate by calendar month and day, averaging across years. +Year 2000 is used as a placeholder date because 2000 was a leap year. + +```{python} +farsinvolved = sv.load_data("farsinvolved") + +month_order = [ + "January", "February", "March", "April", "May", "June", + "July", "August", "September", "October", "November", "December", +] +month_num = {m: i + 1 for i, m in enumerate(month_order)} + +fars_agg = ( + farsinvolved + .with_columns(pl.col("day").cast(pl.Int32)) + .group_by("month", "day") + .agg(n=pl.col("n").mean()) + .with_columns( + month_num=pl.col("month").replace(month_num).cast(pl.Int32), + ) + .with_columns( + fake_yr=pl.date(2000, pl.col("month_num"), pl.col("day")), + flag=(pl.col("month") == "October") & (pl.col("day") == 31), + ) + .sort("fake_yr") +) +``` + +Halloween (October 31) stands out as the single most dangerous day for child pedestrians. + +## Polar chart + +`coord_polar()` maps x to angle and y to radius. +Each circle below is one calendar day; the lone orange circle is Halloween. + +```{python} +#| label: fig-coord-polar-fars +#| fig-cap: "Pedestrians aged 0–17 in Fatal Motor Vehicle Crashes. Daily average, 2009–2023." +plot_data = fars_agg.with_columns(doy=pl.col("fake_yr").dt.ordinal_day()) +halloween = plot_data.filter(pl.col("flag")) + +n_label = plot_data["n"].max() + 0.5 +month_starts = pl.DataFrame({ + "doy": [date(2000, m, 15).timetuple().tm_yday for m in range(1, 13)], + "label": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], + "n": [n_label] * 12, +}) + +( + plot_data + .ggplot(aes(x="doy", y="n")) + .geom_point(shape="o", size=1.5, color="#222222", fill="white", stroke=0.4) + .geom_point(data=halloween, mapping=aes(x="doy", y="n"), + size=4, color="#E06000", inherit_aes=False) + .geom_text(data=month_starts, mapping=aes(x="doy", y="n", label="label"), + size=7, color="#444444", inherit_aes=False) + .coord_polar() + .labs( + title="Pedestrians aged 0–17 in Fatal Motor Vehicle Crashes", + subtitle="Daily average, 2009–2023", + ) + .add_theme( + axis_title=p9.element_blank(), + axis_text=p9.element_blank(), + axis_ticks=p9.element_blank(), + figure_size=(6, 6), + ) +) +``` diff --git a/plotnine/__init__.py b/plotnine/__init__.py index 69526927bd..63126f1039 100644 --- a/plotnine/__init__.py +++ b/plotnine/__init__.py @@ -20,6 +20,7 @@ coord_equal, coord_fixed, coord_flip, + coord_polar, coord_trans, ) from .facets import ( @@ -289,6 +290,7 @@ "coord_equal", "coord_fixed", "coord_flip", + "coord_polar", "coord_trans", "element_blank", "element_line", diff --git a/plotnine/coords/__init__.py b/plotnine/coords/__init__.py index d49f9f8ff2..8601bb1c1a 100644 --- a/plotnine/coords/__init__.py +++ b/plotnine/coords/__init__.py @@ -5,6 +5,7 @@ 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_trans import coord_trans __all__ = ( @@ -12,5 +13,6 @@ "coord_fixed", "coord_equal", "coord_flip", + "coord_polar", "coord_trans", ) diff --git a/plotnine/coords/coord.py b/plotnine/coords/coord.py index 8dcbc378fa..1c185f9db6 100644 --- a/plotnine/coords/coord.py +++ b/plotnine/coords/coord.py @@ -104,6 +104,14 @@ 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 labels(self, cur_labels: labels_view) -> labels_view: """ Modify labels diff --git a/plotnine/coords/coord_polar.py b/plotnine/coords/coord_polar.py new file mode 100644 index 0000000000..20a86882ec --- /dev/null +++ b/plotnine/coords/coord_polar.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +from dataclasses import replace +from typing import TYPE_CHECKING + +import numpy as np + +from .coord import coord, dist_euclidean + +if TYPE_CHECKING: + import pandas as pd + from matplotlib.axes import Axes + from plotnine.iapi import panel_view + from plotnine.scales.scale import scale + + +class coord_polar(coord): + """ + Polar coordinate system. + + Maps one aesthetic to angle (theta) and the other to radius. + Data is transformed at the Cartesian level so every standard geom + works without modification. Concentric circle and radial-spoke + grid lines are drawn automatically from the scale breaks. + + 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 unit circle. Default ``True``. + """ + + 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 = {} + + def setup_panel_params( + self, scale_x: scale, scale_y: scale + ) -> panel_view: + from .coord_cartesian import coord_cartesian + + # Theta should always fill exactly one full revolution — no expansion. + # R uses the caller-controlled expand flag (for padding around data). + pv_theta = coord_cartesian(expand=False).setup_panel_params(scale_x, scale_y) + pv_r = coord_cartesian(expand=self.expand).setup_panel_params(scale_x, scale_y) + + # Store original ranges and breaks so transform() and + # draw() can use them. + if self.theta == "x": + self.params["theta_range"] = pv_theta.x.range + self.params["r_range"] = pv_r.y.range + self.params["r_breaks"] = list(pv_r.y.breaks) + self.params["theta_breaks"] = list(pv_theta.x.breaks) + else: + self.params["theta_range"] = pv_theta.y.range + self.params["r_range"] = pv_r.x.range + self.params["r_breaks"] = list(pv_r.x.breaks) + self.params["theta_breaks"] = list(pv_theta.y.breaks) + + pv = pv_r # use r-expanded view as the base for output ranges + + # Return a fixed unit-square output range; drop all tick marks so + # the Cartesian theming draws nothing — the polar grid is drawn + # explicitly in draw(). + pad = 0.1 if self.expand else 0.0 + out_range = (-1.0 - pad, 1.0 + pad) + empty = np.array([], dtype=float) + new_x = replace( + pv.x, limits=out_range, range=out_range, breaks=[], minor_breaks=empty, labels=[] + ) + new_y = replace( + pv.y, limits=out_range, range=out_range, breaks=[], minor_breaks=empty, labels=[] + ) + return replace(pv, x=new_x, y=new_y) + + def transform( + self, + data: pd.DataFrame, + panel_params: panel_view, + munch: bool = False, + ) -> pd.DataFrame: + if self.theta == "x": + theta_col, r_col = "x", "y" + else: + theta_col, r_col = "y", "x" + + if theta_col not in data.columns or r_col not in data.columns: + return data + + theta_vals = data[theta_col].to_numpy(dtype=float) + r_vals = data[r_col].to_numpy(dtype=float) + + t_min, t_max = ( + float(self.params["theta_range"][0]), + float(self.params["theta_range"][1]), + ) + r_min, r_max = ( + float(self.params["r_range"][0]), + float(self.params["r_range"][1]), + ) + + # Normalise theta → [0, 1] → radians. + denom_t = t_max - t_min + theta_norm = (theta_vals - t_min) / denom_t if denom_t else np.zeros_like(theta_vals) + angle = self.start + self.direction * theta_norm * 2.0 * np.pi + + # Normalise r → [0, 1]. Clamp below zero only; values slightly above + # 1.0 land between the unit circle and the panel edge (which extends + # to 1 + pad ≈ 1.1) and are used for axis-label annotations. + denom_r = r_max - r_min + r_norm = (r_vals - r_min) / denom_r if denom_r else np.zeros_like(r_vals) + r_norm = np.clip(r_norm, 0.0, None) + + # Project to Cartesian. Angle is measured from the positive-y axis + # (12 o'clock) so x = r·sin(θ), y = r·cos(θ). + data = data.copy() + data[theta_col] = r_norm * np.sin(angle) + data[r_col] = r_norm * np.cos(angle) + return data + + def _r_norm(self, r_val: float) -> float: + r_min, r_max = self.params["r_range"] + denom = float(r_max) - float(r_min) + return (float(r_val) - float(r_min)) / denom if denom else 0.0 + + def _theta_angle(self, t_val: float) -> float: + t_min, t_max = self.params["theta_range"] + denom = float(t_max) - float(t_min) + t_norm = (float(t_val) - float(t_min)) / denom if denom else 0.0 + return self.start + self.direction * t_norm * 2.0 * np.pi + + def draw(self, axs: list[Axes]) -> None: + """Draw concentric circles and radial spokes onto each panel axes.""" + import matplotlib.patches as mpatches + import matplotlib.lines as mlines + + r_breaks = [v for v in self.params.get("r_breaks", []) if v is not None] + t_breaks = [v for v in self.params.get("theta_breaks", []) if v is not None] + + if not r_breaks and not t_breaks: + return + + # Outermost circle radius (used as spoke length). + valid_r = [self._r_norm(v) for v in r_breaks if np.isfinite(v)] + r_outer = max(valid_r) if valid_r else 1.0 + + grid_kw = dict(color="gray", alpha=0.3, linewidth=0.5, zorder=0.5) + + for ax in axs: + # Concentric circles at each r break. + for r_val in r_breaks: + if not np.isfinite(r_val): + continue + r = self._r_norm(r_val) + circle = mpatches.Circle( + (0, 0), r, + fill=False, transform=ax.transData, + **grid_kw, + ) + ax.add_patch(circle) + + # Radial spokes at each theta break. + for t_val in t_breaks: + if not np.isfinite(t_val): + continue + angle = self._theta_angle(t_val) + line = mlines.Line2D( + [0, r_outer * np.sin(angle)], + [0, r_outer * np.cos(angle)], + transform=ax.transData, + **grid_kw, + ) + ax.add_line(line) + + def aspect(self, panel_params: panel_view) -> float: + return 1.0 + + def distance( + self, + x: pd.Series, + y: pd.Series, + panel_params: panel_view, + ) -> np.ndarray: + # Output space is [-1, 1]² so max diagonal ≈ 2√2. + return dist_euclidean(x, y) / (2.0 * np.sqrt(2.0)) diff --git a/plotnine/ggplot.py b/plotnine/ggplot.py index 08a00ca8e2..846cfba783 100755 --- a/plotnine/ggplot.py +++ b/plotnine/ggplot.py @@ -511,6 +511,7 @@ def _draw_layers(self): """ # Draw the geoms self.layers.draw(self.layout, self.coordinates) + self.coordinates.draw(self.axs) def _draw_breaks_and_labels(self): """ From c7a00f372b4353165b8764196d86e1d7b2917b70 Mon Sep 17 00:00:00 2001 From: Ian Gow Date: Fri, 1 May 2026 15:38:18 -0400 Subject: [PATCH 02/17] Rewrite coord_polar to use Matplotlib's native PolarAxes Replace the manual Cartesian-projection approach with subplot_kw={"projection": "polar"}, so geom_bar naturally becomes pie/bullseye wedges via munching. transform() now outputs (theta_rad, r) pairs; draw() configures zero-location, direction, and r limits. Guard axis_line and axis_text_x theme elements against PolarAxes spine/tick-param differences. --- plotnine/coords/coord_polar.py | 225 +++++++++++++++++---------------- plotnine/facets/facet.py | 9 +- plotnine/themes/themeable.py | 12 +- 3 files changed, 132 insertions(+), 114 deletions(-) diff --git a/plotnine/coords/coord_polar.py b/plotnine/coords/coord_polar.py index 20a86882ec..a452f0a1b3 100644 --- a/plotnine/coords/coord_polar.py +++ b/plotnine/coords/coord_polar.py @@ -5,6 +5,7 @@ import numpy as np +from ..iapi import panel_ranges from .coord import coord, dist_euclidean if TYPE_CHECKING: @@ -18,10 +19,10 @@ class coord_polar(coord): """ Polar coordinate system. - Maps one aesthetic to angle (theta) and the other to radius. - Data is transformed at the Cartesian level so every standard geom - works without modification. Concentric circle and radial-spoke - grid lines are drawn automatically from the scale breaks. + Uses Matplotlib's native ``PolarAxes`` so every standard geom + (including bar → pie/bullseye) renders correctly without manual + Cartesian conversion. Concentric-circle and radial-spoke grid + lines are drawn automatically by Matplotlib. Parameters ---------- @@ -33,7 +34,7 @@ class coord_polar(coord): direction : ``1`` = clockwise (default), ``-1`` = counter-clockwise. expand : - Add a small buffer around the unit circle. Default ``True``. + Add a small buffer around the data on the radius axis. Default ``True``. """ is_linear = False @@ -51,44 +52,68 @@ def __init__( 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 should always fill exactly one full revolution — no expansion. - # R uses the caller-controlled expand flag (for padding around data). - pv_theta = coord_cartesian(expand=False).setup_panel_params(scale_x, scale_y) - pv_r = coord_cartesian(expand=self.expand).setup_panel_params(scale_x, scale_y) + # 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 + ) - # Store original ranges and breaks so transform() and - # draw() can use them. if self.theta == "x": - self.params["theta_range"] = pv_theta.x.range - self.params["r_range"] = pv_r.y.range - self.params["r_breaks"] = list(pv_r.y.breaks) - self.params["theta_breaks"] = list(pv_theta.x.breaks) + theta_range = pv_no_exp.x.range + r_sv = pv_exp.y else: - self.params["theta_range"] = pv_theta.y.range - self.params["r_range"] = pv_r.x.range - self.params["r_breaks"] = list(pv_r.x.breaks) - self.params["theta_breaks"] = list(pv_theta.y.breaks) - - pv = pv_r # use r-expanded view as the base for output ranges - - # Return a fixed unit-square output range; drop all tick marks so - # the Cartesian theming draws nothing — the polar grid is drawn - # explicitly in draw(). - pad = 0.1 if self.expand else 0.0 - out_range = (-1.0 - pad, 1.0 + pad) + 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: PolarAxes expects radians in [0, 2π]; suppress ticks + # because our data ticks would be in original data units, not radians. new_x = replace( - pv.x, limits=out_range, range=out_range, breaks=[], minor_breaks=empty, labels=[] + pv_exp.x, + limits=(0.0, 2 * np.pi), + range=(0.0, 2 * np.pi), + breaks=[], + minor_breaks=empty, + labels=[], ) - new_y = replace( - pv.y, limits=out_range, range=out_range, breaks=[], minor_breaks=empty, labels=[] - ) - return replace(pv, x=new_x, y=new_y) + + # 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, @@ -96,6 +121,11 @@ def transform( 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" else: @@ -104,99 +134,70 @@ def transform( if theta_col not in data.columns or r_col not in data.columns: return data - theta_vals = data[theta_col].to_numpy(dtype=float) - r_vals = data[r_col].to_numpy(dtype=float) - - t_min, t_max = ( - float(self.params["theta_range"][0]), - float(self.params["theta_range"][1]), - ) - r_min, r_max = ( - float(self.params["r_range"][0]), - float(self.params["r_range"][1]), - ) - - # Normalise theta → [0, 1] → radians. - denom_t = t_max - t_min - theta_norm = (theta_vals - t_min) / denom_t if denom_t else np.zeros_like(theta_vals) - angle = self.start + self.direction * theta_norm * 2.0 * np.pi + data = data.copy() + data[theta_col] = self._to_radians(data[theta_col].to_numpy()) - # Normalise r → [0, 1]. Clamp below zero only; values slightly above - # 1.0 land between the unit circle and the panel edge (which extends - # to 1 + pad ≈ 1.1) and are used for axis-label annotations. - denom_r = r_max - r_min - r_norm = (r_vals - r_min) / denom_r if denom_r else np.zeros_like(r_vals) - r_norm = np.clip(r_norm, 0.0, None) + # 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() - # Project to Cartesian. Angle is measured from the positive-y axis - # (12 o'clock) so x = r·sin(θ), y = r·cos(θ). - data = data.copy() - data[theta_col] = r_norm * np.sin(angle) - data[r_col] = r_norm * np.cos(angle) return data - def _r_norm(self, r_val: float) -> float: - r_min, r_max = self.params["r_range"] - denom = float(r_max) - float(r_min) - return (float(r_val) - float(r_min)) / denom if denom else 0.0 + # ------------------------------------------------------------------ + # Distance (used by munch, called before transform) + # ------------------------------------------------------------------ - def _theta_angle(self, t_val: float) -> float: + 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"] - denom = float(t_max) - float(t_min) - t_norm = (float(t_val) - float(t_min)) / denom if denom else 0.0 - return self.start + self.direction * t_norm * 2.0 * np.pi + 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 - def draw(self, axs: list[Axes]) -> None: - """Draw concentric circles and radial spokes onto each panel axes.""" - import matplotlib.patches as mpatches - import matplotlib.lines as mlines + 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) - r_breaks = [v for v in self.params.get("r_breaks", []) if v is not None] - t_breaks = [v for v in self.params.get("theta_breaks", []) if v is not None] + 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) - if not r_breaks and not t_breaks: - return + 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) # type: ignore[arg-type] + return panel_ranges(x=r_range, y=t_range) # type: ignore[arg-type] + + # ------------------------------------------------------------------ + # Draw decorations on PolarAxes + # ------------------------------------------------------------------ - # Outermost circle radius (used as spoke length). - valid_r = [self._r_norm(v) for v in r_breaks if np.isfinite(v)] - r_outer = max(valid_r) if valid_r else 1.0 + 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)) - grid_kw = dict(color="gray", alpha=0.3, linewidth=0.5, zorder=0.5) + # Matplotlib PolarAxes theta_direction: -1 = clockwise, 1 = counter-CW. + mpl_direction = -1 if self.direction == 1 else 1 for ax in axs: - # Concentric circles at each r break. - for r_val in r_breaks: - if not np.isfinite(r_val): - continue - r = self._r_norm(r_val) - circle = mpatches.Circle( - (0, 0), r, - fill=False, transform=ax.transData, - **grid_kw, - ) - ax.add_patch(circle) - - # Radial spokes at each theta break. - for t_val in t_breaks: - if not np.isfinite(t_val): - continue - angle = self._theta_angle(t_val) - line = mlines.Line2D( - [0, r_outer * np.sin(angle)], - [0, r_outer * np.cos(angle)], - transform=ax.transData, - **grid_kw, - ) - ax.add_line(line) + ax.set_theta_zero_location("N") # 12 o'clock = 0 + ax.set_theta_direction(mpl_direction) + if np.isfinite(r_min) and np.isfinite(r_max) and r_min != r_max: + ax.set_rlim(float(r_min), float(r_max)) + + # ------------------------------------------------------------------ + # Misc + # ------------------------------------------------------------------ def aspect(self, panel_params: panel_view) -> float: return 1.0 - - def distance( - self, - x: pd.Series, - y: pd.Series, - panel_params: panel_view, - ) -> np.ndarray: - # Output space is [-1, 1]² so max diagonal ≈ 2√2. - return dist_euclidean(x, y) / (2.0 * np.sqrt(2.0)) diff --git a/plotnine/facets/facet.py b/plotnine/facets/facet.py index ae784d8768..873488c572 100644 --- a/plotnine/facets/facet.py +++ b/plotnine/facets/facet.py @@ -397,9 +397,16 @@ def _make_axes(self) -> tuple[p9GridSpec, list[Axes]]: gs = self._make_gridspec() # Create axes + from ..coords.coord_polar import coord_polar + + subplot_kw = ( + {"projection": "polar"} + if isinstance(self.plot.coordinates, coord_polar) + else {} + ) it = itertools.product(range(self.nrow), range(self.ncol)) for i, (row, col) in enumerate(it): - axsarr[row, col] = self.figure.add_subplot(gs[i]) + axsarr[row, col] = self.figure.add_subplot(gs[i], **subplot_kw) # Rearrange axes # They are ordered to match the positions in the layout table diff --git a/plotnine/themes/themeable.py b/plotnine/themes/themeable.py index f3e8f0f806..656cdbe1bd 100644 --- a/plotnine/themes/themeable.py +++ b/plotnine/themes/themeable.py @@ -978,7 +978,7 @@ def apply_ax(self, ax: Axes): vinstalled = version.parse(mpl.__version__) v310 = version.parse("3.10.0") name = "labelbottom" if vinstalled >= v310 else "labelleft" - if not ax.xaxis.get_tick_params()[name]: + if not ax.xaxis.get_tick_params().get(name, True): return # if not ax.xaxis.get_tick_params()["labelbottom"]: @@ -1108,6 +1108,9 @@ class axis_line_x(themeable): def apply_ax(self, ax: Axes): super().apply_ax(ax) + # PolarAxes has no "top"/"bottom" spines — skip silently. + if "top" not in ax.spines: + return properties = self._get_properties(omit=("solid_capstyle",)) # MPL has a default zorder of 2.5 for spines # so layers 3+ would be drawn on top of the spines @@ -1118,6 +1121,8 @@ def apply_ax(self, ax: Axes): def blank_ax(self, ax: Axes): super().blank_ax(ax) + if "top" not in ax.spines: + return ax.spines["top"].set_visible(False) ax.spines["bottom"].set_visible(False) @@ -1135,6 +1140,9 @@ class axis_line_y(themeable): def apply_ax(self, ax: Axes): super().apply_ax(ax) + # PolarAxes has no "left"/"right" spines — skip silently. + if "left" not in ax.spines: + return properties = self._get_properties(omit=("solid_capstyle",)) # MPL has a default zorder of 2.5 for spines # so layers 3+ would be drawn on top of the spines @@ -1145,6 +1153,8 @@ def apply_ax(self, ax: Axes): def blank_ax(self, ax: Axes): super().blank_ax(ax) + if "left" not in ax.spines: + return ax.spines["left"].set_visible(False) ax.spines["right"].set_visible(False) From 53f7780fed1d651975c97deb05732cfd106d0148 Mon Sep 17 00:00:00 2001 From: Ian Gow Date: Sat, 2 May 2026 11:37:56 -0400 Subject: [PATCH 03/17] Add radial coordinate system --- plotnine/__init__.py | 2 + plotnine/coords/__init__.py | 2 + plotnine/coords/coord_radial.py | 157 ++++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 plotnine/coords/coord_radial.py diff --git a/plotnine/__init__.py b/plotnine/__init__.py index 63126f1039..a4b9bcce4c 100644 --- a/plotnine/__init__.py +++ b/plotnine/__init__.py @@ -21,6 +21,7 @@ coord_fixed, coord_flip, coord_polar, + coord_radial, coord_trans, ) from .facets import ( @@ -291,6 +292,7 @@ "coord_fixed", "coord_flip", "coord_polar", + "coord_radial", "coord_trans", "element_blank", "element_line", diff --git a/plotnine/coords/__init__.py b/plotnine/coords/__init__.py index 8601bb1c1a..ae14967bef 100644 --- a/plotnine/coords/__init__.py +++ b/plotnine/coords/__init__.py @@ -6,6 +6,7 @@ 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__ = ( @@ -14,5 +15,6 @@ "coord_equal", "coord_flip", "coord_polar", + "coord_radial", "coord_trans", ) diff --git a/plotnine/coords/coord_radial.py b/plotnine/coords/coord_radial.py new file mode 100644 index 0000000000..f48d423d05 --- /dev/null +++ b/plotnine/coords/coord_radial.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from .coord_polar import coord_polar + +if TYPE_CHECKING: + import pandas as pd + from matplotlib.axes import Axes + from plotnine.iapi import panel_view + + +class coord_radial(coord_polar): + """ + Radial coordinate system. + + A modernised polar coordinate system that adds support for partial arcs, + inner radius (donut/gauge charts), configurable radial-axis placement, and + automatic rotation of the ``angle`` aesthetic to align with theta. + + Inherits from :class:`coord_polar`; all standard geoms work without + modification. + + Parameters + ---------- + theta : + Which variable maps to the angle axis, ``"x"`` (default) or ``"y"``. + start : + Starting angle in radians, measured clockwise from 12 o'clock. + Default 0. + end : + Ending angle in radians, measured clockwise from 12 o'clock. + ``None`` (default) gives a full circle (``start + 2π * direction``). + direction : + ``1`` = clockwise (default), ``-1`` = counter-clockwise. + Only used when *end* is ``None``. + expand : + Add a small buffer around the data on the radius axis. Default ``True``. + inner_radius : + Size of the inner hole as a fraction of the outer radius, in + ``[0, 1)``. ``0`` (default) means no hole; ``0.3`` creates a 30 % + donut hole, useful for gauge and donut charts. + r_axis_inside : + Where to place the radial (r) axis tick labels. + + * ``None`` (default) — let Matplotlib decide (usually outside). + * ``True`` — force inside, aligned just past the *start* angle. + * ``False`` — force outside (Matplotlib default). + * *float* — place at this theta angle in radians (clockwise from North). + rotate_angle : + If ``True``, automatically add the local theta angle (in degrees) to + the ``angle`` aesthetic so that text or other rotated marks align with + the spoke direction. Default ``False``. + """ + + def __init__( + self, + theta: str = "x", + start: float = 0, + end: float | None = None, + direction: int = 1, + expand: bool = True, + inner_radius: float = 0, + r_axis_inside: bool | float | None = None, + rotate_angle: bool = False, + ) -> None: + super().__init__( + theta=theta, + start=start, + direction=direction, + expand=expand, + ) + self.end = end + self.inner_radius = inner_radius + self.r_axis_inside = r_axis_inside + self.rotate_angle = rotate_angle + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @property + def _arc(self) -> float: + """Total arc in radians (signed: positive when going clockwise for direction=1).""" + if self.end is not None: + return self.end - self.start + return self.direction * 2.0 * np.pi + + def _to_radians(self, vals: np.ndarray) -> np.ndarray: + """Normalize theta values to [start, start + arc].""" + 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 + norm * self._arc + + # ------------------------------------------------------------------ + # Data transformation + # ------------------------------------------------------------------ + + def transform( + self, + data: pd.DataFrame, + panel_params: panel_view, + munch: bool = False, + ) -> pd.DataFrame: + data = super().transform(data, panel_params, munch=munch) + # After super().transform(), data["x"] is always theta in radians. + if self.rotate_angle and "angle" in data.columns and "x" in data.columns: + data = data.copy() + data["angle"] = data["angle"] + np.degrees(data["x"]) + return data + + # ------------------------------------------------------------------ + # Draw decorations on PolarAxes + # ------------------------------------------------------------------ + + def draw(self, axs: list[Axes]) -> None: + """Configure PolarAxes: arc limits, inner radius, axis placement.""" + super().draw(axs) + + r_min, r_max = self.params.get("r_range", (0.0, 1.0)) + arc = self._arc + + for ax in axs: + # Restrict visible theta range for partial arcs. + if self.end is not None: + theta_lo = min(self.start, self.start + arc) + theta_hi = max(self.start, self.start + arc) + ax.set_thetalim(theta_lo, theta_hi) + + # Inner radius: push the data away from the centre by setting a + # virtual r-origin below r_min. Formula: solve + # inner_radius = (r_min - r_origin) / (r_max - r_origin) + if ( + self.inner_radius > 0 + and np.isfinite(r_min) + and np.isfinite(r_max) + and r_max > r_min + and self.inner_radius < 1.0 + ): + r_origin = (r_min - self.inner_radius * r_max) / ( + 1.0 - self.inner_radius + ) + ax.set_rorigin(r_origin) + + # Radial axis label placement. + if self.r_axis_inside is not None: + if isinstance(self.r_axis_inside, bool): + if self.r_axis_inside: + # Just inside the start angle keeps it out of the data. + ax.set_rlabel_position(np.degrees(self.start) + 10) + else: + ax.set_rlabel_position(np.degrees(float(self.r_axis_inside))) From 8ccae7c0ece6ce94ac921aee95caf12787182a12 Mon Sep 17 00:00:00 2001 From: Ian Gow Date: Sat, 2 May 2026 13:18:53 -0400 Subject: [PATCH 04/17] Extend coord_radial: partial-arc fixes, thetalim/rlim, theta axis labels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Override setup_panel_params to fix partial arcs (start/end): set x panel range to [arc_lo, arc_hi] so set_limits_breaks_and_labels does not overwrite set_thetalim with the default (0, 2π) - Add thetalim and rlim parameters for data-space zoom on each axis, matching ggplot2's coord_radial() interface; filter r-axis breaks to within rlim to prevent PolarAxes autoscale expansion - Restore theta axis tick labels on the outer edge for partial-arc plots by converting data-space breaks to radian positions; suppressed for full-circle charts (pac-man, coxcomb) to preserve existing behaviour --- plotnine/coords/coord_radial.py | 84 +++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/plotnine/coords/coord_radial.py b/plotnine/coords/coord_radial.py index f48d423d05..a97dd9532f 100644 --- a/plotnine/coords/coord_radial.py +++ b/plotnine/coords/coord_radial.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import replace from typing import TYPE_CHECKING import numpy as np @@ -10,6 +11,7 @@ import pandas as pd from matplotlib.axes import Axes from plotnine.iapi import panel_view + from plotnine.scales.scale import scale class coord_radial(coord_polar): @@ -53,6 +55,14 @@ class coord_radial(coord_polar): If ``True``, automatically add the local theta angle (in degrees) to the ``angle`` aesthetic so that text or other rotated marks align with the spoke direction. Default ``False``. + thetalim : + Data-space limits for the theta axis as ``(lo, hi)``. Only data + within this range is mapped to the arc; equivalent to zooming on the + angular axis. ``None`` (default) uses the full data range. + rlim : + Data-space limits for the r axis as ``(lo, hi)``. Only data within + this range is shown; equivalent to zooming on the radial axis. + ``None`` (default) uses the full data range. """ def __init__( @@ -65,6 +75,8 @@ def __init__( inner_radius: float = 0, r_axis_inside: bool | float | None = None, rotate_angle: bool = False, + thetalim: tuple[float, float] | None = None, + rlim: tuple[float, float] | None = None, ) -> None: super().__init__( theta=theta, @@ -76,6 +88,78 @@ def __init__( self.inner_radius = inner_radius self.r_axis_inside = r_axis_inside self.rotate_angle = rotate_angle + self.thetalim = thetalim + self.rlim = rlim + + # ------------------------------------------------------------------ + # Panel params + # ------------------------------------------------------------------ + + def setup_panel_params(self, scale_x: scale, scale_y: scale) -> panel_view: + from .coord_cartesian import coord_cartesian + + # Capture data-space theta breaks before super() clears them. + pv_data = coord_cartesian(expand=False).setup_panel_params(scale_x, scale_y) + if self.theta == "x": + theta_breaks = list(pv_data.x.breaks) + theta_labels = list(pv_data.x.labels) + else: + theta_breaks = list(pv_data.y.breaks) + theta_labels = list(pv_data.y.labels) + + pv = super().setup_panel_params(scale_x, scale_y) + + # thetalim: zoom the theta data range — only this slice maps to the arc. + if self.thetalim is not None: + self.params["theta_range"] = tuple(self.thetalim) + + # rlim: zoom the r data range — update params, panel view y axis, and + # filter breaks/labels to within rlim so set_yticks doesn't force the + # PolarAxes r-axis to expand beyond the requested limits. + if self.rlim is not None: + self.params["r_range"] = tuple(self.rlim) + rlo, rhi = self.rlim + breaks, labels = pv.y.breaks, pv.y.labels + mask = [rlo <= b <= rhi for b in breaks] + new_y = replace( + pv.y, + limits=tuple(self.rlim), + range=tuple(self.rlim), + breaks=[b for b, m in zip(breaks, mask) if m], + labels=[l for l, m in zip(labels, mask) if m], + ) + pv = replace(pv, y=new_y) + + # Compute arc bounds for partial-arc plots (None means full circle). + arc_lo = arc_hi = None + if self.end is not None: + arc = self._arc + arc_lo = min(self.start, self.start + arc) + arc_hi = max(self.start, self.start + arc) + + # For partial arcs only: convert data-space theta breaks to radian + # positions and restore them as theta axis tick labels on the outer edge. + # Full-circle charts (pac-man, coxcomb) keep breaks=[] as set by super(). + x_updates: dict = {} + if theta_breaks and arc_lo is not None: + radian_pos = list(self._to_radians(np.asarray(theta_breaks, dtype=float))) + keep = [arc_lo <= r <= arc_hi for r in radian_pos] + radian_pos = [r for r, k in zip(radian_pos, keep) if k] + theta_labels = [l for l, k in zip(theta_labels, keep) if k] + x_updates["breaks"] = radian_pos + x_updates["labels"] = theta_labels + + # Partial arc: x panel range must match [arc_lo, arc_hi] so that + # set_limits_breaks_and_labels calls ax.set_xlim(arc_lo, arc_hi) rather + # than ax.set_xlim(0, 2π), which would override set_thetalim. + if arc_lo is not None: + x_updates["limits"] = (arc_lo, arc_hi) + x_updates["range"] = (arc_lo, arc_hi) + + if x_updates: + pv = replace(pv, x=replace(pv.x, **x_updates)) + + return pv # ------------------------------------------------------------------ # Helpers From 3d1d3e9169abe9dcd1996d4b1e8779fff68943d4 Mon Sep 17 00:00:00 2001 From: Ian Gow Date: Sat, 2 May 2026 16:15:08 -0400 Subject: [PATCH 05/17] Add theta_labels parameter to coord_radial for full-circle theta axis labels Partial-arc plots already show theta tick labels on the outer edge. Full-circle charts (pac-man, coxcomb) suppress them by default. theta_labels=True opts a full-circle plot into the same behaviour, passing scale breaks through to Matplotlib's PolarAxes which places and rotates them outside the circle automatically. --- plotnine/coords/coord_radial.py | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/plotnine/coords/coord_radial.py b/plotnine/coords/coord_radial.py index a97dd9532f..4da2148747 100644 --- a/plotnine/coords/coord_radial.py +++ b/plotnine/coords/coord_radial.py @@ -63,6 +63,11 @@ class coord_radial(coord_polar): Data-space limits for the r axis as ``(lo, hi)``. Only data within this range is shown; equivalent to zooming on the radial axis. ``None`` (default) uses the full data range. + theta_labels : + If ``True``, show theta axis tick labels on the outer edge of the + circle for full-circle plots, using the breaks and labels from the + theta scale. Default ``False``. Partial-arc plots always show + theta labels (filtered to the visible arc) regardless of this flag. """ def __init__( @@ -77,6 +82,7 @@ def __init__( rotate_angle: bool = False, thetalim: tuple[float, float] | None = None, rlim: tuple[float, float] | None = None, + theta_labels: bool = False, ) -> None: super().__init__( theta=theta, @@ -90,6 +96,7 @@ def __init__( self.rotate_angle = rotate_angle self.thetalim = thetalim self.rlim = rlim + self.theta_labels = theta_labels # ------------------------------------------------------------------ # Panel params @@ -137,15 +144,17 @@ def setup_panel_params(self, scale_x: scale, scale_y: scale) -> panel_view: arc_lo = min(self.start, self.start + arc) arc_hi = max(self.start, self.start + arc) - # For partial arcs only: convert data-space theta breaks to radian - # positions and restore them as theta axis tick labels on the outer edge. - # Full-circle charts (pac-man, coxcomb) keep breaks=[] as set by super(). + # Convert data-space theta breaks to radian positions and restore them + # as theta axis tick labels on the outer edge. Always done for partial + # arcs; for full circles only when theta_labels=True (opt-in, so that + # pac-man / coxcomb charts keep breaks=[] as set by super()). x_updates: dict = {} - if theta_breaks and arc_lo is not None: + if theta_breaks and (arc_lo is not None or self.theta_labels): radian_pos = list(self._to_radians(np.asarray(theta_breaks, dtype=float))) - keep = [arc_lo <= r <= arc_hi for r in radian_pos] - radian_pos = [r for r, k in zip(radian_pos, keep) if k] - theta_labels = [l for l, k in zip(theta_labels, keep) if k] + if arc_lo is not None: + keep = [arc_lo <= r <= arc_hi for r in radian_pos] + radian_pos = [r for r, k in zip(radian_pos, keep) if k] + theta_labels = [l for l, k in zip(theta_labels, keep) if k] x_updates["breaks"] = radian_pos x_updates["labels"] = theta_labels From 232987063a17226e85f27f6d75641b35ab3b0892 Mon Sep 17 00:00:00 2001 From: Ian Gow Date: Sat, 2 May 2026 16:28:22 -0400 Subject: [PATCH 06/17] Add pad to theta tick labels to clear the outer circle spine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without padding, theta labels sit right on the outer boundary. 8 points of pad applies whenever theta labels are shown — both for full-circle plots (theta_labels=True) and partial arcs. --- plotnine/coords/coord_radial.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plotnine/coords/coord_radial.py b/plotnine/coords/coord_radial.py index 4da2148747..9a052ab8d0 100644 --- a/plotnine/coords/coord_radial.py +++ b/plotnine/coords/coord_radial.py @@ -248,3 +248,8 @@ def draw(self, axs: list[Axes]) -> None: ax.set_rlabel_position(np.degrees(self.start) + 10) else: ax.set_rlabel_position(np.degrees(float(self.r_axis_inside))) + + # Push theta tick labels away from the outer circle so they don't + # sit right on the spine. + if self.theta_labels or self.end is not None: + ax.tick_params(axis="x", pad=8) From 0bb20a7a195dcc5d13fc92cb7399d89a70eb851b Mon Sep 17 00:00:00 2001 From: Ian Gow Date: Sat, 2 May 2026 16:33:52 -0400 Subject: [PATCH 07/17] Expose theta_label_pad parameter on coord_radial Replaces the hard-coded pad=8 with a user-facing theta_label_pad parameter (default 8) so callers can tune the gap between the outer circle spine and theta tick labels without post-processing the figure. --- plotnine/coords/coord_radial.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/plotnine/coords/coord_radial.py b/plotnine/coords/coord_radial.py index 9a052ab8d0..3f501f87d4 100644 --- a/plotnine/coords/coord_radial.py +++ b/plotnine/coords/coord_radial.py @@ -68,6 +68,9 @@ class coord_radial(coord_polar): circle for full-circle plots, using the breaks and labels from the theta scale. Default ``False``. Partial-arc plots always show theta labels (filtered to the visible arc) regardless of this flag. + theta_label_pad : + Distance in points between the outer circle spine and the theta tick + labels. Default ``8``. Only applied when theta labels are shown. """ def __init__( @@ -83,6 +86,7 @@ def __init__( thetalim: tuple[float, float] | None = None, rlim: tuple[float, float] | None = None, theta_labels: bool = False, + theta_label_pad: float = 8, ) -> None: super().__init__( theta=theta, @@ -97,6 +101,7 @@ def __init__( self.thetalim = thetalim self.rlim = rlim self.theta_labels = theta_labels + self.theta_label_pad = theta_label_pad # ------------------------------------------------------------------ # Panel params @@ -252,4 +257,4 @@ def draw(self, axs: list[Axes]) -> None: # Push theta tick labels away from the outer circle so they don't # sit right on the spine. if self.theta_labels or self.end is not None: - ax.tick_params(axis="x", pad=8) + ax.tick_params(axis="x", pad=self.theta_label_pad) From 212c9066ef71f5d54be079a0d7f3347c449a9888 Mon Sep 17 00:00:00 2001 From: Ian Gow Date: Sat, 2 May 2026 17:16:45 -0400 Subject: [PATCH 08/17] Fix theta_label_pad being overwritten by facet margin padding facet.set_limits_breaks_and_labels() called ax.tick_params(axis='x', pad=pad_x) after coord.draw(), silently overwriting any custom theta_label_pad set by coord_radial. Add a post_setup_ax() hook to the coord base class, called by set_limits_breaks_and_labels after the margin pad, so coord_radial can apply theta_label_pad at the correct point in the rendering pipeline. --- plotnine/coords/coord.py | 8 ++++++++ plotnine/coords/coord_radial.py | 8 ++++---- plotnine/facets/facet.py | 1 + 3 files changed, 13 insertions(+), 4 deletions(-) diff --git a/plotnine/coords/coord.py b/plotnine/coords/coord.py index 1c185f9db6..300c826772 100644 --- a/plotnine/coords/coord.py +++ b/plotnine/coords/coord.py @@ -112,6 +112,14 @@ def draw(self, axs: list) -> None: 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 diff --git a/plotnine/coords/coord_radial.py b/plotnine/coords/coord_radial.py index 3f501f87d4..9197a1b68d 100644 --- a/plotnine/coords/coord_radial.py +++ b/plotnine/coords/coord_radial.py @@ -254,7 +254,7 @@ def draw(self, axs: list[Axes]) -> None: else: ax.set_rlabel_position(np.degrees(float(self.r_axis_inside))) - # Push theta tick labels away from the outer circle so they don't - # sit right on the spine. - if self.theta_labels or self.end is not None: - ax.tick_params(axis="x", pad=self.theta_label_pad) + def post_setup_ax(self, ax: Axes) -> None: + """Apply theta label pad after facet has set tick positions and padding.""" + if self.theta_labels or self.end is not None: + ax.tick_params(axis="x", pad=self.theta_label_pad) diff --git a/plotnine/facets/facet.py b/plotnine/facets/facet.py index 873488c572..2904ae80d7 100644 --- a/plotnine/facets/facet.py +++ b/plotnine/facets/facet.py @@ -355,6 +355,7 @@ def _inf_to_none( ax.tick_params(axis="x", which="major", pad=pad_x) ax.tick_params(axis="y", which="major", pad=pad_y) + self.coordinates.post_setup_ax(ax) def __deepcopy__(self, memo: dict[Any, Any]) -> facet: """ From ab086666359543e811d1d8fd33cec714f79e1eed Mon Sep 17 00:00:00 2001 From: Ian Gow Date: Sat, 2 May 2026 21:56:46 -0400 Subject: [PATCH 09/17] Fix theta breaks with negative angles causing partial-arc regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ax.set_xticks() with negative radian values silently extends xlim below 0, converting a full circle into a partial arc. When start is chosen so that the first few months map to negative radians (e.g. start = -π/2), the theta labels passed to set_xticks triggered this matplotlib behaviour. Normalise all break positions into [0, 2π] for full-circle plots before they are stored in panel_params.x.breaks so that set_xticks never receives a negative value. Partial-arc plots are unaffected (their breaks are always within [arc_lo, arc_hi] which is already in [0, 2π]). --- plotnine/coords/coord_radial.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/plotnine/coords/coord_radial.py b/plotnine/coords/coord_radial.py index 9197a1b68d..b67c5b40b2 100644 --- a/plotnine/coords/coord_radial.py +++ b/plotnine/coords/coord_radial.py @@ -160,6 +160,12 @@ def setup_panel_params(self, scale_x: scale, scale_y: scale) -> panel_view: keep = [arc_lo <= r <= arc_hi for r in radian_pos] radian_pos = [r for r, k in zip(radian_pos, keep) if k] theta_labels = [l for l, k in zip(theta_labels, keep) if k] + else: + # Full circle: ax.set_xticks with negative values silently extends + # xlim below 0, turning the full circle into a partial arc. Wrap + # all break positions into [0, 2π] to avoid this. + tau = 2.0 * np.pi + radian_pos = [r % tau for r in radian_pos] x_updates["breaks"] = radian_pos x_updates["labels"] = theta_labels From e35098b9fc75f521be16c1a68cad2967778ddbb2 Mon Sep 17 00:00:00 2001 From: Ian Gow Date: Sat, 2 May 2026 22:25:56 -0400 Subject: [PATCH 10/17] Fix start-angle rotation hiding bars for full-circle plots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit coord_polar hardcoded x limits to [0, 2π], but _to_radians maps data to [start, start+2π]. With start=3π/2 the bars from ~Oct–Mar land at theta > 2π and get clipped by the xlim. Fix by setting limits to [start, start+2π] so the data range always falls within the visible window. Remove the now-redundant break-wrapping mod-2π in coord_radial since breaks in [start, start+2π] are naturally within the new limits. --- plotnine/coords/coord_polar.py | 10 ++++++---- plotnine/coords/coord_radial.py | 6 ------ 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/plotnine/coords/coord_polar.py b/plotnine/coords/coord_polar.py index a452f0a1b3..2b2d6b74c1 100644 --- a/plotnine/coords/coord_polar.py +++ b/plotnine/coords/coord_polar.py @@ -82,12 +82,14 @@ def setup_panel_params( empty = np.array([], dtype=float) - # x → theta axis: PolarAxes expects radians in [0, 2π]; suppress ticks - # because our data ticks would be in original data units, not radians. + # 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=(0.0, 2 * np.pi), - range=(0.0, 2 * np.pi), + limits=(theta_start, theta_start + 2 * np.pi), + range=(theta_start, theta_start + 2 * np.pi), breaks=[], minor_breaks=empty, labels=[], diff --git a/plotnine/coords/coord_radial.py b/plotnine/coords/coord_radial.py index b67c5b40b2..9197a1b68d 100644 --- a/plotnine/coords/coord_radial.py +++ b/plotnine/coords/coord_radial.py @@ -160,12 +160,6 @@ def setup_panel_params(self, scale_x: scale, scale_y: scale) -> panel_view: keep = [arc_lo <= r <= arc_hi for r in radian_pos] radian_pos = [r for r, k in zip(radian_pos, keep) if k] theta_labels = [l for l, k in zip(theta_labels, keep) if k] - else: - # Full circle: ax.set_xticks with negative values silently extends - # xlim below 0, turning the full circle into a partial arc. Wrap - # all break positions into [0, 2π] to avoid this. - tau = 2.0 * np.pi - radian_pos = [r % tau for r in radian_pos] x_updates["breaks"] = radian_pos x_updates["labels"] = theta_labels From 6a0adaf0fd2a7890774f949d02e0194771fe715c Mon Sep 17 00:00:00 2001 From: Ian Gow Date: Sat, 2 May 2026 22:39:05 -0400 Subject: [PATCH 11/17] Hide polar spine when panel_border=element_blank() For PolarAxes the outer circle is ax.spines['polar'], which is separate from the rectangular panel_border patch used for Cartesian axes. When panel_border is blank, explicitly hide the polar spine so the theme element works consistently for polar and Cartesian plots. --- plotnine/ggplot.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/plotnine/ggplot.py b/plotnine/ggplot.py index 846cfba783..84ccb5e602 100755 --- a/plotnine/ggplot.py +++ b/plotnine/ggplot.py @@ -485,6 +485,11 @@ def _draw_panel_borders(self): # grid lines below the borders. We leave ax.patch for the # background only. if self.theme.T.is_blank("panel_border"): + # For PolarAxes the default circular spine is separate from the + # panel border Rectangle; hide it explicitly when blank. + for ax in self.axs: + if "polar" in ax.spines: + ax.spines["polar"].set_visible(False) return from matplotlib.patches import Rectangle From 52dc7b8247876b2af27bf20267d5b9d42ec170f7 Mon Sep 17 00:00:00 2001 From: Ian Gow Date: Sun, 3 May 2026 05:39:47 -0400 Subject: [PATCH 12/17] Set clip_on=False on geom_text artists in PolarAxes post_setup_ax iterates ax.texts after the facet creates them and disables clipping, allowing spoke labels placed just beyond the outermost bar tip to render past the axes bounding box. --- plotnine/coords/coord_radial.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/plotnine/coords/coord_radial.py b/plotnine/coords/coord_radial.py index 9197a1b68d..9cea4c9df8 100644 --- a/plotnine/coords/coord_radial.py +++ b/plotnine/coords/coord_radial.py @@ -258,3 +258,7 @@ def post_setup_ax(self, ax: Axes) -> None: """Apply theta label pad after facet has set tick positions and padding.""" if self.theta_labels or self.end is not None: ax.tick_params(axis="x", pad=self.theta_label_pad) + # Allow geom_text labels to extend past the polar axes bounding box + # (e.g. spoke labels placed just beyond the outermost bar tip). + for text in ax.texts: + text.set_clip_on(False) From d222fa3c76b236b23eaaa6672c6fdf562f06af8b Mon Sep 17 00:00:00 2001 From: Ian Gow Date: Sun, 3 May 2026 07:47:00 -0400 Subject: [PATCH 13/17] Transform polar segment endpoints --- plotnine/coords/coord_polar.py | 12 ++++++++++++ tests/test_coord_polar.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 tests/test_coord_polar.py diff --git a/plotnine/coords/coord_polar.py b/plotnine/coords/coord_polar.py index 2b2d6b74c1..75d1318b9d 100644 --- a/plotnine/coords/coord_polar.py +++ b/plotnine/coords/coord_polar.py @@ -130,19 +130,31 @@ def transform( 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 diff --git a/tests/test_coord_polar.py b/tests/test_coord_polar.py new file mode 100644 index 0000000000..1365d5109c --- /dev/null +++ b/tests/test_coord_polar.py @@ -0,0 +1,30 @@ +import numpy as np +import pandas as pd + +from plotnine.coords.coord_polar import coord_polar + + +def test_coord_polar_transforms_segment_endpoints_theta_x(): + coord = coord_polar(theta="x") + coord.params = {"theta_range": (0, 10), "r_range": (0, 10)} + data = pd.DataFrame({"x": [0], "y": [1], "xend": [10], "yend": [2]}) + + out = coord.transform(data, None) + + assert out.loc[0, "x"] == 0 + assert out.loc[0, "y"] == 1 + assert np.isclose(out.loc[0, "xend"], 2 * np.pi) + assert out.loc[0, "yend"] == 2 + + +def test_coord_polar_transforms_segment_endpoints_theta_y(): + coord = coord_polar(theta="y") + coord.params = {"theta_range": (0, 10), "r_range": (0, 10)} + data = pd.DataFrame({"x": [1], "y": [0], "xend": [2], "yend": [10]}) + + out = coord.transform(data, None) + + assert out.loc[0, "x"] == 0 + assert out.loc[0, "y"] == 1 + assert np.isclose(out.loc[0, "xend"], 2 * np.pi) + assert out.loc[0, "yend"] == 2 From 49bcd6dd63802fd3e9fd9e17bb2c66c4448da38e Mon Sep 17 00:00:00 2001 From: Ian Gow Date: Mon, 4 May 2026 14:42:38 -0400 Subject: [PATCH 14/17] Document polar coordinates --- doc/.gitignore | 2 + doc/_quartodoc.yml | 2 + plotnine/coords/coord_polar.py | 51 +++++++++++++---- plotnine/coords/coord_radial.py | 99 +++++++++++++++++++++++++++------ 4 files changed, 126 insertions(+), 28 deletions(-) diff --git a/doc/.gitignore b/doc/.gitignore index 4e48027747..19459cf67a 100644 --- a/doc/.gitignore +++ b/doc/.gitignore @@ -13,3 +13,5 @@ objects.json objects.txt objects.inv gallery/thumbnails + +**/*.quarto_ipynb diff --git a/doc/_quartodoc.yml b/doc/_quartodoc.yml index 6414031a5d..59e8218070 100644 --- a/doc/_quartodoc.yml +++ b/doc/_quartodoc.yml @@ -535,6 +535,8 @@ quartodoc: - coord_equal - coord_fixed - coord_flip + - coord_polar + - coord_radial - coord_trans - title: Composing Plots diff --git a/plotnine/coords/coord_polar.py b/plotnine/coords/coord_polar.py index 75d1318b9d..e3ca59eac0 100644 --- a/plotnine/coords/coord_polar.py +++ b/plotnine/coords/coord_polar.py @@ -11,18 +11,18 @@ if TYPE_CHECKING: import pandas as pd from matplotlib.axes import Axes + from plotnine.iapi import panel_view from plotnine.scales.scale import scale class coord_polar(coord): """ - Polar coordinate system. + Polar coordinate system - Uses Matplotlib's native ``PolarAxes`` so every standard geom - (including bar → pie/bullseye) renders correctly without manual - Cartesian conversion. Concentric-circle and radial-spoke grid - lines are drawn automatically by Matplotlib. + `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 ---------- @@ -34,7 +34,33 @@ class coord_polar(coord): direction : ``1`` = clockwise (default), ``-1`` = counter-clockwise. expand : - Add a small buffer around the data on the radius axis. Default ``True``. + 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 @@ -56,9 +82,7 @@ def __init__( # Panel params # ------------------------------------------------------------------ - def setup_panel_params( - self, scale_x: scale, scale_y: scale - ) -> panel_view: + 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. @@ -95,7 +119,8 @@ def setup_panel_params( labels=[], ) - # y → r axis: use the scale for the r dimension with its natural breaks. + # 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) @@ -140,7 +165,9 @@ def transform( 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 + 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() @@ -204,7 +231,7 @@ def draw(self, axs: list[Axes]) -> None: mpl_direction = -1 if self.direction == 1 else 1 for ax in axs: - ax.set_theta_zero_location("N") # 12 o'clock = 0 + ax.set_theta_zero_location("N") # 12 o'clock = 0 ax.set_theta_direction(mpl_direction) if np.isfinite(r_min) and np.isfinite(r_max) and r_min != r_max: ax.set_rlim(float(r_min), float(r_max)) diff --git a/plotnine/coords/coord_radial.py b/plotnine/coords/coord_radial.py index 9cea4c9df8..37f56ff084 100644 --- a/plotnine/coords/coord_radial.py +++ b/plotnine/coords/coord_radial.py @@ -10,20 +10,19 @@ if TYPE_CHECKING: import pandas as pd from matplotlib.axes import Axes + from plotnine.iapi import panel_view from plotnine.scales.scale import scale class coord_radial(coord_polar): """ - Radial coordinate system. - - A modernised polar coordinate system that adds support for partial arcs, - inner radius (donut/gauge charts), configurable radial-axis placement, and - automatic rotation of the ``angle`` aesthetic to align with theta. + Radial coordinate system - Inherits from :class:`coord_polar`; all standard geoms work without - modification. + `coord_radial` maps one position aesthetic to the angle and the other + to the radius. Compared with ``coord_polar``, it adds support for + partial arcs, inner radius holes, theta/radius limits, radial-axis + placement, and rotation of the ``angle`` aesthetic. Parameters ---------- @@ -39,7 +38,8 @@ class coord_radial(coord_polar): ``1`` = clockwise (default), ``-1`` = counter-clockwise. Only used when *end* is ``None``. expand : - Add a small buffer around the data on the radius axis. Default ``True``. + Add a small buffer around the data on the radius axis. + Default ``True``. inner_radius : Size of the inner hole as a fraction of the outer radius, in ``[0, 1)``. ``0`` (default) means no hole; ``0.3`` creates a 30 % @@ -50,7 +50,11 @@ class coord_radial(coord_polar): * ``None`` (default) — let Matplotlib decide (usually outside). * ``True`` — force inside, aligned just past the *start* angle. * ``False`` — force outside (Matplotlib default). - * *float* — place at this theta angle in radians (clockwise from North). + * *float* — place at this theta angle in radians (clockwise from + North). + + Unlike ggplot2's ``r.axis.inside``, a length-2 value for separate + primary and secondary axis placement is not supported. rotate_angle : If ``True``, automatically add the local theta angle (in degrees) to the ``angle`` aesthetic so that text or other rotated marks align with @@ -71,6 +75,52 @@ class coord_radial(coord_polar): theta_label_pad : Distance in points between the outer circle spine and the theta tick labels. Default ``8``. Only applied when theta labels are shown. + + Notes + ----- + The Python API uses snake_case names for arguments that are dotted in + ggplot2: ``inner_radius``, ``r_axis_inside``, and ``rotate_angle``. + + Unlike ggplot2, plotnine coordinate systems do not currently expose a + ``clip`` argument. The ggplot2 ``reverse`` argument is not currently + implemented. + + Examples + -------- + A donut chart is a stacked bar chart with an inner radius. + + ```python + import pandas as pd + from plotnine import aes, coord_radial, 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_radial(theta="y", inner_radius=0.4) + ) + ``` + + Partial arcs can be used for gauge-like displays. + + ```python + import numpy as np + import pandas as pd + from plotnine import aes, coord_radial, geom_point, ggplot + + df = pd.DataFrame({"x": [1, 2, 3], "y": [2, 4, 3]}) + + ggplot(df, aes("x", "y")) + geom_point() + coord_radial( + start=-0.4 * np.pi, + end=0.4 * np.pi, + inner_radius=0.3, + ) + ``` """ def __init__( @@ -111,7 +161,9 @@ def setup_panel_params(self, scale_x: scale, scale_y: scale) -> panel_view: from .coord_cartesian import coord_cartesian # Capture data-space theta breaks before super() clears them. - pv_data = coord_cartesian(expand=False).setup_panel_params(scale_x, scale_y) + pv_data = coord_cartesian(expand=False).setup_panel_params( + scale_x, scale_y + ) if self.theta == "x": theta_breaks = list(pv_data.x.breaks) theta_labels = list(pv_data.x.labels) @@ -121,7 +173,8 @@ def setup_panel_params(self, scale_x: scale, scale_y: scale) -> panel_view: pv = super().setup_panel_params(scale_x, scale_y) - # thetalim: zoom the theta data range — only this slice maps to the arc. + # thetalim: zoom the theta data range — only this slice maps to the + # arc. if self.thetalim is not None: self.params["theta_range"] = tuple(self.thetalim) @@ -155,7 +208,9 @@ def setup_panel_params(self, scale_x: scale, scale_y: scale) -> panel_view: # pac-man / coxcomb charts keep breaks=[] as set by super()). x_updates: dict = {} if theta_breaks and (arc_lo is not None or self.theta_labels): - radian_pos = list(self._to_radians(np.asarray(theta_breaks, dtype=float))) + radian_pos = list( + self._to_radians(np.asarray(theta_breaks, dtype=float)) + ) if arc_lo is not None: keep = [arc_lo <= r <= arc_hi for r in radian_pos] 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: @property def _arc(self) -> float: - """Total arc in radians (signed: positive when going clockwise for direction=1).""" + """ + Total arc in radians. + + A positive value represents clockwise movement when ``direction=1``. + """ if self.end is not None: return self.end - self.start return self.direction * 2.0 * np.pi @@ -207,7 +266,11 @@ def transform( ) -> pd.DataFrame: data = super().transform(data, panel_params, munch=munch) # After super().transform(), data["x"] is always theta in radians. - if self.rotate_angle and "angle" in data.columns and "x" in data.columns: + if ( + self.rotate_angle + and "angle" in data.columns + and "x" in data.columns + ): data = data.copy() data["angle"] = data["angle"] + np.degrees(data["x"]) return data @@ -252,10 +315,14 @@ def draw(self, axs: list[Axes]) -> None: # Just inside the start angle keeps it out of the data. ax.set_rlabel_position(np.degrees(self.start) + 10) else: - ax.set_rlabel_position(np.degrees(float(self.r_axis_inside))) + ax.set_rlabel_position( + np.degrees(float(self.r_axis_inside)) + ) def post_setup_ax(self, ax: Axes) -> None: - """Apply theta label pad after facet has set tick positions and padding.""" + """ + Apply theta label pad after facet has set tick positions and padding. + """ if self.theta_labels or self.end is not None: ax.tick_params(axis="x", pad=self.theta_label_pad) # Allow geom_text labels to extend past the polar axes bounding box From c9f217067b35add721e69372cc743faf7585a767 Mon Sep 17 00:00:00 2001 From: Ian Gow Date: Tue, 5 May 2026 07:31:00 -0400 Subject: [PATCH 15/17] Add coord polar and radial coverage tests --- tests/test_coord_polar.py | 257 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) diff --git a/tests/test_coord_polar.py b/tests/test_coord_polar.py index 1365d5109c..fd1d169625 100644 --- a/tests/test_coord_polar.py +++ b/tests/test_coord_polar.py @@ -1,7 +1,72 @@ import numpy as np import pandas as pd +from matplotlib import pyplot as plt +from numpy.testing import assert_allclose +from plotnine import ( + aes, + coord_radial, + element_blank, + element_line, + geom_col, + ggplot, + theme, +) from plotnine.coords.coord_polar import coord_polar +from plotnine.scales import scale_x_continuous, scale_y_continuous + + +def trained_scales( + x=(0, 10), + y=(0, 10), + x_breaks=(0, 5, 10), + y_breaks=(0, 5, 10), + x_labels=("0", "5", "10"), + y_labels=("0", "5", "10"), +): + scale_x = scale_x_continuous(breaks=x_breaks, labels=x_labels) + scale_y = scale_y_continuous(breaks=y_breaks, labels=y_labels) + scale_x.train(x) + scale_y.train(y) + return scale_x, scale_y + + +def test_coord_polar_setup_panel_params_theta_x(): + scale_x, scale_y = trained_scales( + y_breaks=(0, 2, 5, 10), + y_labels=("0", "2", "5", "10"), + ) + coord = coord_polar(theta="x", start=np.pi / 4, expand=False) + + panel_params = coord.setup_panel_params(scale_x, scale_y) + + assert coord.params["theta_range"] == (0, 10) + assert coord.params["r_range"] == (0, 10) + assert panel_params.x.range == (np.pi / 4, np.pi / 4 + 2 * np.pi) + assert panel_params.x.breaks == [] + assert panel_params.x.labels == [] + assert panel_params.y.breaks == [0, 2, 5, 10] + + +def test_coord_polar_setup_panel_params_theta_y(): + scale_x, scale_y = trained_scales( + x_breaks=(0, 2, 5, 10), + x_labels=("0", "2", "5", "10"), + ) + coord = coord_polar(theta="y", expand=False) + + panel_params = coord.setup_panel_params(scale_x, scale_y) + + assert coord.params["theta_range"] == (0, 10) + assert coord.params["r_range"] == (0, 10) + assert panel_params.y.breaks == [0, 2, 5, 10] + + +def test_coord_polar_to_radians_zero_width_range(): + coord = coord_polar() + coord.params = {"theta_range": (1, 1)} + + assert_allclose(coord._to_radians(np.array([1, 2, 3])), [0, 0, 0]) def test_coord_polar_transforms_segment_endpoints_theta_x(): @@ -28,3 +93,195 @@ def test_coord_polar_transforms_segment_endpoints_theta_y(): assert out.loc[0, "y"] == 1 assert np.isclose(out.loc[0, "xend"], 2 * np.pi) assert out.loc[0, "yend"] == 2 + + +def test_coord_polar_transforms_theta_y_without_endpoints(): + coord = coord_polar(theta="y") + coord.params = {"theta_range": (0, 10), "r_range": (0, 10)} + data = pd.DataFrame({"x": [1], "y": [5]}) + + out = coord.transform(data, None) + + assert_allclose(out.loc[0, "x"], np.pi) + assert out.loc[0, "y"] == 1 + + +def test_coord_polar_munches_before_radian_transform(): + coord = coord_polar() + coord.params = {"theta_range": (0, 10), "r_range": (0, 10)} + data = pd.DataFrame({"x": [0, 10], "y": [1, 2], "group": [1, 1]}) + + out = coord.transform(data, None, munch=True) + + assert len(out) > len(data) + assert out["x"].between(0, 2 * np.pi).all() + + +def test_coord_polar_leaves_non_position_data_unchanged(): + coord = coord_polar() + data = pd.DataFrame({"label": ["A"]}) + + assert coord.transform(data, None) is data + + +def test_coord_polar_distance_and_backtransform_theta_x(): + coord = coord_polar() + coord.params = {"theta_range": (0, 10), "r_range": (0, 20)} + + distance = coord.distance(pd.Series([0, 10]), pd.Series([0, 10]), None) + + assert_allclose(distance, [np.sqrt(1.25)]) + assert coord.backtransform_range(None).x == (0, 10) + assert coord.backtransform_range(None).y == (0, 20) + + +def test_coord_polar_distance_and_backtransform_theta_y(): + coord = coord_polar(theta="y") + coord.params = {"theta_range": (0, 10), "r_range": (0, 20)} + + distance = coord.distance(pd.Series([0, 10]), pd.Series([0, 10]), None) + + assert_allclose(distance, [np.sqrt(1.25)]) + assert coord.backtransform_range(None).x == (0, 20) + assert coord.backtransform_range(None).y == (0, 10) + + +def test_coord_polar_draw_sets_polar_axis(): + coord = coord_polar(direction=-1) + coord.params = {"r_range": (2, 8)} + fig, ax = plt.subplots(subplot_kw={"projection": "polar"}) + + try: + coord.draw([ax]) + assert ax.get_theta_direction() == 1 + assert_allclose(ax.get_ylim(), (2, 8)) + finally: + plt.close(fig) + + +def test_coord_polar_aspect_is_square(): + assert coord_polar().aspect(None) == 1 + + +def test_coord_polar_draw_uses_polar_axes_and_hides_blank_border(): + data = pd.DataFrame({"x": ["a", "b"], "y": [1, 2]}) + p = ( + ggplot(data, aes("x", "y")) + + geom_col() + + coord_polar() + + theme(panel_border=element_blank(), axis_line=element_line()) + ) + + fig = p.draw() + + try: + ax = fig.axes[0] + assert ax.name == "polar" + assert not ax.spines["polar"].get_visible() + finally: + plt.close(fig) + + +def test_coord_radial_arc_uses_end_or_direction(): + assert coord_radial(start=1, end=4)._arc == 3 + assert coord_radial(direction=-1)._arc == -2 * np.pi + + +def test_coord_radial_setup_panel_params_for_partial_arc(): + scale_x, scale_y = trained_scales( + y_breaks=(0, 2, 4, 8, 10), + y_labels=("0", "2", "4", "8", "10"), + ) + coord = coord_radial( + start=0, + end=np.pi, + thetalim=(0, 10), + rlim=(2, 8), + expand=False, + ) + + panel_params = coord.setup_panel_params(scale_x, scale_y) + + assert coord.params["theta_range"] == (0, 10) + assert coord.params["r_range"] == (2, 8) + assert_allclose(panel_params.x.breaks, [0, np.pi / 2, np.pi]) + assert panel_params.x.labels == ["0", "5", "10"] + assert panel_params.x.range == (0, np.pi) + assert panel_params.y.range == (2, 8) + assert panel_params.y.breaks == [2, 4, 8] + assert panel_params.y.labels == ["2", "4", "8"] + + +def test_coord_radial_setup_panel_params_theta_y_with_labels(): + scale_x, scale_y = trained_scales( + y_breaks=(0, 5, 10), + y_labels=("low", "mid", "high"), + ) + coord = coord_radial(theta="y", theta_labels=True, expand=False) + + panel_params = coord.setup_panel_params(scale_x, scale_y) + + assert_allclose(panel_params.x.breaks, [0, np.pi, 2 * np.pi]) + assert panel_params.x.labels == ["low", "mid", "high"] + + +def test_coord_radial_to_radians_zero_width_range(): + coord = coord_radial() + coord.params = {"theta_range": (1, 1)} + + assert_allclose(coord._to_radians(np.array([1, 2, 3])), [0, 0, 0]) + + +def test_coord_radial_transform_rotates_angle(): + coord = coord_radial(rotate_angle=True) + coord.params = {"theta_range": (0, 10), "r_range": (0, 10)} + data = pd.DataFrame({"x": [0, 5], "y": [1, 1], "angle": [10, 20]}) + + out = coord.transform(data, None) + + assert_allclose(out["x"], [0, np.pi]) + assert_allclose(out["angle"], [10, 200]) + + +def test_coord_radial_draw_sets_arc_inner_radius_and_axis_position(): + coord = coord_radial( + start=np.pi / 4, + end=3 * np.pi / 4, + inner_radius=0.5, + r_axis_inside=True, + ) + coord.params = {"r_range": (2, 10)} + fig, ax = plt.subplots(subplot_kw={"projection": "polar"}) + + try: + coord.draw([ax]) + assert_allclose(ax.get_xlim(), (np.pi / 4, 3 * np.pi / 4)) + assert_allclose(ax.get_rorigin(), -6) + assert ax.get_rlabel_position() == 55 + finally: + plt.close(fig) + + +def test_coord_radial_draw_float_r_axis_position(): + coord = coord_radial(r_axis_inside=np.pi / 2) + coord.params = {"r_range": (0, 10)} + fig, ax = plt.subplots(subplot_kw={"projection": "polar"}) + + try: + coord.draw([ax]) + assert ax.get_rlabel_position() == 90 + finally: + plt.close(fig) + + +def test_coord_radial_post_setup_ax_sets_pad_and_unclips_text(): + coord = coord_radial(theta_label_pad=17, theta_labels=True) + fig, ax = plt.subplots(subplot_kw={"projection": "polar"}) + text = ax.text(0, 1, "label", clip_on=True) + + try: + coord.post_setup_ax(ax) + assert ax.xaxis.get_tick_params()["pad"] == 17 + assert not text.get_clip_on() + finally: + plt.close(fig) From c93c51a357792f9caaee8424d3a22f71dbb4449a Mon Sep 17 00:00:00 2001 From: Ian Gow Date: Tue, 5 May 2026 08:11:39 -0400 Subject: [PATCH 16/17] Fix polar coordinate type checking --- plotnine/coords/coord_polar.py | 14 ++++++++------ plotnine/coords/coord_radial.py | 17 +++++++++++------ 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/plotnine/coords/coord_polar.py b/plotnine/coords/coord_polar.py index e3ca59eac0..d9519b8f6f 100644 --- a/plotnine/coords/coord_polar.py +++ b/plotnine/coords/coord_polar.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import replace -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import numpy as np @@ -11,6 +11,7 @@ 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 @@ -216,8 +217,8 @@ 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) # type: ignore[arg-type] - return panel_ranges(x=r_range, y=t_range) # type: ignore[arg-type] + return panel_ranges(x=t_range, y=r_range) + return panel_ranges(x=r_range, y=t_range) # ------------------------------------------------------------------ # Draw decorations on PolarAxes @@ -231,10 +232,11 @@ def draw(self, axs: list[Axes]) -> None: mpl_direction = -1 if self.direction == 1 else 1 for ax in axs: - ax.set_theta_zero_location("N") # 12 o'clock = 0 - ax.set_theta_direction(mpl_direction) + 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: - ax.set_rlim(float(r_min), float(r_max)) + polar_ax.set_rlim(float(r_min), float(r_max)) # ------------------------------------------------------------------ # Misc diff --git a/plotnine/coords/coord_radial.py b/plotnine/coords/coord_radial.py index 37f56ff084..302d4624c8 100644 --- a/plotnine/coords/coord_radial.py +++ b/plotnine/coords/coord_radial.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import replace -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Sequence, cast import numpy as np @@ -10,6 +10,7 @@ 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 @@ -184,7 +185,8 @@ def setup_panel_params(self, scale_x: scale, scale_y: scale) -> panel_view: if self.rlim is not None: self.params["r_range"] = tuple(self.rlim) rlo, rhi = self.rlim - breaks, labels = pv.y.breaks, pv.y.labels + breaks = cast("Sequence[float]", pv.y.breaks) + labels = pv.y.labels mask = [rlo <= b <= rhi for b in breaks] new_y = replace( pv.y, @@ -287,11 +289,12 @@ def draw(self, axs: list[Axes]) -> None: arc = self._arc for ax in axs: + polar_ax = cast("PolarAxes", ax) # Restrict visible theta range for partial arcs. if self.end is not None: theta_lo = min(self.start, self.start + arc) theta_hi = max(self.start, self.start + arc) - ax.set_thetalim(theta_lo, theta_hi) + polar_ax.set_thetalim(theta_lo, theta_hi) # Inner radius: push the data away from the centre by setting a # virtual r-origin below r_min. Formula: solve @@ -306,16 +309,18 @@ def draw(self, axs: list[Axes]) -> None: r_origin = (r_min - self.inner_radius * r_max) / ( 1.0 - self.inner_radius ) - ax.set_rorigin(r_origin) + polar_ax.set_rorigin(r_origin) # Radial axis label placement. if self.r_axis_inside is not None: if isinstance(self.r_axis_inside, bool): if self.r_axis_inside: # Just inside the start angle keeps it out of the data. - ax.set_rlabel_position(np.degrees(self.start) + 10) + polar_ax.set_rlabel_position( + np.degrees(self.start) + 10 + ) else: - ax.set_rlabel_position( + polar_ax.set_rlabel_position( np.degrees(float(self.r_axis_inside)) ) From 976401611de36b3b6e8b7752b32d31c5b7b92783 Mon Sep 17 00:00:00 2001 From: Ian Gow Date: Tue, 5 May 2026 09:08:48 -0400 Subject: [PATCH 17/17] Remove coord polar notebook --- notebooks/coord-polar.qmd | 89 --------------------------------------- 1 file changed, 89 deletions(-) delete mode 100644 notebooks/coord-polar.qmd diff --git a/notebooks/coord-polar.qmd b/notebooks/coord-polar.qmd deleted file mode 100644 index 6b999bbb19..0000000000 --- a/notebooks/coord-polar.qmd +++ /dev/null @@ -1,89 +0,0 @@ ---- -title: "Polar Coordinates with `coord_polar()`" -execute: - warning: false ---- - -```{python} -from datetime import date - -import polars as pl -import plotnine_polars as p9 -import socviz_pl as sv -from plotnine_polars import aes -``` - -## FARS pedestrian data - -The `farsinvolved` dataset records daily counts of child pedestrians (aged 0–17) -involved in fatal motor vehicle crashes in the United States from 2009 to 2023. -We aggregate by calendar month and day, averaging across years. -Year 2000 is used as a placeholder date because 2000 was a leap year. - -```{python} -farsinvolved = sv.load_data("farsinvolved") - -month_order = [ - "January", "February", "March", "April", "May", "June", - "July", "August", "September", "October", "November", "December", -] -month_num = {m: i + 1 for i, m in enumerate(month_order)} - -fars_agg = ( - farsinvolved - .with_columns(pl.col("day").cast(pl.Int32)) - .group_by("month", "day") - .agg(n=pl.col("n").mean()) - .with_columns( - month_num=pl.col("month").replace(month_num).cast(pl.Int32), - ) - .with_columns( - fake_yr=pl.date(2000, pl.col("month_num"), pl.col("day")), - flag=(pl.col("month") == "October") & (pl.col("day") == 31), - ) - .sort("fake_yr") -) -``` - -Halloween (October 31) stands out as the single most dangerous day for child pedestrians. - -## Polar chart - -`coord_polar()` maps x to angle and y to radius. -Each circle below is one calendar day; the lone orange circle is Halloween. - -```{python} -#| label: fig-coord-polar-fars -#| fig-cap: "Pedestrians aged 0–17 in Fatal Motor Vehicle Crashes. Daily average, 2009–2023." -plot_data = fars_agg.with_columns(doy=pl.col("fake_yr").dt.ordinal_day()) -halloween = plot_data.filter(pl.col("flag")) - -n_label = plot_data["n"].max() + 0.5 -month_starts = pl.DataFrame({ - "doy": [date(2000, m, 15).timetuple().tm_yday for m in range(1, 13)], - "label": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], - "n": [n_label] * 12, -}) - -( - plot_data - .ggplot(aes(x="doy", y="n")) - .geom_point(shape="o", size=1.5, color="#222222", fill="white", stroke=0.4) - .geom_point(data=halloween, mapping=aes(x="doy", y="n"), - size=4, color="#E06000", inherit_aes=False) - .geom_text(data=month_starts, mapping=aes(x="doy", y="n", label="label"), - size=7, color="#444444", inherit_aes=False) - .coord_polar() - .labs( - title="Pedestrians aged 0–17 in Fatal Motor Vehicle Crashes", - subtitle="Daily average, 2009–2023", - ) - .add_theme( - axis_title=p9.element_blank(), - axis_text=p9.element_blank(), - axis_ticks=p9.element_blank(), - figure_size=(6, 6), - ) -) -```