-
Notifications
You must be signed in to change notification settings - Fork 252
Added two new coordinate systems: coord_polar and coord_radial.
#1059
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
iangow
wants to merge
24
commits into
has2k1:main
Choose a base branch
from
iangow:coord-polar
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
24 commits
Select commit
Hold shift + click to select a range
c68a347
Add coord_polar coordinate system
iangow c4ecd50
Rewrite coord_polar to use Matplotlib's native PolarAxes
iangow 8ca6379
Add radial coordinate system
iangow 14c26f6
Extend coord_radial: partial-arc fixes, thetalim/rlim, theta axis labels
iangow ab2d383
Add theta_labels parameter to coord_radial for full-circle theta axis…
iangow bd39a85
Add pad to theta tick labels to clear the outer circle spine
iangow 1fdb523
Expose theta_label_pad parameter on coord_radial
iangow 686d9e0
Fix theta_label_pad being overwritten by facet margin padding
iangow 8e57fe3
Fix theta breaks with negative angles causing partial-arc regression
iangow 56e0a8a
Fix start-angle rotation hiding bars for full-circle plots
iangow 7b28681
Hide polar spine when panel_border=element_blank()
iangow 24b48da
Set clip_on=False on geom_text artists in PolarAxes
iangow ac631b6
Transform polar segment endpoints
iangow 5b44427
Document polar coordinates
iangow 888b59f
Add coord polar and radial coverage tests
iangow ecc76b6
Fix polar coordinate type checking
iangow 13223cf
Remove coord polar notebook
iangow 87422c5
Move projection selection to coord classes
iangow 7a65e28
Move axes setup to coord classes
iangow 2b3e1cc
Move polar border blanking to themeable
iangow c493b3d
Document custom themeable extension path
iangow b2092d0
Added `guide_axis_theta()` (initial pass).
iangow d62df76
fix guide_axis_theta: use 'auto' mode so angle is relative to tangent
iangow cf8eb8f
Document guide_axis_theta semantics
iangow File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -13,3 +13,5 @@ objects.json | |
| objects.txt | ||
| objects.inv | ||
| gallery/thumbnails | ||
|
|
||
| **/*.quarto_ipynb | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,247 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import replace | ||
| from typing import TYPE_CHECKING, cast | ||
|
|
||
| import numpy as np | ||
|
|
||
| from ..iapi import panel_ranges | ||
| from .coord import coord, dist_euclidean | ||
|
|
||
| if TYPE_CHECKING: | ||
| import pandas as pd | ||
| from matplotlib.axes import Axes | ||
| from matplotlib.projections.polar import PolarAxes | ||
|
|
||
| from plotnine.iapi import panel_view | ||
| from plotnine.scales.scale import scale | ||
|
|
||
|
|
||
| class coord_polar(coord): | ||
| """ | ||
| Polar coordinate system | ||
|
|
||
| `coord_polar` maps one position aesthetic to the angle and the other | ||
| to the radius. It is commonly used for pie charts, which are stacked | ||
| bar charts in polar coordinates. | ||
|
|
||
| Parameters | ||
| ---------- | ||
| theta : | ||
| Which variable maps to the angle axis, ``"x"`` (default) or ``"y"``. | ||
| start : | ||
| Starting angle in radians, measured clockwise from 12 o'clock | ||
| (i.e. from the positive-y axis). Default 0. | ||
| direction : | ||
| ``1`` = clockwise (default), ``-1`` = counter-clockwise. | ||
| expand : | ||
| Add a small buffer around the data on the radius axis. | ||
| Default ``True``. | ||
|
|
||
| Notes | ||
| ----- | ||
| Unlike ggplot2, plotnine coordinate systems do not currently expose a | ||
| ``clip`` argument. | ||
|
|
||
| For partial arcs, donut charts, and theta/radius limits, use | ||
| ``coord_radial``. | ||
|
|
||
| Examples | ||
| -------- | ||
| A pie chart is a stacked bar chart with the y position mapped to angle. | ||
|
|
||
| ```python | ||
| import pandas as pd | ||
| from plotnine import aes, coord_polar, geom_col, ggplot | ||
|
|
||
| df = pd.DataFrame({ | ||
| "x": [1, 1, 1], | ||
| "y": [2, 3, 5], | ||
| "group": ["a", "b", "c"], | ||
| }) | ||
|
|
||
| ggplot(df, aes("x", "y", fill="group")) + geom_col() + coord_polar("y") | ||
| ``` | ||
| """ | ||
|
|
||
| is_linear = False | ||
| _projection = "polar" | ||
|
|
||
| def __init__( | ||
| self, | ||
| theta: str = "x", | ||
| start: float = 0, | ||
| direction: int = 1, | ||
| expand: bool = True, | ||
| ) -> None: | ||
| self.theta = theta | ||
| self.start = start | ||
| self.direction = direction | ||
| self.expand = expand | ||
| self.params: dict = {} | ||
|
|
||
| # ------------------------------------------------------------------ | ||
| # Panel params | ||
| # ------------------------------------------------------------------ | ||
|
|
||
| def setup_panel_params(self, scale_x: scale, scale_y: scale) -> panel_view: | ||
| from .coord_cartesian import coord_cartesian | ||
|
|
||
| # Theta fills exactly one full revolution — no expansion on that axis. | ||
| # R uses the caller-controlled expand flag. | ||
| pv_no_exp = coord_cartesian(expand=False).setup_panel_params( | ||
| scale_x, scale_y | ||
| ) | ||
| pv_exp = coord_cartesian(expand=self.expand).setup_panel_params( | ||
| scale_x, scale_y | ||
| ) | ||
|
|
||
| if self.theta == "x": | ||
| theta_range = pv_no_exp.x.range | ||
| r_sv = pv_exp.y | ||
| else: | ||
| theta_range = pv_no_exp.y.range | ||
| r_sv = pv_exp.x | ||
|
|
||
| self.params["theta_range"] = theta_range | ||
| self.params["r_range"] = r_sv.range | ||
|
|
||
| empty = np.array([], dtype=float) | ||
|
|
||
| # x → theta axis: data ticks are in original units (not radians), so | ||
| # suppress them. Limits span [start, start+2π] so that bars rotated | ||
| # by a non-zero start angle stay within the displayed theta range. | ||
| theta_start = float(self.start) | ||
| new_x = replace( | ||
| pv_exp.x, | ||
| limits=(theta_start, theta_start + 2 * np.pi), | ||
| range=(theta_start, theta_start + 2 * np.pi), | ||
| breaks=[], | ||
| minor_breaks=empty, | ||
| labels=[], | ||
| ) | ||
|
|
||
| # y → r axis: use the scale for the r dimension with its natural | ||
| # breaks. | ||
| new_y = replace(r_sv) | ||
|
|
||
| return replace(pv_exp, x=new_x, y=new_y) | ||
|
|
||
| # ------------------------------------------------------------------ | ||
| # Helpers | ||
| # ------------------------------------------------------------------ | ||
|
|
||
| def _to_radians(self, vals: np.ndarray) -> np.ndarray: | ||
| """Normalise data-space theta values to [start, start + 2π].""" | ||
| t_min, t_max = self.params["theta_range"] | ||
| denom = float(t_max) - float(t_min) | ||
| if denom == 0: | ||
| return np.zeros_like(vals, dtype=float) | ||
| norm = (np.asarray(vals, dtype=float) - float(t_min)) / denom | ||
| return self.start + self.direction * norm * 2.0 * np.pi | ||
|
|
||
| # ------------------------------------------------------------------ | ||
| # Data transformation | ||
| # ------------------------------------------------------------------ | ||
|
|
||
| def transform( | ||
| self, | ||
| data: pd.DataFrame, | ||
| panel_params: panel_view, | ||
| munch: bool = False, | ||
| ) -> pd.DataFrame: | ||
| # Munch first (in original data space) so curved edges get enough | ||
| # interpolation points before we convert theta → radians. | ||
| if munch: | ||
| data = self.munch(data, panel_params) | ||
|
|
||
| if self.theta == "x": | ||
| theta_col, r_col = "x", "y" | ||
| theta_end_col, r_end_col = "xend", "yend" | ||
| else: | ||
| theta_col, r_col = "y", "x" | ||
| theta_end_col, r_end_col = "yend", "xend" | ||
|
|
||
| if theta_col not in data.columns or r_col not in data.columns: | ||
| return data | ||
|
|
||
| data = data.copy() | ||
| data[theta_col] = self._to_radians(data[theta_col].to_numpy()) | ||
| has_endpoints = ( | ||
| theta_end_col in data.columns and r_end_col in data.columns | ||
| ) | ||
| if has_endpoints: | ||
| data[theta_end_col] = self._to_radians( | ||
| data[theta_end_col].to_numpy() | ||
| ) | ||
|
|
||
| # PolarAxes always expects x = theta (radians) and y = r. | ||
| # When theta = "y" we need to swap the columns. | ||
| if self.theta == "y": | ||
| data["x"], data["y"] = data["y"].copy(), data["x"].copy() | ||
| if has_endpoints: | ||
| data["xend"], data["yend"] = ( | ||
| data["yend"].copy(), | ||
| data["xend"].copy(), | ||
| ) | ||
|
|
||
| return data | ||
|
|
||
| # ------------------------------------------------------------------ | ||
| # Distance (used by munch, called before transform) | ||
| # ------------------------------------------------------------------ | ||
|
|
||
| def distance( | ||
| self, | ||
| x: pd.Series, | ||
| y: pd.Series, | ||
| panel_params: panel_view, | ||
| ) -> np.ndarray: | ||
| # Normalise theta and r to [0, 1] then compute Euclidean distance. | ||
| t_min, t_max = self.params["theta_range"] | ||
| r_min, r_max = self.params["r_range"] | ||
| t_denom = float(t_max - t_min) or 1.0 | ||
| r_denom = float(r_max - r_min) or 1.0 | ||
|
|
||
| if self.theta == "x": | ||
| theta_vals = np.asarray(x, dtype=float) | ||
| r_vals = np.asarray(y, dtype=float) | ||
| else: | ||
| theta_vals = np.asarray(y, dtype=float) | ||
| r_vals = np.asarray(x, dtype=float) | ||
|
|
||
| theta_norm = (theta_vals - float(t_min)) / t_denom | ||
| r_norm = (r_vals - float(r_min)) / r_denom | ||
| return dist_euclidean(theta_norm, r_norm) | ||
|
|
||
| def backtransform_range(self, panel_params: panel_view) -> panel_ranges: | ||
| t_range = tuple(self.params["theta_range"]) | ||
| r_range = tuple(self.params["r_range"]) | ||
| if self.theta == "x": | ||
| return panel_ranges(x=t_range, y=r_range) | ||
| return panel_ranges(x=r_range, y=t_range) | ||
|
|
||
| # ------------------------------------------------------------------ | ||
| # Draw decorations on PolarAxes | ||
| # ------------------------------------------------------------------ | ||
|
|
||
| def draw(self, axs: list[Axes]) -> None: | ||
| """Configure each PolarAxes: zero location, direction, r limits.""" | ||
| r_min, r_max = self.params.get("r_range", (0.0, 1.0)) | ||
|
|
||
| # Matplotlib PolarAxes theta_direction: -1 = clockwise, 1 = counter-CW. | ||
| mpl_direction = -1 if self.direction == 1 else 1 | ||
|
|
||
| for ax in axs: | ||
| polar_ax = cast("PolarAxes", ax) | ||
| polar_ax.set_theta_zero_location("N") # 12 o'clock = 0 | ||
| polar_ax.set_theta_direction(mpl_direction) | ||
| if np.isfinite(r_min) and np.isfinite(r_max) and r_min != r_max: | ||
| polar_ax.set_rlim(float(r_min), float(r_max)) | ||
|
|
||
| # ------------------------------------------------------------------ | ||
| # Misc | ||
| # ------------------------------------------------------------------ | ||
|
|
||
| def aspect(self, panel_params: panel_view) -> float: | ||
| return 1.0 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.