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 de9be48dd4..e5251d7107 100644 --- a/doc/_quartodoc.yml +++ b/doc/_quartodoc.yml @@ -403,6 +403,12 @@ quartodoc: contents: - aspect_ratio - axis_line + - axis_line_r + - axis_line_r_end + - axis_line_r_start + - axis_line_theta + - axis_line_theta_inside + - axis_line_theta_outside - axis_line_x - axis_line_x_bottom - axis_line_x_top @@ -410,6 +416,12 @@ quartodoc: - axis_line_y_left - axis_line_y_right - axis_text + - axis_text_r + - axis_text_r_end + - axis_text_r_start + - axis_text_theta + - axis_text_theta_inside + - axis_text_theta_outside - axis_text_x - axis_text_x_bottom - axis_text_x_top @@ -419,12 +431,22 @@ quartodoc: - axis_ticks - axis_ticks_length - axis_ticks_length_major + - axis_ticks_length_major_r + - axis_ticks_length_major_theta - axis_ticks_length_major_x - axis_ticks_length_major_y - axis_ticks_length_minor + - axis_ticks_length_minor_r + - axis_ticks_length_minor_theta - axis_ticks_length_minor_x - axis_ticks_length_minor_y - axis_ticks_major + - axis_ticks_major_r + - axis_ticks_major_r_end + - axis_ticks_major_r_start + - axis_ticks_major_theta + - axis_ticks_major_theta_inside + - axis_ticks_major_theta_outside - axis_ticks_major_x - axis_ticks_major_x_bottom - axis_ticks_major_x_top @@ -432,6 +454,12 @@ quartodoc: - axis_ticks_major_y_left - axis_ticks_major_y_right - axis_ticks_minor + - axis_ticks_minor_r + - axis_ticks_minor_r_end + - axis_ticks_minor_r_start + - axis_ticks_minor_theta + - axis_ticks_minor_theta_inside + - axis_ticks_minor_theta_outside - axis_ticks_minor_x - axis_ticks_minor_x_bottom - axis_ticks_minor_x_top @@ -565,6 +593,8 @@ quartodoc: - coord_equal - coord_fixed - coord_flip + - coord_polar + - coord_radial - coord_trans - title: Composing Plots diff --git a/doc/changelog.qmd b/doc/changelog.qmd index 83fd02d505..09182ed61a 100644 --- a/doc/changelog.qmd +++ b/doc/changelog.qmd @@ -11,6 +11,10 @@ title: Changelog transformation of the primary axis with [](:class:`~plotnine.sec_axis`), or as a mirror of the primary axis with [](:func:`~plotnine.dup_axis`). +- [](:class:`~plotnine.coord_radial`) can now draw an angular scale's secondary + axis around the inner rim. Add a [](:class:`~plotnine.sec_axis`) to the scale + and set `inner_radius > 0`. + - Added [](:class:`~plotnine.composition.plot_layout`) with which you can customise the layout of plots in composition. diff --git a/plotnine/__init__.py b/plotnine/__init__.py index 5b7a79c493..98507e5ece 100644 --- a/plotnine/__init__.py +++ b/plotnine/__init__.py @@ -20,6 +20,8 @@ coord_equal, coord_fixed, coord_flip, + coord_polar, + coord_radial, coord_trans, ) from .facets import ( @@ -291,6 +293,8 @@ "coord_equal", "coord_fixed", "coord_flip", + "coord_polar", + "coord_radial", "coord_trans", "dup_axis", "element_blank", diff --git a/plotnine/_mpl/_radial_axes.py b/plotnine/_mpl/_radial_axes.py new file mode 100644 index 0000000000..3677cc52da --- /dev/null +++ b/plotnine/_mpl/_radial_axes.py @@ -0,0 +1,501 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +import matplotlib.patches as mpatches +import matplotlib.transforms as mtransforms +import numpy as np +from matplotlib import cbook +from matplotlib.projections import register_projection +from matplotlib.projections.polar import ( + PolarAxes, + RadialAxis, + ThetaAxis, + _WedgeBbox, +) + +from ._radial_axis import ( + p9RadialAxis, + p9RadialTick, + p9SecondaryRadialAxis, + p9ThetaAxis, + p9ThetaTick, +) +from .axes import register_lim_changed_signal + +if TYPE_CHECKING: + from matplotlib.backend_bases import RendererBase + from numpy.typing import NDArray + + from plotnine.typing import PolarSide + + +class _TightWedgeBbox(_WedgeBbox): + """ + Wedge bounding box that hugs the sector instead of padding to a square + + matplotlib's `_WedgeBbox` pads the sector's bounding box out to a + square and centres the wedge in it, which — with a square panel — + leaves large empty margins around a partial arc. This subclass + reproduces the tight-box computation but omits that final padding, so + the sector fills the panel the layout engine has already shaped to the + same aspect. + """ + + def get_points(self) -> np.ndarray: + if self._invalid: + points = self._viewLim.get_points().copy() # pyright: ignore[reportAttributeAccessIssue] + points[:, 0] *= 180 / np.pi + if points[0, 0] > points[1, 0]: + points[:, 0] = points[::-1, 0] + points[:, 1] -= self._originLim.y0 # pyright: ignore[reportAttributeAccessIssue] + points[:, 1] *= 0.5 / points[1, 1] + width = min(points[1, 1] - points[0, 1], 0.5) + wedge = mpatches.Wedge( + self._center, # pyright: ignore[reportAttributeAccessIssue] + points[1, 1], + points[0, 0], + points[1, 0], + width=width, + ) + # `get_extents` evaluates the true curve extents; a plain + # `update_from_path` would union the wider Bezier control-point + # hull, which reaches back to the centre and over-widens the + # box for a narrow donut sector. + self._points = wedge.get_path().get_extents().get_points().copy() + self._invalid = 0 + return self._points + + +class _PanelWedge(mpatches.Wedge): + """ + Axes-background wedge that fills a wedge-shaped panel undistorted + + `PolarAxes.draw` recomputes the background wedge's centre, radius and + width on every draw in axes-fraction space, a computation that assumes + the axes box is square (it takes the radius from the x-scaling of + `transWedge` alone). In a wedge-shaped, non-square panel that renders the + background — and the clip path the geoms share — as an ellipse mismatched + with the data and the spine. This subclass ignores those geometry setters + and its transform setter, holding a fixed unit wedge whose transform the + axes sets once to `transWedge + transAxes` — the transform the `polar` + spine already uses — so the background traces the same arc as the data. + + Blocking `set_transform` is what keeps the geom clip path correct: the + geoms snapshot the patch's transform *object* when they capture their + clip, so it must be set once and never replaced. `Axes.clear` would + otherwise reset it to `transAxes` (a square wedge, which clips the geoms + to a squashed ellipse). `reshape` sets the real width through the + base-class setter, bypassing the no-op. + """ + + def set_center(self, center) -> None: + pass + + def set_radius(self, radius) -> None: + pass + + def set_width(self, width) -> None: + pass + + def set_transform(self, t) -> None: + pass + + def reshape(self, width: float | None) -> None: + """ + Set the unit-wedge geometry, bypassing the ignored setters + """ + mpatches.Wedge.set_center(self, (0.5, 0.5)) + mpatches.Wedge.set_radius(self, 0.5) + mpatches.Wedge.set_width(self, width) + + +class p9RadialAxes(PolarAxes): + """ + Polar axes that keeps the axis tick labels above the geom layers + """ + + name = "p9radial" + + _shared_axes = { + **PolarAxes._shared_axes, # pyright: ignore[reportAttributeAccessIssue] + "sec_r": cbook.Grouper(), + "sec_theta": cbook.Grouper(), + } + + _axis_at_side: dict[PolarSide, ThetaAxis | RadialAxis] | None = None + + # Secondary r-axis drawn at the end spoke; None until first requested. + _sec_raxis: p9RadialAxis | None = None + + # Secondary theta axis around the inner rim, or `None` before creation. + _sec_thetaaxis: p9ThetaAxis | None = None + + # Whether the r-axis ticks already keep their themed styling across + # matplotlib's tick resets. Guards `lock_raxis_tick_style` so repeated + # calls do not stack wrappers on top of each other. + _raxis_tick_style_locked: bool = False + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.set_theta_zero_location("N") # 12 o'clock + # All spines are opt-in + self.set_spine_visible("polar", False) + self.set_spine_visible("inner", False) + self.set_spine_visible("start", False) + self.set_spine_visible("end", False) + + def _init_axis(self) -> None: + """ + Polar panel axes with plotnine's theta and radial tick geometry + """ + self.xaxis = p9ThetaAxis(self, clear=False) + self.yaxis = p9RadialAxis(self, clear=False) + self.spines["polar"].register_axis(self.yaxis) + if inner_spine := self.spines.get("inner"): + inner_spine.register_axis(self.yaxis) + + def _gen_axes_patch(self) -> mpatches.Wedge: + """ + Background wedge that fills a wedge-shaped panel undistorted + + Returns a `_PanelWedge` in place of the stock `Wedge` so the axes + background (and the clip path the geoms share) traces the same arc + as the data even when the panel is not square. See `_PanelWedge`. + Its transform is fixed here to `transWedge + transAxes` (both exist + by the time the patch is generated) and cannot be replaced, so the + geoms clip to the true arc. See `_PanelWedge`. + """ + patch = _PanelWedge((0.5, 0.5), 0.5, 0.0, 360.0) + mpatches.Wedge.set_transform( + patch, + self.transWedge # pyright: ignore[reportAttributeAccessIssue] + + self.transAxes, + ) + return patch + + def _reshape_panel_wedge(self) -> None: + """ + Restore the background wedge's unit geometry after the square fit + + `_PanelWedge` ignores the square-fit geometry `PolarAxes.draw` + computes; this sets its true unit-wedge width (the donut hole) so the + background fills the wedge panel exactly. Its transform is fixed at + construction and never changes. + """ + patch = self.patch + if not isinstance(patch, _PanelWedge): + return + rscale = self.yaxis.get_transform() + rmin, rmax = ( + rscale.transform(self._realViewLim.intervaly) # pyright: ignore[reportAttributeAccessIssue] + - rscale.transform(self.get_rorigin()) + ) * self.get_rsign() + width = min(0.5 * (rmax - rmin) / rmax, 0.5) if rmax else 0.5 + patch.reshape(None if width == 0.5 else width) + + def apply_aspect(self, position=None) -> None: + """ + Shrink the layout cell to match the tight wedge's aspect, not 1.0 + + The stock `PolarAxes.apply_aspect` always shrinks the cell to a + square (aspect=1.0). That undoes the wedge-shaped cell the layout + engine assigns from `coord_radial.aspect` for partial arcs. This + override uses the tight wedge bbox to derive the actual aspect and + shrinks to that instead, so a full circle still gets a square cell + and a half-disc gets a 2:1 wide cell. + """ + if position is None: + position = self.get_position(original=True) + trans = self.get_figure(root=False).transSubfigure # pyright: ignore[reportOptionalMemberAccess] + bb = mtransforms.Bbox.unit().transformed(trans) + fig_aspect = bb.height / bb.width + pts = self.axesLim.get_points() # pyright: ignore[reportAttributeAccessIssue] + w = pts[1, 0] - pts[0, 0] + h = pts[1, 1] - pts[0, 1] + wedge_aspect = (h / w) if w > 0 else 1.0 + pb = position.frozen() + pb1 = pb.shrunk_to_aspect(wedge_aspect, pb, fig_aspect) + anchor = self.get_anchor() + self._set_position( # pyright: ignore[reportAttributeAccessIssue] + pb1.anchored(anchor, pb), # pyright: ignore[reportArgumentType] + "active", + ) + + def _set_lim_and_transforms(self) -> None: + """ + Build the polar transforms, then let the sector fill the panel + + Runs matplotlib's setup, then retypes the square-padding `axesLim` + bbox in place to `_TightWedgeBbox` so a partial arc hugs the panel + edges. Retyping the existing object -- rather than rebuilding it and + the transform stack -- means every transform the base method already + wired to it (`transData`, the theta/r axis transforms, and the tick + labels' cached copies of them) recomputes through the tight box + without any of them going stale. Safe to run at axes-construction + time: `_TightWedgeBbox` reads only the view/origin limits the base + method has already set, not any plotnine coordinate state. + """ + super()._set_lim_and_transforms() # pyright: ignore[reportAttributeAccessIssue] + self.axesLim.__class__ = _TightWedgeBbox # pyright: ignore[reportAttributeAccessIssue] + self.axesLim.invalidate() # pyright: ignore[reportAttributeAccessIssue] + + @property + def axis_at_side(self) -> dict[PolarSide, ThetaAxis | RadialAxis]: + """ + The theta/r axis artist occupying each active polar side + """ + if self._axis_at_side is None: + self._axis_at_side = {} + return self._axis_at_side + + @property + def is_full_circle(self) -> bool: + """ + Whether the panel spans a full circle + """ + span = abs(self.get_thetamax() - self.get_thetamin()) + return abs(span - 360.0) < 1e-12 + + def spoke_angle(self, side: PolarSide) -> float: + """ + Return an r boundary's spoke angle in data coordinates + + For a full circle, both r boundaries use the configured label spoke. + For an arc, `r_start` uses `thetamin` and `r_end` uses `thetamax`. + + Parameters + ---------- + side : + One of the panel's two r boundaries. + """ + if self.is_full_circle: + return np.deg2rad(self.get_rlabel_position()) + elif side == "r_start": + return np.deg2rad(self.get_thetamin()) + else: + return np.deg2rad(self.get_thetamax()) + + def outward_unit(self, side: PolarSide, loc: float) -> NDArray[np.float64]: + """ + Return the outward unit vector at a polar tick + + For a theta boundary, `loc` is the break angle and the vector follows + its radius. For an r boundary, the vector is perpendicular to the + boundary's spoke, so `loc` does not affect the result. Start and end + boundaries point in opposite directions. + + Parameters + ---------- + side : + Polar boundary containing the tick. + loc : + Break location in the axis's data coordinates. + """ + direction = self.get_theta_direction() + offset = self.get_theta_offset() + sign = 1 if side in ("theta_outside", "r_end") else -1 + + if side in ("theta_outside", "theta_inside"): + angle = loc * direction + offset + return sign * np.array([np.cos(angle), np.sin(angle)]) + + spoke = self.spoke_angle(side) * direction + offset + angle = spoke + sign * direction * np.pi / 2 + return np.array([np.cos(angle), np.sin(angle)]) + + def draw(self, renderer: RendererBase) -> None: + """ + Draw the axes, then lift the r-axis ticks above the geoms + + With the default `panel_ontop=False` (`axisbelow=True`) the normal + pass paints the gridlines, tick marks and tick labels below the + geoms. Re-drawing each r-axis's tick marks and labels keeps them + visible on top of opaque geoms and above the theta gridlines that + cross them on the shared full-circle spoke. + When `panel_ontop=True` everything is already above the geoms, so + the re-draw is a visual no-op. + """ + for tick in (*self.raxis.majorTicks, *self.raxis.minorTicks): + tick.update_position(tick.get_loc()) + + if self._sec_raxis: + for tick in ( + *self._sec_raxis.majorTicks, + *self._sec_raxis.minorTicks, + ): + tick.update_position(tick.get_loc()) + + # Reshape the background wedge before `super().draw` clips the geoms + # to it: `PolarAxes.draw` (inside the super call) recomputes the + # patch assuming a square box, then draws the geoms clipped to that + # squashed patch in the same call. `_PanelWedge` ignores those + # geometry setters, so setting the correct unit-wedge geometry and + # transform here survives the super call and clips the geoms to the + # true arc. + self._reshape_panel_wedge() + + super().draw(renderer) + + _redraw_raxis(self.raxis, renderer) + if self._sec_raxis: + _redraw_raxis(self._sec_raxis, renderer) + + for tick in (*self.thetaaxis.majorTicks, *self.thetaaxis.minorTicks): + if not isinstance(tick, p9ThetaTick): + continue + tick._position_labels(renderer) + for label in (tick.label1, tick.label2): + if label.get_visible(): + label.draw(renderer) + + @property + def thetaaxis(self) -> ThetaAxis: + return cast("ThetaAxis", self.xaxis) + + @property + def raxis(self) -> p9RadialAxis: + return cast("p9RadialAxis", self.yaxis) + + def add_sec_raxis(self) -> p9RadialAxis: + """ + Return the secondary r-axis, creating it once + + The axis starts with no ticks or labels; the caller sets them via + `set_ticks` / `set_ticklabels` and registers it in `axis_at_side` + under the spoke it occupies (opposite the primary). Its tick labels + are redrawn above geoms on every `draw` call, mirroring the primary + r-axis behaviour. + + Returns + ------- + : + The secondary radial axis. + """ + if self._sec_raxis: + return self._sec_raxis + + axis = p9SecondaryRadialAxis(self, clear=True) + # Register so mpl can resolve _get_axis_name(), and add to the + # draw tree. Mirrors the pattern in p9Axes._make_sec_axis. + self._axis_map["sec_r"] = axis + register_lim_changed_signal(self, "sec_r") + self.add_artist(axis) + axis.set_clip_on(False) + axis.grid(visible=False) + self._sec_raxis = axis + return axis + + def add_sec_thetaaxis(self) -> p9ThetaAxis: + """ + Return the secondary theta axis, creating it when needed + + A new axis has no ticks, labels, or grid lines. + + Returns + ------- + : + The secondary theta axis. + """ + if self._sec_thetaaxis: + return self._sec_thetaaxis + + axis = p9ThetaAxis(self, clear=True) + # Matplotlib resolves the axis name through `_axis_map`; the signal + # keeps its view limits synchronised with the polar axes. + self._axis_map["sec_theta"] = axis + register_lim_changed_signal(self, "sec_theta") + self.add_artist(axis) + axis.set_clip_on(False) + axis.grid(visible=False) + self._sec_thetaaxis = axis + return axis + + def set_spine_visible(self, name: str, visible: bool) -> None: + """ + Set a spine's visibility and block matplotlib's from overwriting it + + `PolarAxes.draw` derives `inner`/`start`/`end` spine visibility + from pure geometry (donut hole present, arc partial) and paints + the spine in that same call, so a plain `set_visible` on one of + these spines is overwritten and never matters. `polar` is not + subject to that override, but a caller has no way to tell which + spine names need the protection, so all four are routed through + this same method. + """ + # Set the spine to the requested visibility, then replace its + # own `set_visible` with a no-op so matplotlib's internal call + # cannot overwrite it before painting. + spine = self.spines[name] + spine_cls = type(spine) + spine_cls.set_visible(spine, visible) + + def noop(b): + pass + + spine.set_visible = noop + + def lock_raxis_tick_style(self) -> None: + """ + Keep the r-axis tick labels styled by the theme across draws + + The theme styles the tick labels by setting the font on their label + artists directly, so matplotlib's tick reset -- which `PolarAxes.draw` + runs on the r-axis -- would otherwise repaint them with its defaults. + The tick marks are not at risk: their colour, width and length go + through `set_tick_params`, which the reset preserves. Only the r-axis + is reset; the theta-axis is not. + """ + # Wrapping `reset_ticks` restores the styling that lives on the tick + # artists (rather than in `set_tick_params` state) after every reset. + # Reading it live from the pre-reset ticks on each call keeps this + # correct however many resets run: a reset before the theme is applied + # simply carries the default look forward. + if self._raxis_tick_style_locked: + return + self._raxis_tick_style_locked = True + + axis = self.raxis + reset_ticks = axis.reset_ticks + + def reset_ticks_and_restyle() -> None: + # `_copy_tick_props` `update_from`s each label and tick line, so it + # carries the full look of the pre-reset `majorTicks[0]` / + # `minorTicks[0]` onto the ticks the reset grows -- the label font + # included, which is the part `set_tick_params` cannot record. + major, minor = axis.majorTicks[0], axis.minorTicks[0] + reset_ticks() + axis._copy_tick_props(major, axis.majorTicks[0]) # type: ignore + axis._copy_tick_props(minor, axis.minorTicks[0]) # type: ignore + + axis.reset_ticks = reset_ticks_and_restyle + + +def _redraw_raxis(axis: p9RadialAxis, renderer: RendererBase) -> None: + """ + Redraw an r-axis's visible tick marks and labels on top of the geoms + + Only the pair on the axis's active spoke is visible, so redrawing every + visible mark/label lifts the right one whichever spoke it occupies: the + primary r-axis moves to the end spoke under `reverse="theta"`, swapping + with the secondary. Each mark draws before its label so a label never + sits under its own mark. + + Reposition each label before redrawing it because placement depends on + its rendered bounds. This preserves the measured position when the redraw + lifts the label above the geoms. + """ + for tick in (*axis.majorTicks, *axis.minorTicks): + if isinstance(tick, p9RadialTick): + tick._position_labels(renderer) + for mark, label in ( + (tick.tick1line, tick.label1), + (tick.tick2line, tick.label2), + ): + if mark.get_visible(): + mark.draw(renderer) + if label.get_visible(): + label.draw(renderer) + + +register_projection(p9RadialAxes) diff --git a/plotnine/_mpl/_radial_axis.py b/plotnine/_mpl/_radial_axis.py new file mode 100644 index 0000000000..5947d86a77 --- /dev/null +++ b/plotnine/_mpl/_radial_axis.py @@ -0,0 +1,347 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, cast + +import numpy as np +from matplotlib import artist as martist +from matplotlib import markers as mmarkers +from matplotlib.projections.polar import ( + RadialAxis, + RadialTick, + ThetaAxis, + ThetaTick, +) +from matplotlib.transforms import Affine2D, Bbox, ScaledTranslation + +if TYPE_CHECKING: + from matplotlib.backend_bases import RendererBase + from matplotlib.lines import Line2D + from matplotlib.text import Text + from matplotlib.transforms import Transform + from numpy.typing import NDArray + + from plotnine.typing import PolarSide + + from ._radial_axes import p9RadialAxes + + +_NON_DESCENDING_NUMERIC_CHARS = frozenset("0123456789.+-\N{MINUS SIGN}eE") + + +def label_bounds(label: Text, renderer: RendererBase) -> Bbox: + """ + Return label bounds with unused numeric descent removed + + Matplotlib reserves the font's full descent even when a numeric label + has no descenders. Excluding that space keeps the visible gap between + labels and ticks consistent around the panel. + """ + bbox, parts, _ = label._get_layout(renderer) # pyright: ignore[reportAttributeAccessIssue] + descent = parts[-1][1][2] + x, y = label.get_unitless_position() + x, y = label.get_transform().transform((x, y)) + bbox = bbox.translated(x, y) + + text = label.get_text() + _, ismath = label._preprocess_math(text) # pyright: ignore[reportAttributeAccessIssue] + # Exclude the unused font descent from simple numeric labels without + # measuring rendered glyphs. + if ( + text + and "\n" not in text + and ismath is False + and set(text) <= _NON_DESCENDING_NUMERIC_CHARS + ): + bbox = Bbox.from_extents(bbox.x0, bbox.y0 + descent, bbox.x1, bbox.y1) + + return bbox + + +def facing_point(box: Bbox, unit: NDArray[np.float64]) -> NDArray[np.float64]: + """ + Return the point on a label's bounds that faces the panel + + `unit` points away from the panel. The opposite point is an edge midpoint + for an axis-aligned vector and moves towards a corner between axes. + """ + ux, uy = unit + return np.array( + [ + box.x0 + (0.5 - ux / 2) * box.width, + box.y0 + (0.5 - uy / 2) * box.height, + ] + ) + + +class p9ThetaTick(ThetaTick): + """ + Theta tick whose labels sit on the rim of their matching tick marks + """ + + def _get_text1_transform(self) -> tuple[Transform, str, str]: + # matplotlib anchors the theta labels through a flipr-reversed + # transform, sending label1 to tick2line's radius and label2 to + # tick1line's. Use the unflipped tick transform so label1 shares + # tick1line's inner radius instead. + return self.axes.get_xaxis_transform("tick1"), "center", "center" + + def _get_text2_transform(self) -> tuple[Transform, str, str]: + # Counterpart to _get_text1_transform: label2 shares tick2line's + # outer radius. + return self.axes.get_xaxis_transform("tick2"), "center", "center" + + def _update_padding(self, pad: float, angle: float) -> None: + # The rendered label extent replaces matplotlib's fixed allowance. + self._radial_angle = angle + self._text1_translate._t = (0, 0) # pyright: ignore[reportAttributeAccessIssue] + self._text1_translate.invalidate() # pyright: ignore[reportAttributeAccessIssue] + self._text2_translate._t = (0, 0) # pyright: ignore[reportAttributeAccessIssue] + self._text2_translate.invalidate() # pyright: ignore[reportAttributeAccessIssue] + + def _position_labels(self, renderer: RendererBase) -> None: + # Matplotlib pads theta labels from their centres, so the visible gap + # varies with the label's angle. Place the point facing the panel at + # the themed distance beyond its own tick. + axes = cast("p9RadialAxes", self.axes) + loc = self.get_loc() + + pairs: tuple[ + tuple[Text, Line2D, ScaledTranslation, PolarSide], ... + ] = ( + ( + self.label1, + self.tick1line, + self._text1_translate, # pyright: ignore[reportAttributeAccessIssue] + "theta_inside", + ), + ( + self.label2, + self.tick2line, + self._text2_translate, # pyright: ignore[reportAttributeAccessIssue] + "theta_outside", + ), + ) + + for label, tickline, translate, side in pairs: + if not label.get_visible() or not label.get_text(): + continue + + label.set_horizontalalignment("center") + label.set_verticalalignment("center") + tick_length = ( + self._size # pyright: ignore[reportAttributeAccessIssue] + if tickline.get_visible() + else 0 + ) + base_pad = ( + self._base_pad # pyright: ignore[reportAttributeAccessIssue] + + tick_length + ) + unit = axes.outward_unit(side, loc) + + # Apply the padding at the label's centre, then correct the offset + # so the point facing the panel reaches the same target. + padded = unit * base_pad / 72 + translate._t = tuple(padded) + translate.invalidate() + + target = label.get_transform().transform(label.get_position()) + point = facing_point(label_bounds(label, renderer), unit) + translate._t = tuple(padded + (target - point) / axes.figure.dpi) + translate.invalidate() + + @martist.allow_rasterization + def draw(self, renderer: RendererBase) -> None: + self._position_labels(renderer) + super().draw(renderer) + + +class p9ThetaAxis(ThetaAxis): + """ + Theta axis whose labels follow their matching tick marks + """ + + _tick_class = p9ThetaTick + + +class p9RadialTick(RadialTick): + """ + Radial tick whose mark and label share the start-spoke sweep side + """ + + # Which side of the shared full-circle spoke this axis's ticks occupy, + # as a signed perpendicular (`-1` / `+1`). The primary and secondary + # r-axes take opposite sides so their marks and labels never overlap. + # The sign also selects the tick/label pair: the positive side draws the + # end pair (`label2`/`tick2line`, the `r_end` side theming uses), the + # negative side the start pair. + _tick_side: int = -1 + + def __init__(self, axes, *args, **kwargs): + # `RadialTick` has no label translations. Add one to each label + # transform so `draw` can position the pair from its measured bounds. + figure = axes.get_figure(root=False) + self._text1_translate = ScaledTranslation(0, 0, figure.dpi_scale_trans) + self._text2_translate = ScaledTranslation(0, 0, figure.dpi_scale_trans) + super().__init__(axes, *args, **kwargs) + + def _placements( + self, + ) -> list[tuple[PolarSide, Text, Line2D, ScaledTranslation]]: + """ + Return the label placement for each active r boundary + + An arc places one pair on each end spoke. A full circle places both + pairs on one spoke, where this axis draws the pair selected by + `_tick_side`. + """ + start: tuple[PolarSide, Text, Line2D, ScaledTranslation] = ( + "r_start", + self.label1, + self.tick1line, + self._text1_translate, + ) + end: tuple[PolarSide, Text, Line2D, ScaledTranslation] = ( + "r_end", + self.label2, + self.tick2line, + self._text2_translate, + ) + if not cast("p9RadialAxes", self.axes).is_full_circle: + return [start, end] + return [end] if self._tick_side > 0 else [start] + + def _pad_offset(self, unit: NDArray[np.float64]) -> NDArray[np.float64]: + """ + Return the pre-measurement translation from the spoke + """ + # `self._pad` includes the tick length and the gap beyond the mark. + return unit * self._pad / 72 # pyright: ignore[reportAttributeAccessIssue] + + def update_position(self, loc: float) -> None: + # Matplotlib's discrete text alignments make the gap depend on the + # spoke angle. Centre each label on its spoke and pad it in display + # space. `draw` then aligns its measured edge with that padded point. + + # On a full circle, Matplotlib hides the second tick and label pair + # because both pairs occupy one spoke. The secondary r-axis uses that + # pair on the opposite side, so preserve its visibility across the + # base position update. + want_label = self.label2.get_visible() + want_tick = self.tick2line.get_visible() + + super().update_position(loc) + + axes = cast("p9RadialAxes", self.axes) + + for side, label, tickline, translate in self._placements(): + unit = axes.outward_unit(side, loc) + + if axes.is_full_circle: + if side == "r_end": + label.set_visible(want_label) + tickline.set_visible(want_tick) + + # On a full circle, the end tick anchors at `thetamax` + # (`x = 1`) and diverges from the shared start spoke as the + # radius grows. Move it to `x = 0`, where the start tick + # already anchors. + tickline.set_xdata([0]) + + # Replace the marker's base transform so a one-sided tick + # points outwards. `MarkerStyle.transformed()` would compose + # with the existing `TICKLEFT` or `TICKRIGHT` transform. + # + # The outward angle distinguishes primary and secondary ticks + # by 180°. `TICKLEFT` and `TICKRIGHT` share the same path, so + # rotate both directly. Applying `TICKRIGHT`'s horizontal flip + # would place the secondary mark on the primary side. + marker_angle = np.arctan2(unit[1], unit[0]) + marker = tickline.get_marker() + tick_marker = cast("mmarkers.MarkerStyle", tickline._marker) # pyright: ignore[reportAttributeAccessIssue] + if marker in (mmarkers.TICKLEFT, mmarkers.TICKRIGHT): + transform = Affine2D().rotate(marker_angle) + elif marker == "_": + transform = Affine2D().rotate(marker_angle + np.pi / 2) + else: + transform = tick_marker._transform # pyright: ignore[reportAttributeAccessIssue] + tick_marker._transform = transform # pyright: ignore[reportAttributeAccessIssue] + + # For a full circle, the base transform interprets x as an + # angle. Set x to the shared spoke. For an arc, it interprets + # x as a sweep fraction and already places each pair on an + # end spoke. + label.set_x(0) + + # Request the base transform without padding, then add a fixed + # display-space offset. Otherwise Matplotlib derives an arc + # label's padding direction from the view limits, which can point + # away from its spoke after the sweep reverses. + base = ( + axes.get_yaxis_text2_transform(0)[0] + if side == "r_end" + else axes.get_yaxis_text1_transform(0)[0] + ) + label.set_transform(base + translate) + label.set_horizontalalignment("center") + label.set_verticalalignment("center") + translate._t = tuple(self._pad_offset(unit)) # pyright: ignore[reportAttributeAccessIssue] + translate.invalidate() + + def _position_labels(self, renderer: RendererBase) -> None: + # Recompute from the initial pad offset on every draw so translations + # do not accumulate across repeated draws. + axes = cast("p9RadialAxes", self.axes) + loc = self.get_loc() + + for side, label, _, translate in self._placements(): + if not label.get_visible() or not label.get_text(): + continue + + unit = axes.outward_unit(side, loc) + padded = self._pad_offset(unit) + translate._t = tuple(padded) # pyright: ignore[reportAttributeAccessIssue] + translate.invalidate() + + target = label.get_transform().transform(label.get_position()) + point = facing_point(label_bounds(label, renderer), unit) + translate._t = tuple(padded + (target - point) / axes.figure.dpi) # pyright: ignore[reportAttributeAccessIssue] + translate.invalidate() + + @martist.allow_rasterization + def draw(self, renderer: RendererBase) -> None: + self._position_labels(renderer) + super().draw(renderer) + + +class p9SecondaryRadialTick(p9RadialTick): + """ + Full-circle secondary r-axis tick mirrored across the shared spoke + + The secondary r-axis shares the primary's single full-circle spoke, so + it sits on the opposite side of it. That side draws the end tick/label + pair — the same pair theming resolves for the `r_end` side — keeping its + mark and label clear of the primary's. + """ + + _tick_side = 1 + + +class p9RadialAxis(RadialAxis): + """ + Radial axis whose ticks follow plotnine's start-spoke geometry + """ + + _tick_class = p9RadialTick + + def _copy_tick_props(self, src: p9RadialTick, dest: p9RadialTick) -> None: + super()._copy_tick_props(src, dest) # pyright: ignore[reportAttributeAccessIssue] + dest.update_position(dest.get_loc()) + + +class p9SecondaryRadialAxis(p9RadialAxis): + """ + Secondary radial axis mirrored across the primary's full-circle spoke + """ + + _tick_class = p9SecondaryRadialTick diff --git a/plotnine/_mpl/axes.py b/plotnine/_mpl/axes.py index 3fc712b968..8426671e5c 100644 --- a/plotnine/_mpl/axes.py +++ b/plotnine/_mpl/axes.py @@ -6,9 +6,10 @@ from matplotlib.axes import Axes from matplotlib.axis import XAxis, YAxis from matplotlib.projections import register_projection +from matplotlib.projections.polar import PolarAxes if TYPE_CHECKING: - from plotnine.typing import Side + from plotnine.typing import PolarSide, Side AxisT = TypeVar("AxisT", XAxis, YAxis) @@ -108,6 +109,7 @@ def add_sec_axis(self, side: Side) -> XAxis | YAxis: def _make_sec_axis(self, cls: type[AxisT]) -> AxisT: axis = cls(self) + register_lim_changed_signal(self, f"sec_{axis.axis_name}") # Add the axis to the draw tree. Plotnine does not clear the panel # after this point because clearing it would detach the secondary axis. self.add_artist(axis) @@ -116,34 +118,75 @@ def _make_sec_axis(self, cls: type[AxisT]) -> AxisT: return axis -def axis_at(ax: Axes, side: Side) -> XAxis | YAxis | None: +def register_lim_changed_signal(ax: Axes, axis_name: str) -> None: """ - Return the axis of `ax` whose ticks occupy `side`, if any + Register the `lim_changed` callback signal on a panel - Considers the primary axis of the side's dimension and, on a - `p9Axes`, the secondary one. + Setting fixed ticks on an axis makes matplotlib expand its view + interval, and when that interval actually changes it fires a + `f"{axis._get_axis_name()}lim_changed"` callback. A panel's + `CallbackRegistry` only knows the built-in `x`/`y`/`z` signals, so a + secondary axis registered under a name like `sec_x` or `sec_r` would + raise `ValueError` on that lookup. Adding the matching signal lets the + callback resolve to a no-op (nothing is connected to it). + + Parameters + ---------- + ax : + Panel axes owning the callback registry. + axis_name : + Name the axis is registered under in `ax._axis_map`. + """ + signal = f"{axis_name}lim_changed" + signals = ax.callbacks._signals # pyright: ignore[reportAttributeAccessIssue] + if signals is not None and signal not in signals: + signals.append(signal) + + +def axis_at(ax: Axes, side: Side | PolarSide) -> XAxis | YAxis | None: + """ + Return the axis whose ticks occupy `side`, if any + + For a cartesian `side`, considers the primary axis of the side's + dimension and, on a `p9Axes`, the secondary one, via mpl's own + tick-flag state — reliable for `XAxis`/`YAxis`. For a polar `side` + (a theta/r side), reads `p9RadialAxes.axis_at_side` instead: + `ThetaAxis`/`RadialAxis` can't self-report which side they + occupy through `get_tick_params()`, so plotnine tracks it + explicitly there. + + Cartesian sides never resolve on polar axes. Polar tick flags describe + theta and r placement rather than box edges. For example, a full-circle + r axis reports `left=True` even though it does not occupy the left edge + of a Cartesian panel. Parameters ---------- ax : Panel axes. side : - Side of the panel. + Side of a cartesian panel, or side of a polar one. Returns ------- : The axis with active ticks on `side`, or `None` when that side - shows no ticks (e.g. an interior facet panel). + shows no ticks (e.g. an interior facet panel, or a polar + side nothing occupies). """ - if side in ("top", "bottom"): - candidates = (ax.xaxis, getattr(ax, "sec_xaxis", None)) - else: - candidates = (ax.yaxis, getattr(ax, "sec_yaxis", None)) - for axis in candidates: - if axis and axis.get_tick_params(which="major").get(side): - return axis - return None + if side in ("top", "bottom", "left", "right"): + if isinstance(ax, PolarAxes): + return None + candidates = ( + (ax.xaxis, getattr(ax, "sec_xaxis", None)) + if side in ("top", "bottom") + else (ax.yaxis, getattr(ax, "sec_yaxis", None)) + ) + for axis in candidates: + if axis and axis.get_tick_params(which="major").get(side): + return axis + return None + return getattr(ax, "axis_at_side", {}).get(side) register_projection(p9Axes) diff --git a/plotnine/_mpl/layout_manager/_plot_layout_items.py b/plotnine/_mpl/layout_manager/_plot_layout_items.py index 22073e3fef..5677b4ed56 100644 --- a/plotnine/_mpl/layout_manager/_plot_layout_items.py +++ b/plotnine/_mpl/layout_manager/_plot_layout_items.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from itertools import chain -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from matplotlib.text import Text @@ -31,6 +31,7 @@ from matplotlib.axes import Axes from matplotlib.axis import Axis, Tick + from matplotlib.backend_bases import RendererBase from matplotlib.figure import Figure from matplotlib.lines import Line2D from matplotlib.patches import Rectangle @@ -38,6 +39,7 @@ from matplotlib.transforms import Bbox, Transform from plotnine import ggplot + from plotnine._mpl._radial_axes import p9RadialAxes from plotnine._mpl.offsetbox import FlexibleAnchoredOffsetbox from plotnine._mpl.text import StripText from plotnine.iapi import legend_artists @@ -45,6 +47,7 @@ from plotnine.themes.theme import theme from plotnine.typing import ( HorizontalJustification, + PolarSide, Side, StripPosition, VerticalJustification, @@ -73,6 +76,98 @@ ) +# Polar boundaries that may carry a theta or r axis. +POLAR_SIDES: tuple[PolarSide, ...] = ( + "theta_outside", + "theta_inside", + "r_start", + "r_end", +) + + +@dataclass +class PolarLabel: + """ + A tick label of a polar panel, and where it attaches to the panel + + Sizes are in display space. `anchor` is in panel fractions, which hold + whatever size the panel settles at. + """ + + anchor: tuple[float, float] + """Point on the panel the label is placed against""" + + unit: tuple[float, float] + """Outward unit vector at the label's anchor""" + + width: float + """Width of the label""" + + height: float + """Height of the label""" + + gap: float + """Distance the label is held off its anchor""" + + @classmethod + def make( + cls, + ax: p9RadialAxes, + side: PolarSide, + loc: float, + tick: Tick, + label: Text, + renderer: RendererBase, + ) -> PolarLabel: + """ + The label at one tick of a polar axis + + A theta tick anchors on its own boundary at the angle of its break; + an r tick anchors along its spoke at the radius of its break. The + gap covers the tick mark the label clears and the pad beyond it. The + unit vector points from the panel towards the label. + """ + r_inner, r_outer = ax.get_ylim() + if side == "theta_outside": + point = (loc, r_outer) + elif side == "theta_inside": + point = (loc, r_inner) + else: + point = (ax.spoke_angle(side), loc) + + box = label.get_window_extent(renderer) + anchor = ax.transAxes.inverted().transform( + ax.transData.transform(point) + ) + unit = ax.outward_unit(side, loc) + gap_pt = (tick.get_pad() or 0) + tick.get_tick_padding() + return cls( + anchor=(anchor[0], anchor[1]), + unit=(unit[0], unit[1]), + width=box.width, + height=box.height, + gap=gap_pt * ax.figure.dpi / 72, + ) + + def reach_past(self, side: Side, panel: Bbox) -> float: + """ + How far the label reaches past one edge of the panel, display space + + The direction component towards the edge determines how much of the + label and gap extend past it. A label parallel to the edge contributes + half its extent and none of its gap. A negative result means the label + stops inside the edge. + """ + if side in ("left", "right"): + along, extent, span = self.anchor[0], self.width, panel.width + facing = self.unit[0] if side == "right" else -self.unit[0] + else: + along, extent, span = self.anchor[1], self.height, panel.height + facing = self.unit[1] if side == "top" else -self.unit[1] + inset = along if side in ("left", "bottom") else 1 - along + return extent * (1 + facing) / 2 + self.gap * facing - inset * span + + @dataclass class StripSpec: """ @@ -363,9 +458,19 @@ def strip_shift(self, st: StripText) -> float: panel, so within one facet the strips of axis-bearing panels shift while the others do not. """ - theme = self.plot.theme - if theme.getp("strip_placement") != "outside": + if self.plot.theme.getp("strip_placement") != "outside": return 0 + return self._strip_axis_clearance(st) + + def _strip_axis_clearance(self, st: StripText) -> float: + """ + Extent of the axis a strip must clear to sit beyond it, figure space + + The ticks, tick labels and panel-facing text margin its own panel + draws on the strip's side, plus the strip_switch_pad. Zero when the + panel draws no axis there. + """ + theme = self.plot.theme side, ax = st.position, st.ax W, H = theme.getp("figure_size") if side in ("top", "bottom"): @@ -903,6 +1008,100 @@ def _position_strip_text(self, st: StripText): st.set_position((x, y)) +class PolarPlotLayoutItems(PlotLayoutItems): + """ + Space around a polar panel for its axis decorations + + A theta axis follows an arc and an r axis follows a spoke, so a tick + label attaches to the panel wherever its break falls rather than along + one Cartesian side. Each side reserves the labels that reach past it: + a label's size and the gap that holds it off its boundary, less however + much of that reach falls inside the panel. + + The size and the gap are physical, so they hold wherever the panel ends + up. The inset is a fraction of the panel, and the panel is what the + layout is solving for, so it comes from the previous render. A first + render therefore over-reserves, which costs a little space and clips + nothing. + + The band is reported as text clearance, so tick clearance stays zero + rather than counting the marks twice. Per-side label protrusions stay + zero too: a label reaching past a second edge is counted on that edge. + """ + + def _axis_clearance(self, ax: Axes, side: Side) -> float: + """ + Space the panel's axes need past one edge, figure space + """ + panel = ax.get_window_extent(self.geometry.renderer) + reaches = [ + label.reach_past(side, panel) for label in self._outward_labels(ax) + ] + figure = self.plot.figure.bbox + span = figure.width if side in ("left", "right") else figure.height + return max([*reaches, 0]) / span + + def _outward_labels(self, ax: Axes) -> Iterator[PolarLabel]: + """ + Every tick label the panel will show, with where it attaches + + Each boundary contributes only the label pair assigned to that side. + """ + renderer = self.geometry.renderer + panel = cast("p9RadialAxes", ax) + for polar_side in POLAR_SIDES: + axis = axis_at(ax, polar_side) + if axis is None: + continue + _, label_attr = side_artists(polar_side) + for locs, ticks in ( + (axis.get_majorticklocs(), axis.get_major_ticks()), + (axis.get_minorticklocs(), axis.get_minor_ticks()), + ): + for loc, tick in zip(locs, ticks): + label = getattr(tick, label_attr) + if _text_is_visible(label): + yield PolarLabel.make( + panel, polar_side, loc, tick, label, renderer + ) + + def axis_ticks_x_max_height(self, ax: Axes, side: Side) -> float: + return 0 + + def axis_text_x_max_height(self, ax: Axes, side: Side) -> float: + return self._axis_clearance(ax, side) + + def axis_ticks_y_max_width(self, ax: Axes, side: Side) -> float: + return 0 + + def axis_text_y_max_width(self, ax: Axes, side: Side) -> float: + return self._axis_clearance(ax, side) + + def axis_text_y_top_protrusion(self, location: AxesLocation) -> float: + return 0.0 + + def axis_text_y_bottom_protrusion(self, location: AxesLocation) -> float: + return 0.0 + + def axis_text_x_left_protrusion(self, location: AxesLocation) -> float: + return 0.0 + + def axis_text_x_right_protrusion(self, location: AxesLocation) -> float: + return 0.0 + + def strip_shift(self, st: StripText) -> float: + """ + Outward shift of a strip past the polar decorations, figure space + + A polar panel draws its theta and r axes on the arc perimeter, + so there is no room between the panel and its axis for a strip to + sit "inside". The strip therefore always clears the decorations, + as it does for `strip_placement="outside"` on a Cartesian panel, + regardless of the theme's `strip_placement`. + """ + return self._strip_axis_clearance(st) + + def _spine_set_position_outward(spine: Spine, axis: Axis, distance: float): """ Move a spine and its tick marks outward, keeping the theme's tick styling diff --git a/plotnine/_mpl/layout_manager/_plot_side_space.py b/plotnine/_mpl/layout_manager/_plot_side_space.py index 0d7166794f..83bc5f5e83 100644 --- a/plotnine/_mpl/layout_manager/_plot_side_space.py +++ b/plotnine/_mpl/layout_manager/_plot_side_space.py @@ -15,10 +15,11 @@ from functools import cached_property from typing import TYPE_CHECKING, Literal, cast +from plotnine.coords import coord_radial from plotnine.exceptions import PlotnineError from plotnine.facets import facet_grid, facet_null, facet_wrap -from ._plot_layout_items import PlotLayoutItems +from ._plot_layout_items import PlotLayoutItems, PolarPlotLayoutItems from ._side_space import GridSpecParams, _side_space if TYPE_CHECKING: @@ -909,7 +910,12 @@ def __init__(self, plot: ggplot): self.plot = plot self.gridspec = plot._gridspec self.sub_gridspec = plot._sub_gridspec - self.items = PlotLayoutItems(plot) + items_cls = ( + PolarPlotLayoutItems + if isinstance(plot.coordinates, coord_radial) + else PlotLayoutItems + ) + self.items = items_cls(plot) self.l = left_space(self.items) """All subspaces to the left of the panels""" @@ -1188,6 +1194,36 @@ def _calculate_panel_spacing(self) -> GridSpecParams: hspace, ) + def _polar_gulley_clearance(self) -> tuple[float, float]: + """ + Extra gulley space a polar panel's decorations need, `(sw, sh)` + + A polar panel draws its full theta and r axes on every panel, + not just the ones on the grid's edge, so their ticks and labels + reach into the gulleys on all four sides. Reserve room for both + the left and right decorations in the horizontal gulley and the + top and bottom decorations in the vertical one. As on the + Cartesian free-scale path, the tick-label extent is bare and the + tick-label margin is added here. + """ + items = self.items + theme = self.plot.theme + sw = 0.0 + for side in ("left", "right"): + text = items.axis_text_y_max_width_at("all", side) + if text: + m = theme.get_margin(f"axis_text_y_{side}").fig + text += m.l + m.r + sw += text + items.axis_ticks_y_max_width_at("all", side) + sh = 0.0 + for side in ("top", "bottom"): + text = items.axis_text_x_max_height_at("all", side) + if text: + m = theme.get_margin(f"axis_text_x_{side}").fig + text += m.t + m.b + sh += text + items.axis_ticks_x_max_height_at("all", side) + return sw, sh + def _calculate_panel_spacing_facet_grid(self) -> tuple[float, float]: """ Calculate spacing parts for facet_grid @@ -1203,6 +1239,11 @@ def _calculate_panel_spacing_facet_grid(self) -> tuple[float, float]: self.sw = theme.getp("panel_spacing_x") self.sh = theme.getp("panel_spacing_y") * self.W / self.H + if isinstance(self.plot.coordinates, coord_radial): + extra_sw, extra_sh = self._polar_gulley_clearance() + self.sw += extra_sw + self.sh += extra_sh + # width and height of axes as fraction of figure width & height self.w = (self.panel_width - self.sw * (ncol - 1)) / ncol self.h = (self.panel_height - self.sh * (nrow - 1)) / nrow @@ -1241,26 +1282,33 @@ def _calculate_panel_spacing_facet_wrap(self) -> tuple[float, float]: else: self.sw += space.strip_text + space.strip_switch_pad - # Per-panel axes claim their ticks, labels and label margins in - # the gullies. - if facet.free["x"]: - for side in ("bottom", "top"): - text = self.items.axis_text_x_max_height_at("all", side) - if text: - m = theme.get_margin(f"axis_text_x_{side}").fig - text += m.t + m.b - self.sh += text + self.items.axis_ticks_x_max_height_at( - "all", side - ) - if facet.free["y"]: - for side in ("left", "right"): - text = self.items.axis_text_y_max_width_at("all", side) - if text: - m = theme.get_margin(f"axis_text_y_{side}").fig - text += m.l + m.r - self.sw += text + self.items.axis_ticks_y_max_width_at( - "all", side - ) + # A polar panel draws its full theta and r axes on every panel, + # so its decorations reach into the gullies regardless of free. + # A cartesian panel only shows them on the grid's edge, so per-panel + # axes claim gulley space only where the scales are free. + if isinstance(self.plot.coordinates, coord_radial): + extra_sw, extra_sh = self._polar_gulley_clearance() + self.sw += extra_sw + self.sh += extra_sh + else: + if facet.free["x"]: + for side in ("bottom", "top"): + text = self.items.axis_text_x_max_height_at("all", side) + if text: + m = theme.get_margin(f"axis_text_x_{side}").fig + text += m.t + m.b + self.sh += text + self.items.axis_ticks_x_max_height_at( + "all", side + ) + if facet.free["y"]: + for side in ("left", "right"): + text = self.items.axis_text_y_max_width_at("all", side) + if text: + m = theme.get_margin(f"axis_text_y_{side}").fig + text += m.l + m.r + self.sw += text + self.items.axis_ticks_y_max_width_at( + "all", side + ) # width and height of axes as fraction of figure width & height self.w = (self.panel_width - self.sw * (ncol - 1)) / ncol diff --git a/plotnine/_utils/__init__.py b/plotnine/_utils/__init__.py index 0aebd502b1..9839d1d360 100644 --- a/plotnine/_utils/__init__.py +++ b/plotnine/_utils/__init__.py @@ -35,6 +35,7 @@ FloatArray, FloatArrayLike, HorizontalJustification, + PolarSide, Side, VerticalJustification, ) @@ -61,14 +62,15 @@ to_rgba = color_utils.to_rgba -def side_artists(side: str) -> tuple[str, str]: +def side_artists(side: Side | PolarSide) -> tuple[str, str]: """ - Return the `(tickline, label)` tick-attribute names for an axis side + Return the (tickline, label) attribute names for one side of an axis - The bottom/left side maps to `tick1line`/`label1` and the top/right side - to `tick2line`/`label2`. + `"top"`/`"right"` (and their polar counterparts `"theta_outside"`/ + `"r_end"`) use the second tick/label pair; every other side uses the + first. """ - if side in ("top", "right"): + if side in ("top", "right", "theta_outside", "r_end"): return ("tick2line", "label2") return ("tick1line", "label1") @@ -84,12 +86,18 @@ def side_artists(side: str) -> tuple[str, str]: # The margin side that faces inward for an element on each side: for an axis # the side facing the panel (bottom axis -> top "t", top -> "b", left -> right # "r", right -> "l"); for a legend title/text the side facing the keys. It is -# the initial of the opposite side; cf. OPPOSITE_SIDE. -MARGIN_SIDE: dict[Side, str] = { +# the initial of the opposite side; cf. OPPOSITE_SIDE. Polar boundaries reuse +# the cartesian side their tick/label pair matches in side_artists: outside +# and start behave like bottom/left, inside and end like top/right. +MARGIN_SIDE: dict[Side | PolarSide, str] = { "bottom": "t", "top": "b", "left": "r", "right": "l", + "theta_outside": "t", + "theta_inside": "b", + "r_start": "r", + "r_end": "l", } diff --git a/plotnine/coords/__init__.py b/plotnine/coords/__init__.py index d49f9f8ff2..6c16635bff 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_radial import coord_polar, coord_radial from .coord_trans import coord_trans __all__ = ( @@ -12,5 +13,7 @@ "coord_fixed", "coord_equal", "coord_flip", + "coord_polar", + "coord_radial", "coord_trans", ) diff --git a/plotnine/coords/coord.py b/plotnine/coords/coord.py index 91b1ed3ddf..75a93d5386 100644 --- a/plotnine/coords/coord.py +++ b/plotnine/coords/coord.py @@ -119,6 +119,9 @@ class coord: Base class for all coordinate systems """ + # Matplotlib projection name to use when creating panel axes. + _projection: str | None = None + # If the coordinate system is linear is_linear = False @@ -126,6 +129,22 @@ class coord: # if the coordinate system needs them params: dict[str, Any] + # The ggplot this coord belongs to. Bound in `_setup`, not + # `__init__`/`__radd__`: a ggplot is copied on every `+`, so any + # earlier binding would point at a stale, intermediate copy rather + # than the final plot that's actually drawn. + _owner: ggplot | None = None + + @property + def preserves_dimensions(self) -> bool: + """ + Whether transformed position aesthetics retain their dimensions + + When this is `False`, geoms must read each position and its bounds + from the opposite dimension. + """ + return True + def __radd__(self, other: ggplot) -> ggplot: """ Add coordinates to ggplot object @@ -133,6 +152,25 @@ def __radd__(self, other: ggplot) -> ggplot: other.coordinates = copy(self) return other + def _setup(self, plot: ggplot) -> None: + """ + Bind this coord to the plot that owns it + + Unlike `theme._setup`/`guides._setup`, whose owner can be a + `ggplot`, a `Compose`, or (for `theme`) a `guide`, a coord is + never collected/shared across a `Compose` — it's always local to + exactly one `ggplot` — so the parameter is named for what it + concretely always is. + + Parameters + ---------- + plot : + The final `ggplot` being drawn. Called once per draw, after + all copying from `+` operators is done, mirroring + `guides._setup`/`theme._setup`. + """ + self._owner = plot + def setup_data(self, data: list[pd.DataFrame]) -> list[pd.DataFrame]: """ Allow the coordinate system to manipulate the layer data @@ -292,12 +330,21 @@ def _setup_secondary_axis( if (sec := sv.sec) is None: return + position = cast("Side", sec.position) p9ax = cast("p9Axes", ax) - axis = p9ax.add_sec_axis(sec.position) + axis = p9ax.add_sec_axis(position) _set_fixed_ticks(axis, sec.breaks, sec.labels) - _activate_axis(axis, sec.position, present) - p9ax.sides_with_an_axis.add(sec.position) - ax.spines[sec.position].set_visible(True) + _activate_axis(axis, position, present) + p9ax.sides_with_an_axis.add(position) + ax.spines[position].set_visible(True) + + 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: """ diff --git a/plotnine/coords/coord_flip.py b/plotnine/coords/coord_flip.py index 4d7ad9997e..bf8b62f2d4 100644 --- a/plotnine/coords/coord_flip.py +++ b/plotnine/coords/coord_flip.py @@ -1,13 +1,13 @@ from __future__ import annotations -import typing +from typing import TYPE_CHECKING, cast import pandas as pd from ..iapi import labels_view, panel_ranges, panel_view from .coord_cartesian import coord_cartesian -if typing.TYPE_CHECKING: +if TYPE_CHECKING: from typing import Sequence, TypeVar from plotnine.scales.scale import scale @@ -45,6 +45,10 @@ class coord_flip(coord_cartesian): use the limits from the data. """ + @property + def preserves_dimensions(self) -> bool: + return False + def labels(self, cur_labels: labels_view) -> labels_view: return flip_labels(super().labels(cur_labels)) @@ -63,7 +67,7 @@ def setup_panel_params(self, scale_x: scale, scale_y: scale) -> panel_view: panel_params.y.position = _FLIP_POSITION[panel_params.y.position] for sv in (panel_params.x, panel_params.y): if sv.sec is not None: - sv.sec.position = _FLIP_POSITION[sv.sec.position] + sv.sec.position = _FLIP_POSITION[cast("Side", sv.sec.position)] return panel_params def setup_layout(self, layout: pd.DataFrame) -> pd.DataFrame: diff --git a/plotnine/coords/coord_radial.py b/plotnine/coords/coord_radial.py new file mode 100644 index 0000000000..0cf469435a --- /dev/null +++ b/plotnine/coords/coord_radial.py @@ -0,0 +1,593 @@ +from __future__ import annotations + +from dataclasses import replace +from functools import cached_property +from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast +from warnings import warn + +import numpy as np + +from .._mpl._radial_axes import p9RadialAxes # noqa: TCH001 +from .._utils.registry import alias +from ..exceptions import PlotnineError, PlotnineWarning +from ..iapi import panel_ranges, radial_panel_view +from ..mapping.aes import ( + TRANSPOSED_POSITION_AESTHETICS, + X_AESTHETICS, + Y_AESTHETICS, +) +from .coord import _activate_axis, _set_fixed_ticks, coord, dist_euclidean + +if TYPE_CHECKING: + from collections.abc import Sequence + + import numpy.typing as npt + import pandas as pd + from matplotlib.axes import Axes + + from plotnine.iapi import ( + labels_view, + layout_details, + panel_view, + scale_position_view, + sec_axis_view, + ) + from plotnine.scales.scale import scale + from plotnine.typing import FloatArrayLike, FloatSeries, Side + +T1 = TypeVar("T1") +T2 = TypeVar("T2") + + +def polar_bbox( + arc: tuple[float, float], inner_radius: float = 0.0 +) -> tuple[float, float, float, float]: + """ + Tight bounding box of a polar sector in unit-circle coordinates + + The box is expressed as `(left, right, bottom, top)` in the `[0, 1]` + space of the full circle, where the centre is `(0.5, 0.5)` and the + outer radius is `0.5`. A cardinal direction (top, right, bottom, left) + that lies inside the arc extends the box to the circle's full extent + there; otherwise the box is clamped to the arc endpoints. `inner_radius` + pulls the inner edge toward the centre for donut sectors. No margin is + added, so the box matches the wedge the panel is sized to fill. + """ + lo, hi = arc + if abs(hi - lo) >= 2 * np.pi: + return (0.0, 1.0, 0.0, 1.0) + + lo, hi = sorted((lo, hi)) + ends = np.array([lo, hi]) + x_outer = 0.5 * np.sin(ends) + 0.5 + y_outer = 0.5 * np.cos(ends) + 0.5 + # `inner_radius` is a fraction of the outer radius, and the outer + # radius is 0.5 in this unit-circle space, so the inner ring sits at + # `inner_radius * 0.5` from the centre. + r_inner = inner_radius * 0.5 + x_inner = r_inner * np.sin(ends) + 0.5 + y_inner = r_inner * np.cos(ends) + 0.5 + + def in_arc(theta: float) -> bool: + folded = (theta - lo) % (2 * np.pi) + lo + return lo <= folded <= hi + + top = 1.0 if in_arc(0.0) else max(y_outer.max(), y_inner.max()) + right = 1.0 if in_arc(np.pi / 2) else max(x_outer.max(), x_inner.max()) + bottom = 0.0 if in_arc(np.pi) else min(y_outer.min(), y_inner.min()) + left = 0.0 if in_arc(1.5 * np.pi) else min(x_outer.min(), x_inner.min()) + return (float(left), float(right), float(bottom), float(top)) + + +class coord_radial(coord): + """ + Radial coordinate system + + A system where position aesthetic is mapped to the angle and the other + to the radius. + + 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) draws a full circle. Angles separated by whole turns + are equivalent, except that `end == start` raises a + [](:class:`~plotnine.exceptions.PlotnineError`) because it defines a + zero-width arc. + expand : + If `True` (the default), add a small buffer around the data on both + axes, so the data stops short of the arc ends and of the outer + radius. If `False`, use the limits from the data. + 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. + rotate_angle : + If `True`, rotate the `angle` aesthetic so that text or other + rotated marks align tangentially with the arc at their spoke. The + rotation is folded so labels stay upright (readable) rather than + appearing upside-down in the lower half. 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. + reverse : + Which axes run in the opposite direction. + + * `"none"` (default) — neither axis is reversed. + * `"theta"` — the angular axis runs the other way around the + same arc, so the data sweeps from `end` back to `start`. + * `"r"` — the radial axis is inverted, so large values sit + toward the centre. + * `"thetar"` — both the angular and radial axes are reversed. + + The sweep itself is always clockwise. A full circle runs the other + way with `reverse="theta"`; a partial arc mirrors onto the opposite + side with `start=-end, end=-start, reverse="theta"`. + + Notes + ----- + Theta-axis tick labels are shown by default. Since a polar axes' x-axis + *is* the theta axis, they are styled through the theme: hide them with + `theme(axis_text_x=element_blank())` and adjust the gap to the outer + circle through the `axis_text_x` margin. + + A secondary axis on the theta scale is drawn around the inner rim, with + its labels inside the hole. It requires `inner_radius > 0`; otherwise, + `coord_radial` warns and ignores it. + """ + + is_linear = False + _projection = "p9radial" + + def __init__( + self, + theta: str = "x", + start: float = 0, + end: float | None = None, + expand: bool = True, + inner_radius: float = 0, + rotate_angle: bool = False, + thetalim: tuple[float, float] | None = None, + rlim: tuple[float, float] | None = None, + reverse: Literal["none", "theta", "r", "thetar"] = "none", + ) -> None: + if reverse not in {"none", "theta", "r", "thetar"}: + raise ValueError( + "reverse must be one of 'none', 'theta', 'r', 'thetar'; " + f"got {reverse!r}." + ) + if end is not None and end == start: + raise PlotnineError( + f"start={start!r} and end={end!r} define a zero-width arc. " + "Set end=None for a full circle or choose a different angle." + ) + self.theta = theta + self.start = start + self.end = end + self.expand = expand + self.inner_radius = inner_radius + self.rotate_angle = rotate_angle + self.thetalim = thetalim + self.rlim = rlim + self.reverse = reverse + + @property + def preserves_dimensions(self) -> bool: + return self.theta == "x" + + def _flip(self, p1: T1, p2: T2) -> tuple[T1, T2] | tuple[T2, T1]: + return (p1, p2) if self.preserves_dimensions else (p2, p1) + + def setup_panel_params( + self, scale_x: scale, scale_y: scale + ) -> radial_panel_view: + """ + Compute the range and break information for the panel + + The panel_params are created in only this method. If the axes + have been switched (i.e. `theta = "y"`) from the default + (`theta = "x"` is the default), the panel_params are adjusted + accordingly. + """ + from .coord_cartesian import coord_cartesian + + # One expanded cartesian view supplies both the theta/r ranges and + # the theta breaks. Theta follows the expand flag like every other + # axis: expand=True buffers the data off the arc ends; expand=False + # keeps it flush so a pie closes cleanly. + xlim, ylim = self._flip(self.thetalim, self.rlim) + cartesian_view = coord_cartesian( + xlim=xlim, ylim=ylim, expand=self.expand + ).setup_panel_params(scale_x, scale_y) + theta, r = self._flip(cartesian_view.x, cartesian_view.y) + theta.breaks = cast("list[float]", theta.breaks) + arc_range = self._arc_range + + # The display range keeps its requested order; a full circle ends + # one turn after start. Data ticks are converted to radians below. + x = replace( + theta, + limits=arc_range, + range=arc_range, + breaks=self._to_radians(theta.breaks, theta.range), + minor_breaks=np.asarray( + self._to_radians(theta.minor_breaks, theta.range) + ), + labels=list(theta.labels), + sec=self._theta_sec_view(theta), + ) + y = replace(r) + + # Partial arcs keep only breaks inside their visible bounds. + if not self._is_full_circle: + arc_lo, arc_hi = sorted(arc_range) + + def on_arc(values: Sequence[float]) -> list[bool]: + return [arc_lo <= value <= arc_hi for value in values] + + x_breaks = cast("list[float]", x.breaks) + keep = on_arc(x_breaks) + sec = x.sec + if sec is not None: + sec_keep = on_arc(sec.breaks) + sec = replace( + sec, + breaks=[ + value + for value, include in zip(sec.breaks, sec_keep) + if include + ], + labels=[ + label + for label, include in zip(sec.labels, sec_keep) + if include + ], + ) + x = replace( + x, + breaks=[ + value for value, include in zip(x_breaks, keep) if include + ], + minor_breaks=[ + value + for value in x.minor_breaks + if arc_lo <= value <= arc_hi + ], + labels=[ + label for label, include in zip(x.labels, keep) if include + ], + sec=sec, + ) + + # Keep r in trained scale space. For radial reversal, make y match + # the display space produced by a reversed scale; descending polar + # limits alone do not reverse radial geometry. + if self.reverse in ("r", "thetar"): + r_limits = cast("tuple[float, float]", r.limits) + r_breaks = cast("list[float]", r.breaks) + # The secondary axis lives in the same reversed display frame, + # so its breaks are negated alongside the primary ones; leaving + # them positive would place its ticks outside the data band and + # stretch the radial range. + r_sec = r.sec + if r_sec is not None: + r_sec = replace( + r_sec, breaks=[-value for value in r_sec.breaks] + ) + y = replace( + y, + limits=(-r_limits[1], -r_limits[0]), + range=(-r.range[1], -r.range[0]), + breaks=[-value for value in r_breaks], + minor_breaks=-np.asarray(r.minor_breaks, dtype=float), + labels=list(r.labels), + sec=r_sec, + ) + + # theta="y" transposes the x and y views, so their axis position + # rotates with them (matching coord_flip): the angular title lands + # on x's bottom/top and the radial title on y's left/right, the + # sides the layout manager repositions. theta="x" keeps them as is. + if not self.preserves_dimensions: + from .coord_flip import _FLIP_POSITION + + x = replace(x, position=_FLIP_POSITION[x.position]) + y = replace(y, position=_FLIP_POSITION[y.position]) + for sv in (x, y): + if sv.sec: + # A secondary axis takes the side opposite its primary, + # which is always a cartesian side: on a polar panel the + # position moves only the title. + pos = cast("Side", sv.sec.position) + sv.sec.position = _FLIP_POSITION[pos] + + return radial_panel_view(x=x, y=y, theta=theta, r=r) + + def labels(self, cur_labels: labels_view) -> labels_view: + labels = super().labels(cur_labels) + if self.theta == "x": + return labels + + # The default theta="x", so when theta="y", we also swap the labels + # and axis titles. + from .coord_flip import flip_labels + + return flip_labels(labels) + + @cached_property + def _arc_range(self) -> tuple[float, float]: + """ + Forward angular limits of the displayed arc + + Equivalent end angles select the same clockwise arc. A non-zero + whole turn selects a full circle. + """ + turn = 2 * np.pi + if self.end is None: + return (self.start, self.start + turn) + + span = (self.end - self.start) % turn + if span == 0: + span = turn + return (self.start, self.start + span) + + @cached_property + def _is_full_circle(self) -> bool: + """True when the displayed arc covers one complete turn""" + start, end = self._arc_range + return bool(np.isclose(end - start, 2 * np.pi)) + + @cached_property + def _r_axis_side(self) -> Literal["r_start", "r_end"]: + """ + Polar side the radial axis occupies + + The radial axis sits where the data begins. `reverse="theta"` runs + the data from `end` back to `start`, so a partial arc moves the axis + to the end spoke. A full circle has a single shared spoke, so it + stays at the start. + """ + if self.reverse in ("theta", "thetar") and not self._is_full_circle: + return "r_end" + return "r_start" + + @cached_property + def _sec_r_axis_side(self) -> Literal["r_start", "r_end"]: + """ + Polar side the secondary radial axis occupies + + The secondary axis sits on the spoke opposite the primary one, so it + follows the primary when `reverse="theta"` moves it to the end spoke. + """ + return "r_start" if self._r_axis_side == "r_end" else "r_end" + + def _to_radians( + self, vals: FloatArrayLike, theta_range: tuple[float, float] + ) -> list[float]: + """Normalize theta values to [start, start + arc]""" + lo, hi = theta_range + span = hi - lo + + if span == 0: + return [0] * len(vals) + + arc_start, arc_end = self._arc_range + norm = (np.asarray(vals) - lo) / span + # Traverse the same arc the other way, so data runs from + # end back to start. + if self.reverse in ("theta", "thetar"): + norm = 1 - norm + return list(arc_start + norm * (arc_end - arc_start)) + + def _theta_sec_view( + self, theta: scale_position_view + ) -> sec_axis_view | None: + """ + Secondary theta axis of a panel, with its breaks in radians + + The axis is drawn on the rim of the inner hole, so a panel without a + hole has nowhere to put it and drops it. Dropping it here also drops + its title, which is built from this view. + """ + if (sec := theta.sec) is None: + return None + + if not self.inner_radius: + warn( + f"{self.__class__.__name__}() draws a secondary theta axis " + "on the rim of the inner hole, so it needs inner_radius > 0. " + "Ignoring it.", + PlotnineWarning, + ) + return None + + return replace(sec, breaks=self._to_radians(sec.breaks, theta.range)) + + 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 "x" not in data or "y" not in data: + return data + + view = cast("radial_panel_view", panel_params) + theta_aesthetics = self._flip(X_AESTHETICS, Y_AESTHETICS)[0] + + data = data.copy() + for aes in theta_aesthetics & set(data.columns): + data[aes] = self._to_radians(data[aes], view.theta.range) + + # Matplotlib requires theta in `x` and radius in `y`. Transpose every + # position aesthetic so bounds, endpoints, and intercepts stay with + # their dimension when `theta="y"`. + if not self.preserves_dimensions: + data = data.rename(columns=TRANSPOSED_POSITION_AESTHETICS) + + if self.reverse in ("r", "thetar"): + for aes in Y_AESTHETICS & set(data.columns): + data[aes] = -data[aes] + + # After the swap, data["x"] is always theta in radians. + if self.rotate_angle and "angle" in data and "x" in data: + # Align marks tangentially to their spoke. The PolarAxes places + # a data theta t at on-screen angle (deg, CCW from East) + # screen = 90 - degrees(t), since the sweep is clockwise + # Tangential text rotation is screen - 90; folding into (-90, 90] + # keeps labels upright (a bottom label reads "6", not "9"). + rot = -np.degrees(data["x"]) + rot = (rot + 90.0) % 180.0 - 90.0 + data["angle"] = data["angle"] + rot + return data + + def distance( + self, + x: FloatSeries, + y: FloatSeries, + panel_params: panel_view, + ) -> npt.NDArray[Any]: + # Normalise theta and r to [0, 1] then compute Euclidean distance. + view = cast("radial_panel_view", panel_params) + + t_lo, t_hi = view.theta.range + r_lo, r_hi = view.r.range + + t_span = (t_hi - t_lo) or 1 + r_span = (r_hi - r_lo) or 1 + + # While the panel_params are flipped, the x and y parameters + # have not be reoriented. + x, y = self._flip(x, y) + t_vals = np.asarray(x, dtype=float) + r_vals = np.asarray(y, dtype=float) + + t_norm = (t_vals - t_lo) / t_span + r_norm = (r_vals - r_lo) / r_span + + return dist_euclidean(t_norm, r_norm) + + def backtransform_range(self, panel_params: panel_view) -> panel_ranges: + view = cast("radial_panel_view", panel_params) + x, y = self._flip(view.theta.range, view.r.range) + return panel_ranges(x=x, y=y) + + def setup_ax( + self, + ax: Axes, + panel_params: panel_view, + layout_info: layout_details, + ) -> None: + """ + Configure each polar axes from this panel's limits + + Sets limits, breaks, tick labels, the fixed active side, arc limits, + inner radius, and radial-axis placement using `panel_params` so + faceted panels with free scales each get their own radial range. + + The primary theta axis uses the outer rim and its secondary axis uses + the inner rim. The primary r axis sits on the spoke where the data + begins: normally the start spoke, or the end spoke when + `reverse="theta"` reverses a partial arc. `scale.position` moves only + an axis title, not the axis itself. + """ + view = cast("radial_panel_view", panel_params) + radial_ax = cast("p9RadialAxes", ax) + + # plotnine sweeps clockwise, which is matplotlib's -1. + radial_ax.set_theta_direction(-1) + + self._setup_ticks_labels(ax, view) + + # The radial axis is the yaxis; its start spoke is matplotlib's + # "left" tick pair and its end spoke the "right" pair. + r_side = "right" if self._r_axis_side == "r_end" else "left" + _activate_axis(ax.xaxis, "top", True) + _activate_axis(ax.yaxis, r_side, True) + + radial_ax.axis_at_side["theta_outside"] = radial_ax.thetaaxis + radial_ax.axis_at_side[self._r_axis_side] = radial_ax.raxis + + if (sec := view.x.sec) is not None: + sec_thetaaxis = radial_ax.add_sec_thetaaxis() + _set_fixed_ticks(sec_thetaaxis, sec.breaks, sec.labels) + # Matplotlib places the `bottom` tick and label pair inside the + # circle. + _activate_axis(sec_thetaaxis, "bottom", True) + radial_ax.set_spine_visible("inner", True) + radial_ax.axis_at_side["theta_inside"] = sec_thetaaxis + + if (sec := view.y.sec) is not None: + sec_raxis = radial_ax.add_sec_raxis() + _set_fixed_ticks(sec_raxis, sec.breaks, sec.labels) + # The secondary axis occupies the spoke opposite the primary; that + # spoke's tick pair is its "left"/"right" mpl side and its spine. + sec_side = "right" if self._sec_r_axis_side == "r_end" else "left" + _activate_axis(sec_raxis, sec_side, True) + sec_spoke = "end" if sec_side == "right" else "start" + radial_ax.set_spine_visible(sec_spoke, True) + radial_ax.axis_at_side[self._sec_r_axis_side] = sec_raxis + + # The theme styles these tick objects later; keep matplotlib's + # tick resets from replacing their styling with the default look. + radial_ax.lock_raxis_tick_style() + + # Restrict visible theta range for partial arcs. + if not self._is_full_circle: + radial_ax.set_thetalim(*self._arc_range) + + # Inner radius: push the data away from the centre by setting a + # virtual r-origin below r_min. Formula: solve + # inner_radius = (r_lo - r_origin) / (r_hi - r_origin) + # Use the ordered display range so the origin follows ordinary, + # scale-reversed, and coordinate-reversed radial geometry. + r_lo, r_hi = view.y.range + + if ( + self.inner_radius > 0 + and np.isfinite(r_lo) + and np.isfinite(r_hi) + and r_lo < r_hi + and self.inner_radius < 1.0 + ): + r_origin = (r_lo - self.inner_radius * r_hi) / ( + 1.0 - self.inner_radius + ) + radial_ax.set_rorigin(r_origin) + + # Full-circle r ticks and labels share the start spoke. Partial arcs + # use matplotlib's distinct start/end radial axes. + if self._is_full_circle: + radial_ax.set_rlabel_position(np.degrees(self.start)) + + ax.tick_params(axis="x", which="major", direction="out") + # 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) + + def aspect(self, panel_params: panel_view) -> float: + left, right, bottom, top = polar_bbox( + self._arc_range, self.inner_radius + ) + return (top - bottom) / (right - left) + + +@alias +class coord_polar(coord_radial): + pass diff --git a/plotnine/facets/facet.py b/plotnine/facets/facet.py index fd7fe4745c..842681bd3c 100644 --- a/plotnine/facets/facet.py +++ b/plotnine/facets/facet.py @@ -342,16 +342,20 @@ def _make_axes(self) -> tuple[p9GridSpec, list[Axes]]: """ Create and return subplot axes """ - num_panels = len(self.layout.layout) axsarr = np.empty((self.nrow, self.ncol), dtype=object) gs = self._make_gridspec() # Create axes + projection = ( + p9Axes.name + if self.plot.coordinates._projection is None + else self.plot.coordinates._projection + ) 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], projection=p9Axes.name + gs[i], projection=projection ) # Rearrange axes diff --git a/plotnine/geoms/geom_ribbon.py b/plotnine/geoms/geom_ribbon.py index 0469e038b2..42d5a9424c 100644 --- a/plotnine/geoms/geom_ribbon.py +++ b/plotnine/geoms/geom_ribbon.py @@ -3,7 +3,6 @@ import typing from .._utils import SIZE_FACTOR, to_rgba -from ..coords import coord_flip from ..doctools import document from ..exceptions import PlotnineError from .geom import geom @@ -90,7 +89,7 @@ def draw_group( ax: Axes, params: dict[str, Any], ): - _x = "y" if isinstance(coord, coord_flip) else "x" + _x = "x" if coord.preserves_dimensions else "y" data = coord.transform(data, panel_params, munch=True) data = data.sort_values(by=["group", _x], kind="mergesort") units = ["alpha", "color", "fill", "linetype", "size"] @@ -122,12 +121,12 @@ def draw_unit( if fill is None: fill = "none" - if isinstance(coord, coord_flip): - fill_between = ax.fill_betweenx - _x, _min, _max = data["y"], data["xmin"], data["xmax"] - else: + if coord.preserves_dimensions: fill_between = ax.fill_between _x, _min, _max = data["x"], data["ymin"], data["ymax"] + else: + fill_between = ax.fill_betweenx + _x, _min, _max = data["y"], data["xmin"], data["xmax"] # We only change this defaults for fill_between when necessary where = data.get("where", None) @@ -167,9 +166,9 @@ def _draw_outline( if outline_type == "full": return - # The data is already in panel coordinates. After `coord_flip`, - # the ribbon bounds are `xmin` and `xmax`. - bounds = "x" if isinstance(coord, coord_flip) else "y" + # The data is already in panel coordinates. Where the coord swaps + # the dimensions, the ribbon bounds are `xmin` and `xmax`. + bounds = "y" if coord.preserves_dimensions else "x" # Each call receives one ribbon group, so an outline forms one path # with constant aesthetics. diff --git a/plotnine/ggplot.py b/plotnine/ggplot.py index 8592014085..1fb030bc10 100755 --- a/plotnine/ggplot.py +++ b/plotnine/ggplot.py @@ -384,6 +384,7 @@ def draw(self, *, show: bool = False) -> Figure: self._build() # setup + self.coordinates._setup(self) self.guides._setup(self) self.theme._setup(self) @@ -546,6 +547,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): """ diff --git a/plotnine/guides/__init__.py b/plotnine/guides/__init__.py index d3d18e3b2c..ad64f75fbb 100644 --- a/plotnine/guides/__init__.py +++ b/plotnine/guides/__init__.py @@ -2,4 +2,9 @@ from .guide_legend import guide_legend from .guides import guides -__all__ = ("guide_colorbar", "guide_colourbar", "guide_legend", "guides") +__all__ = ( + "guide_colorbar", + "guide_colourbar", + "guide_legend", + "guides", +) diff --git a/plotnine/guides/guides.py b/plotnine/guides/guides.py index d01d6bd7d3..3764adefc4 100644 --- a/plotnine/guides/guides.py +++ b/plotnine/guides/guides.py @@ -27,7 +27,12 @@ from matplotlib.figure import Figure from matplotlib.offsetbox import OffsetBox, PackerBase - from plotnine import ggplot, guide_colorbar, guide_legend, theme + from plotnine import ( + ggplot, + guide_colorbar, + guide_legend, + theme, + ) from plotnine._mpl.offsetbox import FlexibleAnchoredOffsetbox from plotnine.composition import Compose from plotnine.iapi import labels_view diff --git a/plotnine/iapi.py b/plotnine/iapi.py index eaca78e7a3..c41c8de504 100644 --- a/plotnine/iapi.py +++ b/plotnine/iapi.py @@ -22,6 +22,7 @@ CoordRange, FloatArrayLike, HorizontalJustification, + PolarSide, ScaledAestheticsName, Side, StripPosition, @@ -60,8 +61,9 @@ class sec_axis_view: labels: Sequence[str] # Title of the secondary axis; None means the primary axis title name: Optional[str] - # The side opposite the primary axis - position: Side + # The side opposite the primary axis (cartesian), or the polar spoke + # a secondary radial axis occupies + position: Side | PolarSide @dataclass @@ -160,7 +162,7 @@ def __repr__(self) -> str: @dataclass class panel_view: """ - Information from the trained position scales in a panel + Position-scale state for one panel """ x: scale_position_view @@ -177,6 +179,16 @@ class panel_ranges: y: tuple[float, float] +@dataclass +class radial_panel_view(panel_view): + """ + Data-space and display-space scales for one radial panel + """ + + theta: scale_position_view + r: scale_position_view + + @dataclass class pos_scales: """ diff --git a/plotnine/mapping/aes.py b/plotnine/mapping/aes.py index de1bc0ac5d..eb841b4b8b 100644 --- a/plotnine/mapping/aes.py +++ b/plotnine/mapping/aes.py @@ -32,6 +32,13 @@ class ColorOrColour(Protocol): X_AESTHETICS = {"x", "xmin", "xmax", "xend", "xintercept"} Y_AESTHETICS = {"y", "ymin", "ymax", "yend", "yintercept"} +# Counterparts for transposing every position aesthetic between dimensions. +# Bounds, endpoints, and intercepts must move with `x` and `y`. +TRANSPOSED_POSITION_AESTHETICS = { + **{aes: f"y{aes[1:]}" for aes in X_AESTHETICS}, + **{aes: f"x{aes[1:]}" for aes in Y_AESTHETICS}, +} + ALL_AESTHETICS = { "alpha", "angle", diff --git a/plotnine/themes/elements/margin.py b/plotnine/themes/elements/margin.py index e53702753e..baddebee2e 100644 --- a/plotnine/themes/elements/margin.py +++ b/plotnine/themes/elements/margin.py @@ -86,6 +86,11 @@ class margin: init=False, default=False, repr=False, compare=False ) + @property + def max(self) -> float: + """Largest edge value in the margin's current unit""" + return max(self.t, self.r, self.b, self.l) + def setup(self, theme: theme, themeable_name: str): """ Setup the margin to be used in the layout diff --git a/plotnine/themes/theme.py b/plotnine/themes/theme.py index 13d3247a72..63d2346bd3 100644 --- a/plotnine/themes/theme.py +++ b/plotnine/themes/theme.py @@ -85,6 +85,9 @@ class theme: These simply bind together all the aspects of a themeable that can be themed. See [](`~plotnine.themes.themeable.themeable`). + Extension packages may provide additional themeables by defining + and importing subclasses of `themeable`, then passing values for + them as keyword arguments to `theme`. Notes ----- @@ -114,7 +117,7 @@ def __init__( # python -c " # from plotnine.themes.themeable import themeable # for name in themeable.registry(): - # print(f'{name}=None,') + # print(f' {name}=None,') # " axis_title_x_bottom=None, axis_title_x_top=None, @@ -147,30 +150,54 @@ def __init__( title=None, axis_text_x_bottom=None, axis_text_x_top=None, + axis_text_theta_outside=None, + axis_text_theta_inside=None, + axis_text_theta=None, axis_text_x=None, axis_text_y_left=None, axis_text_y_right=None, + axis_text_r_start=None, + axis_text_r_end=None, + axis_text_r=None, axis_text_y=None, axis_text=None, text=None, axis_line_x_bottom=None, axis_line_x_top=None, + axis_line_theta_inside=None, + axis_line_theta_outside=None, + axis_line_theta=None, axis_line_x=None, axis_line_y_left=None, axis_line_y_right=None, + axis_line_r_start=None, + axis_line_r_end=None, + axis_line_r=None, axis_line_y=None, axis_line=None, axis_ticks_minor_x_bottom=None, axis_ticks_minor_x_top=None, + axis_ticks_minor_theta_outside=None, + axis_ticks_minor_theta_inside=None, + axis_ticks_minor_theta=None, axis_ticks_minor_x=None, axis_ticks_minor_y_left=None, axis_ticks_minor_y_right=None, + axis_ticks_minor_r_start=None, + axis_ticks_minor_r_end=None, + axis_ticks_minor_r=None, axis_ticks_minor_y=None, axis_ticks_major_x_bottom=None, axis_ticks_major_x_top=None, + axis_ticks_major_theta_outside=None, + axis_ticks_major_theta_inside=None, + axis_ticks_major_theta=None, axis_ticks_major_x=None, axis_ticks_major_y_left=None, axis_ticks_major_y_right=None, + axis_ticks_major_r_start=None, + axis_ticks_major_r_end=None, + axis_ticks_major_r=None, axis_ticks_major_y=None, axis_ticks_major=None, axis_ticks_minor=None, @@ -201,9 +228,13 @@ def __init__( rect=None, axis_ticks_length_major_x=None, axis_ticks_length_major_y=None, + axis_ticks_length_major_theta=None, + axis_ticks_length_major_r=None, axis_ticks_length_major=None, axis_ticks_length_minor_x=None, axis_ticks_length_minor_y=None, + axis_ticks_length_minor_theta=None, + axis_ticks_length_minor_r=None, axis_ticks_length_minor=None, axis_ticks_length=None, panel_spacing_x=None, diff --git a/plotnine/themes/theme_gray.py b/plotnine/themes/theme_gray.py index afb43ab683..c4fec59f7b 100644 --- a/plotnine/themes/theme_gray.py +++ b/plotnine/themes/theme_gray.py @@ -68,6 +68,10 @@ def __init__(self, base_size=11, base_family=None): axis_text_y_right=element_text( ha="left", margin=margin(l=fifth_line) ), + axis_text_theta_outside=element_text(margin=margin(t=fifth_line)), + axis_text_theta_inside=element_text(margin=margin(b=fifth_line)), + axis_text_r_start=element_text(margin=margin(r=fifth_line)), + axis_text_r_end=element_text(margin=margin(l=fifth_line)), axis_ticks=element_line(color="#333333"), axis_ticks_length=0, axis_ticks_length_major=quarter_line, diff --git a/plotnine/themes/themeable.py b/plotnine/themes/themeable.py index f0e9420d57..e19e5e305f 100644 --- a/plotnine/themes/themeable.py +++ b/plotnine/themes/themeable.py @@ -16,6 +16,7 @@ from warnings import warn import numpy as np +from matplotlib.projections.polar import PolarAxes from .._mpl.axes import axis_at from .._utils import MARGIN_SIDE, has_alpha_channel, side_artists, to_rgba @@ -35,7 +36,7 @@ from plotnine import theme from plotnine.themes.targets import ThemeTargets - from plotnine.typing import Side + from plotnine.typing import PolarSide, Side class themeable(metaclass=RegistryHierarchyMeta): @@ -99,8 +100,11 @@ class axis_title(axis_title_x, axis_title_y): Notes ----- - A user should never create instances of class - [](`~plotnine.themes.themeable.Themeable`) or subclasses of it. + Most users should not create instances of class + [](`~plotnine.themes.themeable.themeable`) or subclasses of it + directly. Extension authors may define subclasses; they are registered + by class name when their module is imported and can be used through + [](`~plotnine.themes.theme.theme`) keyword arguments. """ def __init__(self, theme_element: element_base | str | float): @@ -530,7 +534,7 @@ def blend_alpha( return properties -def _set_axis_text_margin(themeable, ax, side: Side): +def _set_axis_text_margin(themeable, ax, side: Side | PolarSide): """ Set the gap between axis tick and axis text """ @@ -539,7 +543,10 @@ def _set_axis_text_margin(themeable, ax, side: Side): return if (axis := axis_at(ax, side)) is None: return - pad = getattr(margin.pt, MARGIN_SIDE[side]) + if side in ("theta_outside", "theta_inside"): + pad = margin.pt.max + else: + pad = getattr(margin.pt, MARGIN_SIDE[side]) axis.set_tick_params(which="major", pad=pad) @@ -1136,7 +1143,67 @@ def blank_ax(self, ax: Axes): t.label2.set_visible(False) -class axis_text_x(axis_text_x_top, axis_text_x_bottom): +class axis_text_theta_outside(MixinSequenceOfValues): + """ + theta-axis tick labels at the outer (rim) boundary + + Parameters + ---------- + theme_element : element_text + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + if (axis := axis_at(ax, "theta_outside")) is None: + return + labels = [t.label2 for t in axis.get_major_ticks()] + self.set(labels, self._get_properties(omit=("margin", "ha", "va"))) + _set_axis_text_margin(self, ax, "theta_outside") + + def blank_ax(self, ax: Axes): + super().blank_ax(ax) + if (axis := axis_at(ax, "theta_outside")) is None: + return + for t in axis.get_major_ticks(): + t.label2.set_visible(False) + + +class axis_text_theta_inside(MixinSequenceOfValues): + """ + theta-axis tick labels at the inner (donut-hole) boundary + + Parameters + ---------- + theme_element : element_text + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + if (axis := axis_at(ax, "theta_inside")) is None: + return + labels = [t.label1 for t in axis.get_major_ticks()] + self.set(labels, self._get_properties(omit=("margin", "ha", "va"))) + _set_axis_text_margin(self, ax, "theta_inside") + + def blank_ax(self, ax: Axes): + super().blank_ax(ax) + if (axis := axis_at(ax, "theta_inside")) is None: + return + for t in axis.get_major_ticks(): + t.label1.set_visible(False) + + +class axis_text_theta(axis_text_theta_outside, axis_text_theta_inside): + """ + theta-axis tick labels + + Parameters + ---------- + theme_element : element_text + """ + + +class axis_text_x(axis_text_x_top, axis_text_x_bottom, axis_text_theta): """ x-axis tick labels @@ -1196,7 +1263,67 @@ def blank_ax(self, ax: Axes): t.label2.set_visible(False) -class axis_text_y(axis_text_y_left, axis_text_y_right): +class axis_text_r_start(MixinSequenceOfValues): + """ + r-axis tick labels at the start-angle spoke + + Parameters + ---------- + theme_element : element_text + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + if (axis := axis_at(ax, "r_start")) is None: + return + labels = [t.label1 for t in axis.get_major_ticks()] + self.set(labels, self._get_properties(omit=("margin", "ha"))) + _set_axis_text_margin(self, ax, "r_start") + + def blank_ax(self, ax: Axes): + super().blank_ax(ax) + if (axis := axis_at(ax, "r_start")) is None: + return + for t in axis.get_major_ticks(): + t.label1.set_visible(False) + + +class axis_text_r_end(MixinSequenceOfValues): + """ + r-axis tick labels at the end-angle spoke + + Parameters + ---------- + theme_element : element_text + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + if (axis := axis_at(ax, "r_end")) is None: + return + labels = [t.label2 for t in axis.get_major_ticks()] + self.set(labels, self._get_properties(omit=("margin", "ha"))) + _set_axis_text_margin(self, ax, "r_end") + + def blank_ax(self, ax: Axes): + super().blank_ax(ax) + if (axis := axis_at(ax, "r_end")) is None: + return + for t in axis.get_major_ticks(): + t.label2.set_visible(False) + + +class axis_text_r(axis_text_r_start, axis_text_r_end): + """ + r-axis tick labels + + Parameters + ---------- + theme_element : element_text + """ + + +class axis_text_y(axis_text_y_left, axis_text_y_right, axis_text_r): """ y-axis tick labels @@ -1277,6 +1404,39 @@ def _style_axis_line(themeable, ax, side): ax.spines[side].set(**properties) +def _style_polar_axis_line(themeable, ax, spine): + """ + Style a polar spine and show it + + Mirrors `_style_axis_line`: an explicitly themed (non-blank) spine + is shown (`visible=True`) even if matplotlib's own geometry-driven + default would hide it (e.g. a full circle has no `start`/`end` + spokes). Visibility goes through `p9RadialAxes.set_spine_visible` + rather than the `properties` dict, since that's what makes the + choice stick across draws. + """ + properties = themeable._get_properties(omit=("solid_capstyle",)) + visible = properties.pop("visible", True) + if "zorder" not in properties: + properties["zorder"] = 10000 + ax.spines[spine].set(**properties) + ax.set_spine_visible(spine, visible) + + +def _blank_polar_axis_line(ax, spine): + """ + Hide a polar spine + + A plain `Axes` has no matching spine name, so this is a safe no-op + for cartesian axes. It's reachable from every `blank_ax` call, polar + or not, since `axis_line_theta`/`axis_line_r` nest under + `axis_line_x`/`axis_line_y`. `p9RadialAxes.set_spine_visible` is what + makes this choice stick across draws. + """ + if spine in ax.spines: + ax.set_spine_visible(spine, False) + + class axis_line_x_bottom(themeable): """ x-axis line on the bottom @@ -1288,7 +1448,8 @@ def apply_ax(self, ax: Axes): def blank_ax(self, ax: Axes): super().blank_ax(ax) - ax.spines["bottom"].set_visible(False) + if "bottom" in ax.spines: + ax.spines["bottom"].set_visible(False) class axis_line_x_top(themeable): @@ -1302,10 +1463,53 @@ def apply_ax(self, ax: Axes): def blank_ax(self, ax: Axes): super().blank_ax(ax) - ax.spines["top"].set_visible(False) + if "top" in ax.spines: + ax.spines["top"].set_visible(False) + + +class axis_line_theta_inside(themeable): + """ + theta-axis line on the inner boundary + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + if axis_at(ax, "theta_inside") is None: + return + _style_polar_axis_line(self, ax, "inner") + + def blank_ax(self, ax: Axes): + super().blank_ax(ax) + _blank_polar_axis_line(ax, "inner") -class axis_line_x(axis_line_x_top, axis_line_x_bottom): +class axis_line_theta_outside(themeable): + """ + theta-axis line on the outer boundary + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + if axis_at(ax, "theta_outside") is None: + return + _style_polar_axis_line(self, ax, "polar") + + def blank_ax(self, ax: Axes): + super().blank_ax(ax) + _blank_polar_axis_line(ax, "polar") + + +class axis_line_theta(axis_line_theta_inside, axis_line_theta_outside): + """ + theta-axis line + + Parameters + ---------- + theme_element : element_line + """ + + +class axis_line_x(axis_line_x_top, axis_line_x_bottom, axis_line_theta): """ x-axis line @@ -1326,7 +1530,8 @@ def apply_ax(self, ax: Axes): def blank_ax(self, ax: Axes): super().blank_ax(ax) - ax.spines["left"].set_visible(False) + if "left" in ax.spines: + ax.spines["left"].set_visible(False) class axis_line_y_right(themeable): @@ -1340,10 +1545,53 @@ def apply_ax(self, ax: Axes): def blank_ax(self, ax: Axes): super().blank_ax(ax) - ax.spines["right"].set_visible(False) + if "right" in ax.spines: + ax.spines["right"].set_visible(False) -class axis_line_y(axis_line_y_left, axis_line_y_right): +class axis_line_r_start(themeable): + """ + r-axis line at the start angle + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + if axis_at(ax, "r_start") is None: + return + _style_polar_axis_line(self, ax, "start") + + def blank_ax(self, ax: Axes): + super().blank_ax(ax) + _blank_polar_axis_line(ax, "start") + + +class axis_line_r_end(themeable): + """ + r-axis line at the end angle + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + if axis_at(ax, "r_end") is None: + return + _style_polar_axis_line(self, ax, "end") + + def blank_ax(self, ax: Axes): + super().blank_ax(ax) + _blank_polar_axis_line(ax, "end") + + +class axis_line_r(axis_line_r_start, axis_line_r_end): + """ + r-axis line + + Parameters + ---------- + theme_element : element_line + """ + + +class axis_line_y(axis_line_y_left, axis_line_y_right, axis_line_r): """ y-axis line @@ -1363,7 +1611,7 @@ class axis_line(axis_line_x, axis_line_y): """ -def _style_axis_ticks(themeable, ax, which, side): +def _style_axis_ticks(themeable, ax, which, side: Side | PolarSide): """ Style the tick lines on one side of an axis @@ -1395,7 +1643,7 @@ def _style_axis_ticks(themeable, ax, which, side): themeable.set([getattr(t, attr) for t in ticks], properties) -def _blank_axis_ticks(ax, which, side): +def _blank_axis_ticks(ax, which, side: Side | PolarSide): """ Hide the tick lines on one side of an axis """ @@ -1437,7 +1685,49 @@ def blank_ax(self, ax: Axes): _blank_axis_ticks(ax, "minor", "top") -class axis_ticks_minor_x(axis_ticks_minor_x_top, axis_ticks_minor_x_bottom): +class axis_ticks_minor_theta_outside(MixinSequenceOfValues): + """ + theta-axis minor tick lines at the outer (rim) boundary + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + _style_axis_ticks(self, ax, "minor", "theta_outside") + + def blank_ax(self, ax: Axes): + super().blank_ax(ax) + _blank_axis_ticks(ax, "minor", "theta_outside") + + +class axis_ticks_minor_theta_inside(MixinSequenceOfValues): + """ + theta-axis minor tick lines at the inner (donut-hole) boundary + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + _style_axis_ticks(self, ax, "minor", "theta_inside") + + def blank_ax(self, ax: Axes): + super().blank_ax(ax) + _blank_axis_ticks(ax, "minor", "theta_inside") + + +class axis_ticks_minor_theta( + axis_ticks_minor_theta_outside, axis_ticks_minor_theta_inside +): + """ + theta-axis minor tick lines + + Parameters + ---------- + theme_element : element_line + """ + + +class axis_ticks_minor_x( + axis_ticks_minor_x_top, axis_ticks_minor_x_bottom, axis_ticks_minor_theta +): """ x-axis minor tick lines @@ -1475,7 +1765,47 @@ def blank_ax(self, ax: Axes): _blank_axis_ticks(ax, "minor", "right") -class axis_ticks_minor_y(axis_ticks_minor_y_left, axis_ticks_minor_y_right): +class axis_ticks_minor_r_start(MixinSequenceOfValues): + """ + r-axis minor tick lines at the start-angle spoke + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + _style_axis_ticks(self, ax, "minor", "r_start") + + def blank_ax(self, ax: Axes): + super().blank_ax(ax) + _blank_axis_ticks(ax, "minor", "r_start") + + +class axis_ticks_minor_r_end(MixinSequenceOfValues): + """ + r-axis minor tick lines at the end-angle spoke + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + _style_axis_ticks(self, ax, "minor", "r_end") + + def blank_ax(self, ax: Axes): + super().blank_ax(ax) + _blank_axis_ticks(ax, "minor", "r_end") + + +class axis_ticks_minor_r(axis_ticks_minor_r_start, axis_ticks_minor_r_end): + """ + r-axis minor tick lines + + Parameters + ---------- + theme_element : element_line + """ + + +class axis_ticks_minor_y( + axis_ticks_minor_y_left, axis_ticks_minor_y_right, axis_ticks_minor_r +): """ y-axis minor tick lines @@ -1513,7 +1843,49 @@ def blank_ax(self, ax: Axes): _blank_axis_ticks(ax, "major", "top") -class axis_ticks_major_x(axis_ticks_major_x_top, axis_ticks_major_x_bottom): +class axis_ticks_major_theta_outside(MixinSequenceOfValues): + """ + theta-axis major tick lines at the outer (rim) boundary + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + _style_axis_ticks(self, ax, "major", "theta_outside") + + def blank_ax(self, ax: Axes): + super().blank_ax(ax) + _blank_axis_ticks(ax, "major", "theta_outside") + + +class axis_ticks_major_theta_inside(MixinSequenceOfValues): + """ + theta-axis major tick lines at the inner (donut-hole) boundary + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + _style_axis_ticks(self, ax, "major", "theta_inside") + + def blank_ax(self, ax: Axes): + super().blank_ax(ax) + _blank_axis_ticks(ax, "major", "theta_inside") + + +class axis_ticks_major_theta( + axis_ticks_major_theta_outside, axis_ticks_major_theta_inside +): + """ + theta-axis major tick lines + + Parameters + ---------- + theme_element : element_line + """ + + +class axis_ticks_major_x( + axis_ticks_major_x_top, axis_ticks_major_x_bottom, axis_ticks_major_theta +): """ x-axis major tick lines @@ -1551,7 +1923,47 @@ def blank_ax(self, ax: Axes): _blank_axis_ticks(ax, "major", "right") -class axis_ticks_major_y(axis_ticks_major_y_left, axis_ticks_major_y_right): +class axis_ticks_major_r_start(MixinSequenceOfValues): + """ + r-axis major tick lines at the start-angle spoke + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + _style_axis_ticks(self, ax, "major", "r_start") + + def blank_ax(self, ax: Axes): + super().blank_ax(ax) + _blank_axis_ticks(ax, "major", "r_start") + + +class axis_ticks_major_r_end(MixinSequenceOfValues): + """ + r-axis major tick lines at the end-angle spoke + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + _style_axis_ticks(self, ax, "major", "r_end") + + def blank_ax(self, ax: Axes): + super().blank_ax(ax) + _blank_axis_ticks(ax, "major", "r_end") + + +class axis_ticks_major_r(axis_ticks_major_r_start, axis_ticks_major_r_end): + """ + r-axis major tick lines + + Parameters + ---------- + theme_element : element_line + """ + + +class axis_ticks_major_y( + axis_ticks_major_y_left, axis_ticks_major_y_right, axis_ticks_major_r +): """ y-axis major tick lines @@ -2076,6 +2488,8 @@ class axis_ticks_length_major_x(themeable): def apply_ax(self, ax: Axes): super().apply_ax(ax) + if isinstance(ax, PolarAxes): + return for axis in (ax.xaxis, getattr(ax, "sec_xaxis", None)): if axis is None: continue @@ -2117,6 +2531,8 @@ class axis_ticks_length_major_y(themeable): def apply_ax(self, ax: Axes): super().apply_ax(ax) + if isinstance(ax, PolarAxes): + return for axis in (ax.yaxis, getattr(ax, "sec_yaxis", None)): if axis is None: continue @@ -2144,8 +2560,71 @@ def apply_ax(self, ax: Axes): ) +class axis_ticks_length_major_theta(themeable): + """ + theta-axis major-tick length + + Applies uniformly to whichever theta boundary is active — unlike + `axis_ticks_major_theta_outside`/`_inside`, length was never split + by boundary even for the cartesian `axis_ticks_length_major_x`. + + Parameters + ---------- + theme_element : float | complex + Value in points. A negative value creates the ticks + inside the plot panel. A complex value (e.g. `3j`) + creates ticks that span both in and out of the panel. + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + value: float | complex = self.properties["value"] + if isinstance(value, (float, int)): + tickdir = "in" if value < 0 else "out" + else: + tickdir = "inout" + for side in ("theta_inside", "theta_outside"): + if (axis := axis_at(ax, side)) is not None: + axis.set_tick_params( + which="major", length=abs(value), tickdir=tickdir + ) + + +class axis_ticks_length_major_r(themeable): + """ + r-axis major-tick length + + Applies uniformly to whichever r boundary is active — unlike + `axis_ticks_major_r_start`/`_end`, length was never split + by boundary even for the cartesian `axis_ticks_length_major_y`. + + Parameters + ---------- + theme_element : float | complex + Value in points. A negative value creates the ticks + inside the plot panel. A complex value (e.g. `3j`) + creates ticks that span both in and out of the panel. + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + value: float | complex = self.properties["value"] + if isinstance(value, (float, int)): + tickdir = "in" if value < 0 else "out" + else: + tickdir = "inout" + for side in ("r_start", "r_end"): + if (axis := axis_at(ax, side)) is not None: + axis.set_tick_params( + which="major", length=abs(value), tickdir=tickdir + ) + + class axis_ticks_length_major( - axis_ticks_length_major_x, axis_ticks_length_major_y + axis_ticks_length_major_x, + axis_ticks_length_major_y, + axis_ticks_length_major_theta, + axis_ticks_length_major_r, ): """ Axis major-tick length @@ -2173,6 +2652,8 @@ class axis_ticks_length_minor_x(themeable): def apply_ax(self, ax: Axes): super().apply_ax(ax) + if isinstance(ax, PolarAxes): + return value: float | complex = self.properties["value"] if isinstance(value, (float, int)): @@ -2202,6 +2683,8 @@ class axis_ticks_length_minor_y(themeable): def apply_ax(self, ax: Axes): super().apply_ax(ax) + if isinstance(ax, PolarAxes): + return value: float | complex = self.properties["value"] if isinstance(value, (float, int)): @@ -2217,8 +2700,71 @@ def apply_ax(self, ax: Axes): ) +class axis_ticks_length_minor_theta(themeable): + """ + theta-axis minor-tick length + + Applies uniformly to whichever theta boundary is active — unlike + `axis_ticks_minor_theta_outside`/`_inside`, length was never split + by boundary even for the cartesian `axis_ticks_length_minor_x`. + + Parameters + ---------- + theme_element : float | complex + Value in points. A negative value creates the ticks + inside the plot panel. A complex value (e.g. `3j`) + creates ticks that span both in and out of the panel. + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + value: float | complex = self.properties["value"] + if isinstance(value, (float, int)): + tickdir = "in" if value < 0 else "out" + else: + tickdir = "inout" + for side in ("theta_inside", "theta_outside"): + if (axis := axis_at(ax, side)) is not None: + axis.set_tick_params( + which="minor", length=abs(value), tickdir=tickdir + ) + + +class axis_ticks_length_minor_r(themeable): + """ + r-axis minor-tick length + + Applies uniformly to whichever r boundary is active — unlike + `axis_ticks_minor_r_start`/`_end`, length was never split + by boundary even for the cartesian `axis_ticks_length_minor_y`. + + Parameters + ---------- + theme_element : float | complex + Value in points. A negative value creates the ticks + inside the plot panel. A complex value (e.g. `3j`) + creates ticks that span both in and out of the panel. + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + value: float | complex = self.properties["value"] + if isinstance(value, (float, int)): + tickdir = "in" if value < 0 else "out" + else: + tickdir = "inout" + for side in ("r_start", "r_end"): + if (axis := axis_at(ax, side)) is not None: + axis.set_tick_params( + which="minor", length=abs(value), tickdir=tickdir + ) + + class axis_ticks_length_minor( - axis_ticks_length_minor_x, axis_ticks_length_minor_y + axis_ticks_length_minor_x, + axis_ticks_length_minor_y, + axis_ticks_length_minor_theta, + axis_ticks_length_minor_r, ): """ Axis minor-tick length @@ -2795,7 +3341,9 @@ class strip_switch_pad_x(themeable): theme_element : float Size in points. Only has an effect when `strip_placement="outside"` and an axis shares the strip's - side (top or bottom). + side (top or bottom). Polar panels (`coord_radial`) always draw + their theta axis on the arc, so the pad applies to them at any + `strip_placement`. """ @@ -2808,7 +3356,9 @@ class strip_switch_pad_y(themeable): theme_element : float Size in points. Only has an effect when `strip_placement="outside"` and an axis shares the strip's - side (left or right). + side (left or right). Polar panels (`coord_radial`) always draw + their r axis on the arc, so the pad applies to them at any + `strip_placement`. """ @@ -2821,7 +3371,8 @@ class strip_switch_pad(strip_switch_pad_x, strip_switch_pad_y): theme_element : float Size in points. Only has an effect when `strip_placement="outside"` and an axis shares the strip's - side. + side. Polar panels (`coord_radial`) always draw their axes on the + arc, so the pad applies to them at any `strip_placement`. """ diff --git a/plotnine/typing.py b/plotnine/typing.py index 200f95bc7b..7583fce099 100644 --- a/plotnine/typing.py +++ b/plotnine/typing.py @@ -114,6 +114,16 @@ def to_pandas(self) -> pd.DataFrame: # Guide Side: TypeAlias = Literal["left", "right", "top", "bottom"] +PolarSide: TypeAlias = Literal[ + "theta_inside", "theta_outside", "r_start", "r_end" +] +""" +A side of a polar panel's theta or r axis + +`"theta_inside"`/`"theta_outside"` are the donut-hole and rim boundaries +of the angular axis; `"r_start"`/`"r_end"` are the start-angle and +end-angle spokes of the radial axis. +""" LegendPosition: TypeAlias = ( Literal["left", "right", "top", "bottom", "inside"] | tuple[float, float] ) diff --git a/tests/baseline_images/test_coord_radial/arc_wraps_past_twelve.png b/tests/baseline_images/test_coord_radial/arc_wraps_past_twelve.png new file mode 100644 index 0000000000..ad1218fc0a Binary files /dev/null and b/tests/baseline_images/test_coord_radial/arc_wraps_past_twelve.png differ diff --git a/tests/baseline_images/test_coord_radial/axis_line_r_end_on_reversed_arc.png b/tests/baseline_images/test_coord_radial/axis_line_r_end_on_reversed_arc.png new file mode 100644 index 0000000000..83666e47a3 Binary files /dev/null and b/tests/baseline_images/test_coord_radial/axis_line_r_end_on_reversed_arc.png differ diff --git a/tests/baseline_images/test_coord_radial/axis_line_r_reaches_both_spokes.png b/tests/baseline_images/test_coord_radial/axis_line_r_reaches_both_spokes.png new file mode 100644 index 0000000000..a44f965bfd Binary files /dev/null and b/tests/baseline_images/test_coord_radial/axis_line_r_reaches_both_spokes.png differ diff --git a/tests/baseline_images/test_coord_radial/axis_line_r_start_on_full_circle.png b/tests/baseline_images/test_coord_radial/axis_line_r_start_on_full_circle.png new file mode 100644 index 0000000000..10086ab477 Binary files /dev/null and b/tests/baseline_images/test_coord_radial/axis_line_r_start_on_full_circle.png differ diff --git a/tests/baseline_images/test_coord_radial/axis_line_styles_polar_boundaries.png b/tests/baseline_images/test_coord_radial/axis_line_styles_polar_boundaries.png new file mode 100644 index 0000000000..e58ca00be1 Binary files /dev/null and b/tests/baseline_images/test_coord_radial/axis_line_styles_polar_boundaries.png differ diff --git a/tests/baseline_images/test_coord_radial/axis_text_r_blank_hides_radial_labels.png b/tests/baseline_images/test_coord_radial/axis_text_r_blank_hides_radial_labels.png new file mode 100644 index 0000000000..cef4f829f8 Binary files /dev/null and b/tests/baseline_images/test_coord_radial/axis_text_r_blank_hides_radial_labels.png differ diff --git a/tests/baseline_images/test_coord_radial/axis_ticks_theta_blank_keeps_label_gap.png b/tests/baseline_images/test_coord_radial/axis_ticks_theta_blank_keeps_label_gap.png new file mode 100644 index 0000000000..2e5f6abfe8 Binary files /dev/null and b/tests/baseline_images/test_coord_radial/axis_ticks_theta_blank_keeps_label_gap.png differ diff --git a/tests/baseline_images/test_coord_radial/discrete_theta.png b/tests/baseline_images/test_coord_radial/discrete_theta.png new file mode 100644 index 0000000000..cef81738c9 Binary files /dev/null and b/tests/baseline_images/test_coord_radial/discrete_theta.png differ diff --git a/tests/baseline_images/test_coord_radial/donut_full_circle.png b/tests/baseline_images/test_coord_radial/donut_full_circle.png new file mode 100644 index 0000000000..73cf8890a6 Binary files /dev/null and b/tests/baseline_images/test_coord_radial/donut_full_circle.png differ diff --git a/tests/baseline_images/test_coord_radial/donut_narrow_arc.png b/tests/baseline_images/test_coord_radial/donut_narrow_arc.png new file mode 100644 index 0000000000..4b2b239a6a Binary files /dev/null and b/tests/baseline_images/test_coord_radial/donut_narrow_arc.png differ diff --git a/tests/baseline_images/test_coord_radial/expand_false.png b/tests/baseline_images/test_coord_radial/expand_false.png new file mode 100644 index 0000000000..af7c9ca98c Binary files /dev/null and b/tests/baseline_images/test_coord_radial/expand_false.png differ diff --git a/tests/baseline_images/test_coord_radial/facet_wrap.png b/tests/baseline_images/test_coord_radial/facet_wrap.png new file mode 100644 index 0000000000..863dba6c54 Binary files /dev/null and b/tests/baseline_images/test_coord_radial/facet_wrap.png differ diff --git a/tests/baseline_images/test_coord_radial/half_disc.png b/tests/baseline_images/test_coord_radial/half_disc.png new file mode 100644 index 0000000000..044befee50 Binary files /dev/null and b/tests/baseline_images/test_coord_radial/half_disc.png differ diff --git a/tests/baseline_images/test_coord_radial/large_theta_text_fits_in_the_figure.png b/tests/baseline_images/test_coord_radial/large_theta_text_fits_in_the_figure.png new file mode 100644 index 0000000000..abcd66d121 Binary files /dev/null and b/tests/baseline_images/test_coord_radial/large_theta_text_fits_in_the_figure.png differ diff --git a/tests/baseline_images/test_coord_radial/long_r_labels_on_both_spokes.png b/tests/baseline_images/test_coord_radial/long_r_labels_on_both_spokes.png new file mode 100644 index 0000000000..8dcfd8f936 Binary files /dev/null and b/tests/baseline_images/test_coord_radial/long_r_labels_on_both_spokes.png differ diff --git a/tests/baseline_images/test_coord_radial/long_theta_labels_all_quadrants.png b/tests/baseline_images/test_coord_radial/long_theta_labels_all_quadrants.png new file mode 100644 index 0000000000..8d268c39f9 Binary files /dev/null and b/tests/baseline_images/test_coord_radial/long_theta_labels_all_quadrants.png differ diff --git a/tests/baseline_images/test_coord_radial/long_theta_ticks_fit_in_the_figure.png b/tests/baseline_images/test_coord_radial/long_theta_ticks_fit_in_the_figure.png new file mode 100644 index 0000000000..e0c605600d Binary files /dev/null and b/tests/baseline_images/test_coord_radial/long_theta_ticks_fit_in_the_figure.png differ diff --git a/tests/baseline_images/test_coord_radial/one_long_theta_label.png b/tests/baseline_images/test_coord_radial/one_long_theta_label.png new file mode 100644 index 0000000000..86bfa59fbe Binary files /dev/null and b/tests/baseline_images/test_coord_radial/one_long_theta_label.png differ diff --git a/tests/baseline_images/test_coord_radial/path_munched_into_arc.png b/tests/baseline_images/test_coord_radial/path_munched_into_arc.png new file mode 100644 index 0000000000..2bf0f3477b Binary files /dev/null and b/tests/baseline_images/test_coord_radial/path_munched_into_arc.png differ diff --git a/tests/baseline_images/test_coord_radial/pie.png b/tests/baseline_images/test_coord_radial/pie.png new file mode 100644 index 0000000000..289c0f7f91 Binary files /dev/null and b/tests/baseline_images/test_coord_radial/pie.png differ diff --git a/tests/baseline_images/test_coord_radial/reverse_r.png b/tests/baseline_images/test_coord_radial/reverse_r.png new file mode 100644 index 0000000000..96bfe77d55 Binary files /dev/null and b/tests/baseline_images/test_coord_radial/reverse_r.png differ diff --git a/tests/baseline_images/test_coord_radial/reverse_r_donut.png b/tests/baseline_images/test_coord_radial/reverse_r_donut.png new file mode 100644 index 0000000000..a7a1faec7f Binary files /dev/null and b/tests/baseline_images/test_coord_radial/reverse_r_donut.png differ diff --git a/tests/baseline_images/test_coord_radial/reverse_theta_full_circle.png b/tests/baseline_images/test_coord_radial/reverse_theta_full_circle.png new file mode 100644 index 0000000000..bab8e51665 Binary files /dev/null and b/tests/baseline_images/test_coord_radial/reverse_theta_full_circle.png differ diff --git a/tests/baseline_images/test_coord_radial/reverse_theta_partial_arc.png b/tests/baseline_images/test_coord_radial/reverse_theta_partial_arc.png new file mode 100644 index 0000000000..2f62297abd Binary files /dev/null and b/tests/baseline_images/test_coord_radial/reverse_theta_partial_arc.png differ diff --git a/tests/baseline_images/test_coord_radial/ribbon.png b/tests/baseline_images/test_coord_radial/ribbon.png new file mode 100644 index 0000000000..3a56247e62 Binary files /dev/null and b/tests/baseline_images/test_coord_radial/ribbon.png differ diff --git a/tests/baseline_images/test_coord_radial/ribbon_reverse_r.png b/tests/baseline_images/test_coord_radial/ribbon_reverse_r.png new file mode 100644 index 0000000000..16e3b8ba02 Binary files /dev/null and b/tests/baseline_images/test_coord_radial/ribbon_reverse_r.png differ diff --git a/tests/baseline_images/test_coord_radial/ribbon_theta_y.png b/tests/baseline_images/test_coord_radial/ribbon_theta_y.png new file mode 100644 index 0000000000..57335e1cd7 Binary files /dev/null and b/tests/baseline_images/test_coord_radial/ribbon_theta_y.png differ diff --git a/tests/baseline_images/test_coord_radial/rotate_angle_text.png b/tests/baseline_images/test_coord_radial/rotate_angle_text.png new file mode 100644 index 0000000000..7628b9a90e Binary files /dev/null and b/tests/baseline_images/test_coord_radial/rotate_angle_text.png differ diff --git a/tests/baseline_images/test_coord_radial/rotated_full_circle.png b/tests/baseline_images/test_coord_radial/rotated_full_circle.png new file mode 100644 index 0000000000..0a62a99132 Binary files /dev/null and b/tests/baseline_images/test_coord_radial/rotated_full_circle.png differ diff --git a/tests/baseline_images/test_coord_radial/scale_positions_move_only_titles.png b/tests/baseline_images/test_coord_radial/scale_positions_move_only_titles.png new file mode 100644 index 0000000000..3db3985ea9 Binary files /dev/null and b/tests/baseline_images/test_coord_radial/scale_positions_move_only_titles.png differ diff --git a/tests/baseline_images/test_coord_radial/secondary_r_axis_full_circle.png b/tests/baseline_images/test_coord_radial/secondary_r_axis_full_circle.png new file mode 100644 index 0000000000..8559a28e4f Binary files /dev/null and b/tests/baseline_images/test_coord_radial/secondary_r_axis_full_circle.png differ diff --git a/tests/baseline_images/test_coord_radial/secondary_r_axis_partial_arc.png b/tests/baseline_images/test_coord_radial/secondary_r_axis_partial_arc.png new file mode 100644 index 0000000000..af057274d5 Binary files /dev/null and b/tests/baseline_images/test_coord_radial/secondary_r_axis_partial_arc.png differ diff --git a/tests/baseline_images/test_coord_radial/secondary_r_axis_theta_y.png b/tests/baseline_images/test_coord_radial/secondary_r_axis_theta_y.png new file mode 100644 index 0000000000..2972d7d063 Binary files /dev/null and b/tests/baseline_images/test_coord_radial/secondary_r_axis_theta_y.png differ diff --git a/tests/baseline_images/test_coord_radial/secondary_theta_axis_donut.png b/tests/baseline_images/test_coord_radial/secondary_theta_axis_donut.png new file mode 100644 index 0000000000..d39bd27d7e Binary files /dev/null and b/tests/baseline_images/test_coord_radial/secondary_theta_axis_donut.png differ diff --git a/tests/baseline_images/test_coord_radial/secondary_theta_axis_partial_arc.png b/tests/baseline_images/test_coord_radial/secondary_theta_axis_partial_arc.png new file mode 100644 index 0000000000..dc8ef0df60 Binary files /dev/null and b/tests/baseline_images/test_coord_radial/secondary_theta_axis_partial_arc.png differ diff --git a/tests/baseline_images/test_coord_radial/secondary_theta_axis_reverse_theta.png b/tests/baseline_images/test_coord_radial/secondary_theta_axis_reverse_theta.png new file mode 100644 index 0000000000..1bd752f6ee Binary files /dev/null and b/tests/baseline_images/test_coord_radial/secondary_theta_axis_reverse_theta.png differ diff --git a/tests/baseline_images/test_coord_radial/secondary_theta_axis_theta_y.png b/tests/baseline_images/test_coord_radial/secondary_theta_axis_theta_y.png new file mode 100644 index 0000000000..8d3acb8e5f Binary files /dev/null and b/tests/baseline_images/test_coord_radial/secondary_theta_axis_theta_y.png differ diff --git a/tests/baseline_images/test_coord_radial/theming_reaches_all_decorations.png b/tests/baseline_images/test_coord_radial/theming_reaches_all_decorations.png new file mode 100644 index 0000000000..5f13d8d811 Binary files /dev/null and b/tests/baseline_images/test_coord_radial/theming_reaches_all_decorations.png differ diff --git a/tests/baseline_images/test_coord_radial/theming_reaches_the_inside_theta_axis.png b/tests/baseline_images/test_coord_radial/theming_reaches_the_inside_theta_axis.png new file mode 100644 index 0000000000..495d28fac7 Binary files /dev/null and b/tests/baseline_images/test_coord_radial/theming_reaches_the_inside_theta_axis.png differ diff --git a/tests/baseline_images/test_coord_radial/theta_y.png b/tests/baseline_images/test_coord_radial/theta_y.png new file mode 100644 index 0000000000..a9a58bea9e Binary files /dev/null and b/tests/baseline_images/test_coord_radial/theta_y.png differ diff --git a/tests/baseline_images/test_coord_radial/thin_wedge.png b/tests/baseline_images/test_coord_radial/thin_wedge.png new file mode 100644 index 0000000000..c05bf77b9f Binary files /dev/null and b/tests/baseline_images/test_coord_radial/thin_wedge.png differ diff --git a/tests/baseline_images/test_coord_radial/zoom.png b/tests/baseline_images/test_coord_radial/zoom.png new file mode 100644 index 0000000000..bc1ef77d6c Binary files /dev/null and b/tests/baseline_images/test_coord_radial/zoom.png differ diff --git a/tests/baseline_images/test_theme/extension_themeable_applies_from_theme_kwargs.png b/tests/baseline_images/test_theme/extension_themeable_applies_from_theme_kwargs.png new file mode 100644 index 0000000000..13d38e8576 Binary files /dev/null and b/tests/baseline_images/test_theme/extension_themeable_applies_from_theme_kwargs.png differ diff --git a/tests/test_coord_radial.py b/tests/test_coord_radial.py new file mode 100644 index 0000000000..07edd92815 --- /dev/null +++ b/tests/test_coord_radial.py @@ -0,0 +1,578 @@ +import warnings +from math import pi + +import numpy as np +import pandas as pd +import pytest +from numpy.testing import assert_allclose + +from plotnine import ( + aes, + coord_radial, + element_blank, + element_line, + element_text, + facet_wrap, + geom_col, + geom_path, + geom_point, + geom_ribbon, + geom_text, + ggplot, + scale_y_continuous, + theme, +) +from plotnine.coords.coord_radial import polar_bbox +from plotnine.data import mtcars +from plotnine.exceptions import PlotnineError, PlotnineWarning +from plotnine.iapi import labels_view +from plotnine.scales import dup_axis, scale_x_continuous, sec_axis +from plotnine.themes.elements import margin + +p_point = ggplot(mtcars, aes("wt", "mpg")) + geom_point() +p_col = ( + ggplot(mtcars, aes("factor(cyl)", "mpg", fill="factor(cyl)")) + geom_col() +) + +# Points spread evenly around theta, so a chord and an arc differ visibly +path_data = pd.DataFrame({"x": range(6), "y": [3, 8, 5, 9, 4, 7]}) + +pie_data = pd.DataFrame( + {"one": ["a"] * 4, "value": [3, 5, 2, 6], "slice": list("wxyz")} +) + +# A wedge with a hole shows both sets of tick marks and labels clearly +p_wedge = p_point + coord_radial( + start=0.5 * pi, end=-0.5 * pi, inner_radius=0.3 +) + + +def test_arc_range_normalizes_end_forward(): + assert coord_radial(start=1, end=4)._arc_range == (1, 4) + assert_allclose(coord_radial(start=pi, end=0)._arc_range, (pi, 2 * pi)) + assert_allclose( + coord_radial(start=pi, end=2 * pi)._arc_range, (pi, 2 * pi) + ) + assert_allclose(coord_radial(start=0, end=2 * pi)._arc_range, (0, 2 * pi)) + assert coord_radial(start=1)._arc_range == (1, 1 + 2 * pi) + + +def test_rejects_zero_width_arc(): + with pytest.raises(PlotnineError, match="zero-width arc"): + coord_radial(start=1, end=1) + + +def test_classifies_full_circle_from_arc_range(): + assert coord_radial()._is_full_circle + assert coord_radial(start=0, end=2 * pi)._is_full_circle + assert coord_radial(start=1, end=1 + 2 * pi)._is_full_circle + assert coord_radial(end=2 * pi, reverse="theta")._is_full_circle + assert not coord_radial(start=0, end=pi)._is_full_circle + + +def test_polar_bbox_full_circle_is_unit_square(): + assert polar_bbox((0.0, 2 * pi)) == (0.0, 1.0, 0.0, 1.0) + + +def test_aspect_is_square(): + assert coord_radial().aspect(None) == 1 + + +@pytest.mark.parametrize( + ("start", "end", "expected"), + [ + (-pi / 2, pi / 2, 0.5), # right half-disc: wide box + (0.0, 0.25, pytest.approx(4.041, abs=1e-2)), # thin sliver: tall box + (-pi / 2, 0.0, 1.0), # quarter: square + (0.0, 2 * pi, 1.0), # full circle: square + ], +) +def test_aspect_matches_wedge(start: float, end: float, expected: float): + assert coord_radial(start=start, end=end).aspect(None) == expected + + +def test_aspect_uses_zero_margin_for_half_disc(): + # margin 0 (not ggplot2's 0.05) -> exactly 0.5, so the panel matches the + # tight wedge and the sector is not stretched. + assert coord_radial(start=-pi / 2, end=pi / 2).aspect(None) == 0.5 + + +def test_to_radians_zero_width_range(): + assert_allclose(coord_radial()._to_radians(np.array([1, 2, 3]), (1, 1)), 0) + + +def test_swaps_labels_when_theta_y(): + out = coord_radial(theta="y").labels(labels_view(x="xlab", y="ylab")) + assert out.x == "ylab" + assert out.y == "xlab" + + +def test_keeps_labels_when_theta_x(): + out = coord_radial(theta="x").labels(labels_view(x="xlab", y="ylab")) + assert out.x == "xlab" + assert out.y == "ylab" + + +def test_no_longer_has_r_axis_inside(): + with pytest.raises(TypeError, match="r_axis_inside"): + coord_radial(r_axis_inside=True) # type: ignore[call-arg] + + +def test_coord_polar_is_alias_of_coord_radial(): + # coord_polar is superseded; it is exported only as an alias so old + # code keeps working. It IS coord_radial with the same signature. + from plotnine import coord_polar + + assert issubclass(coord_polar, coord_radial) + assert isinstance( + coord_polar(theta="y", inner_radius=0.4, end=3.14), coord_radial + ) + assert coord_polar.__doc__ == ( + "alias of [coord_radial](`plotnine.coords.coord_radial.coord_radial`)" + ) + + +def test_full_circle_position_right_no_warning(): + # position="right" moves only the r title, so a full circle no longer + # warns that it has no visible effect. This is the one test that draws; + # it inspects nothing afterwards. + p = ( + ggplot(mtcars, aes("disp", "mpg")) + + geom_point() + + coord_radial() + + scale_y_continuous(position="right") + ) + with warnings.catch_warnings(): + warnings.simplefilter("error", PlotnineWarning) + p.draw_test() # pyright: ignore[reportAttributeAccessIssue] + + +def test_half_disc(): + p = p_point + coord_radial(start=-pi / 2, end=pi / 2) + assert p == "half_disc" + + +def test_thin_wedge(): + p = p_point + coord_radial(start=-pi / 2, end=-pi / 3) + assert p == "thin_wedge" + + +def test_arc_wraps_past_twelve(): + # end < start, so the end normalises forward and the arc sweeps through + # 12 o'clock rather than backwards to meet the start. + p = p_point + coord_radial(start=pi, end=pi / 2) + assert p == "arc_wraps_past_twelve" + + +def test_rotated_full_circle(): + # The theta labels and the labelling r spoke both follow `start`. + p = p_point + coord_radial(start=pi / 4) + assert p == "rotated_full_circle" + + +def test_donut_full_circle(): + p = p_point + coord_radial(inner_radius=0.3) + assert p == "donut_full_circle" + + +def test_donut_narrow_arc(): + # The inner ring must sit at inner_radius * outer_radius. When it did + # not, the panel aspect mismatched the wedge and the sector under-filled. + p = p_point + coord_radial(start=pi / 4, end=3 * pi / 4, inner_radius=0.3) + assert p == "donut_narrow_arc" + + +def test_theta_y(): + # theta="y" puts mpg on the arc and wt on the radius, and swaps which + # axis title the layout treats as the theta title. + p = p_point + coord_radial("y") + assert p == "theta_y" + + +def test_discrete_theta(): + # A discrete theta scale spans the whole circle rather than leaving the + # last category short of the first. + p = p_col + coord_radial() + assert p == "discrete_theta" + + +def test_expand_false(): + # Every other image in this file uses the default buffer, which holds the + # data clear of the arc ends and the outer radius. Turning it off is what + # needs an image of its own: the bars then run flush to both ends of the + # arc and out to the rim. + p = p_col + coord_radial(start=0, end=pi, inner_radius=0.1, expand=False) + assert p == "expand_false" + + +def test_zoom(): + # Both axes zoom on one coordinate system. The radius recomputes nice + # breaks over (10, 25) rather than filtering the full-range breaks, which + # would leave almost none, and (2, 4) of wt spans the whole circle. + p = p_point + coord_radial(rlim=(10, 25), thetalim=(2, 4)) + assert p == "zoom" + + +def test_reverse_theta_full_circle(): + # The sweep is still clockwise; the data runs the other way along it. + p = p_col + coord_radial(reverse="theta") + assert p == "reverse_theta_full_circle" + + +def test_reverse_theta_partial_arc(): + # On a partial arc the data runs from end back to start, so the + # labelling r spoke follows it to the end spoke. + p = p_col + coord_radial( + start=pi, end=pi / 2, inner_radius=0.1, reverse="theta" + ) + assert p == "reverse_theta_partial_arc" + + +def test_reverse_r(): + p = p_point + coord_radial(reverse="r") + assert p == "reverse_r" + + +def test_reverse_r_donut(): + p = p_point + coord_radial(reverse="r", inner_radius=0.3) + assert p == "reverse_r_donut" + + +def test_path_munched_into_arc(): + # Each segment is subdivided before the radian transform, so it bends + # along the arc instead of cutting a straight chord across it. + p = ggplot(path_data, aes("x", "y")) + geom_path(size=1) + coord_radial() + assert p == "path_munched_into_arc" + + +def test_ribbon(): + p = ( + ggplot(path_data, aes("x", ymin="y - 2", ymax="y + 2")) + + geom_ribbon(alpha=0.5) + + coord_radial() + ) + assert p == "ribbon" + + +def test_ribbon_reverse_r(): + # Radial reversal must negate the ribbon bounds with its centreline; + # otherwise, the band falls outside the visible range. + p = ( + ggplot(path_data, aes("x", ymin="y - 2", ymax="y + 2")) + + geom_ribbon(alpha=0.5) + + coord_radial(reverse="r") + ) + assert p == "ribbon_reverse_r" + + +def test_ribbon_theta_y(): + # With `theta="y"`, `ymin` and `ymax` become angular bounds at each `x` + # radius. + p = ( + ggplot(path_data, aes("x", ymin="y - 2", ymax="y + 2")) + + geom_ribbon(alpha=0.5) + + coord_radial(theta="y") + ) + assert p == "ribbon_theta_y" + + +def test_rotate_angle_text(): + # The labels align tangentially to the arc and fold into (-90, 90], so + # the one at the bottom of the circle stays readable. + p = ( + ggplot(path_data, aes("x", "y", label="y")) + + geom_text(angle=0, size=18, color="green") + + coord_radial(rotate_angle=True) + ) + assert p == "rotate_angle_text" + + +def test_pie(): + p = ( + ggplot(pie_data, aes("one", "value", fill="slice")) + + geom_col() + + coord_radial("y") + ) + assert p == "pie" + + +def test_axis_line_styles_polar_boundaries(): + # panel_border no longer owns the outer circle. axis_line does, and + # unlike panel_border it can style it rather than only hide it. On a + # donut arc the same themeable shows which boundaries it owns: the outer + # arc and the spoke holding the radial axis are drawn, while the hole, + # which no angular axis sits on, is left bare. + p = ( + p_col + + coord_radial(start=-pi / 2, end=pi / 2, inner_radius=0.3) + + theme( + panel_border=element_blank(), + axis_line=element_line(color="red", size=2), + ) + ) + assert p == "axis_line_styles_polar_boundaries" + + +def test_axis_line_r_start_on_full_circle(): + # A full circle's start and end spokes coincide, so matplotlib hides + # them by default. Theming axis_line_r_start must show it anyway. + p = ( + p_col + + coord_radial() + + theme(axis_line_r_start=element_line(color="blue", size=2)) + ) + assert p == "axis_line_r_start_on_full_circle" + + +def test_axis_line_r_end_on_reversed_arc(): + # A spoke is themeable only where a radial axis lives, and reverse= + # "theta" moves the only one to the end spoke. So axis_line_r_end + # styles that spoke and the start spoke stays bare. + p = ( + p_col + + coord_radial( + start=-pi / 2, end=pi / 2, inner_radius=0.1, reverse="theta" + ) + + theme(axis_line_r_end=element_line(color="blue", size=2)) + ) + assert p == "axis_line_r_end_on_reversed_arc" + + +def test_axis_line_r_reaches_both_spokes(): + # A secondary axis puts a radial axis on each spoke, so the parent + # themeable styles both leaves at once. + p = ( + p_col + + scale_y_continuous(sec_axis=sec_axis(lambda x: x * 2)) + + coord_radial(start=-pi / 2, end=pi / 2, inner_radius=0.1) + + theme(axis_line_r=element_line(color="blue", size=2)) + ) + assert p == "axis_line_r_reaches_both_spokes" + + +def test_theming_reaches_all_decorations(): + # The general themeables reach every polar decoration and the polar + # leaves refine them. Both label sets are red at size 13 from axis_text, + # and the angular labels sit further out because their own leaf adds a + # margin while inheriting that colour and size. Major marks are blue and + # minor ones green, on the arc and on the spokes alike. The angular marks + # are the longer pair, each axis taking its own themed length. The grid + # thickens on the rings and the spokes together. A secondary radial axis + # gives axis_text a second set of radial labels to reach. + p = ( + p_wedge + + scale_y_continuous(sec_axis=sec_axis(lambda x: x * 2)) + + theme( + axis_text=element_text(color="red", size=13), + axis_text_theta=element_text(margin=margin(r=12)), + axis_ticks=element_line(color="blue", size=2), + axis_ticks_minor=element_line(color="green", size=1), + axis_ticks_length_major_theta=10, + panel_grid=element_line(color="white", size=1.5), + ) + ) + assert p == "theming_reaches_all_decorations" + + +def test_axis_ticks_theta_blank_keeps_label_gap(): + # A blank tick contributes no length, so the label keeps its plain gap + # to the arc rather than being pushed out by the themed length. + p = p_wedge + theme( + axis_ticks_major_theta=element_blank(), + axis_ticks_length_major_theta=20, + ) + assert p == "axis_ticks_theta_blank_keeps_label_gap" + + +def test_large_theta_text_fits_in_the_figure(): + # Larger theta labels must shrink the panel enough to remain inside the + # figure. + p = p_point + coord_radial() + theme(axis_text_theta=element_text(size=28)) + assert p == "large_theta_text_fits_in_the_figure" + + +def test_long_theta_ticks_fit_in_the_figure(): + # Longer visible theta ticks move their labels outward, so the panel must + # shrink by the themed tick length. + p = p_point + coord_radial() + theme(axis_ticks_length_major_theta=60) + assert p == "long_theta_ticks_fit_in_the_figure" + + +def test_one_long_theta_label(): + # A label reserves space only on the sides it reaches, so one long + # label costs the panel nothing on the other three. + p = ( + p_point + + scale_x_continuous( + breaks=[2, 3, 4, 5], + labels=["2", "3", "Compact and subcompact", "5"], + ) + + coord_radial() + ) + assert p == "one_long_theta_label" + + +def test_long_theta_labels_all_quadrants(): + # Each long label must meet the rim beside its own tick in every quadrant. + p = ( + p_point + + scale_x_continuous( + breaks=[1.5, 2, 2.5, 3, 3.5, 4, 4.5, 5], + labels=[f"long label {i}" for i in range(1, 9)], + ) + + coord_radial() + ) + assert p == "long_theta_labels_all_quadrants" + + +def test_long_r_labels_on_both_spokes(): + # Labels on both collinear spokes must face away from the filled half. + p = p_wedge + scale_y_continuous( + breaks=[10, 20, 30], + labels=["ten mpg", "twenty mpg", "thirty mpg"], + sec_axis=sec_axis( + lambda x: x * 2, + breaks=[20, 40, 60], + labels=["twenty kph", "forty kph", "sixty kph"], + ), + ) + assert p == "long_r_labels_on_both_spokes" + + +def test_axis_text_r_blank_hides_radial_labels(): + # `axis_text_r` hides both radial axes without hiding the theta axis. On a + # full circle, the primary and secondary radial labels share one spoke. + p = ( + p_point + + scale_y_continuous(sec_axis=sec_axis(lambda x: x * 2)) + + coord_radial() + + theme(axis_text_r=element_blank()) + ) + assert p == "axis_text_r_blank_hides_radial_labels" + + +def test_scale_positions_move_only_titles(): + # On a polar panel a scale's position moves its axis title and nothing + # else. Moving both at once shows each title on its new side while the + # angular axis stays outside the arc and the radial axis stays on the + # start spoke. + p = ( + p_point + + coord_radial(start=-1.0, end=1.0, inner_radius=0.3) + + scale_x_continuous(position="top") + + scale_y_continuous(position="right") + ) + assert p == "scale_positions_move_only_titles" + + +def test_secondary_r_axis_partial_arc(): + p = ( + p_point + + scale_y_continuous( + sec_axis=sec_axis(lambda x: x * 0.354006, name="km/L") + ) + + coord_radial(start=-pi / 2, end=pi / 2, inner_radius=0.1) + + theme(axis_line_r=element_line()) + ) + assert p == "secondary_r_axis_partial_arc" + + +def test_secondary_r_axis_full_circle(): + # A full circle's start and end spokes coincide, so the secondary axis + # shares the primary's spoke. Its labels and marks go to the other side + # of that spoke rather than onto a spoke of their own. + p = ( + p_point + + scale_y_continuous( + sec_axis=sec_axis(lambda x: x * 2, breaks=[20, 40, 60]) + ) + + coord_radial() + + theme( + axis_line_r=element_line(), + axis_ticks_length_major=20, + ) + ) + assert p == "secondary_r_axis_full_circle" + + +def test_secondary_r_axis_theta_y(): + p = ( + p_point + + scale_x_continuous( + sec_axis=sec_axis(lambda x: x * 0.354006, name="scaled") + ) + + coord_radial("y", start=-pi / 2, end=pi / 2) + ) + assert p == "secondary_r_axis_theta_y" + + +def test_secondary_theta_axis_donut(): + # The ticks and labels move to the inner rim, while the title retains the + # secondary scale's position above the panel. + p = ( + p_point + + scale_x_continuous( + sec_axis=sec_axis(lambda x: x * 1000, name="grams") + ) + + coord_radial(inner_radius=0.45) + ) + assert p == "secondary_theta_axis_donut" + + +def test_secondary_theta_axis_partial_arc(): + # Both theta axes omit breaks outside the visible arc. + p = ( + p_point + + scale_x_continuous(sec_axis=sec_axis(lambda x: x * 2)) + + coord_radial(start=-pi / 2, end=pi / 2, inner_radius=0.4) + ) + assert p == "secondary_theta_axis_partial_arc" + + +def test_secondary_theta_axis_theta_y(): + # With `theta="y"`, the y scale supplies both angular axes. + p = ( + p_point + + scale_y_continuous( + sec_axis=sec_axis(lambda x: x * 0.354006, name="km/L") + ) + + coord_radial("y", start=-pi / 2, end=pi / 2, inner_radius=0.4) + ) + assert p == "secondary_theta_axis_theta_y" + + +def test_secondary_theta_axis_reverse_theta(): + # Reversing theta maps both angular axes in the opposite direction. + p = ( + p_point + + scale_x_continuous(sec_axis=dup_axis()) + + coord_radial(inner_radius=0.4, reverse="theta") + ) + assert p == "secondary_theta_axis_reverse_theta" + + +def test_theming_reaches_the_inside_theta_axis(): + # General axis themeables style every axis, while inside variants override + # the secondary theta axis. The shared theta tick length makes both sets + # cross their spines. + p = ( + p_point + + scale_x_continuous(sec_axis=dup_axis()) + + coord_radial(inner_radius=0.45) + + theme( + axis_text=element_text(color="red", size=10), + axis_text_theta_inside=element_text(size=14, margin=margin(b=10)), + axis_ticks=element_line(color="blue", size=2), + axis_ticks_major_theta_inside=element_line(color="green"), + axis_ticks_length_major_theta=12, + axis_line_theta_inside=element_line(color="purple", size=2), + ) + ) + assert p == "theming_reaches_the_inside_theta_axis" + + +def test_facet_wrap(): + # Every polar panel draws its full theta and radial decorations, so the + # gulley must hold them and the strip band must clear the arc apex. + p = p_point + facet_wrap("gear", nrow=2) + coord_radial(start=-1, end=1) + assert p == "facet_wrap" diff --git a/tests/test_margin.py b/tests/test_margin.py index 472e2907b0..e935fef729 100644 --- a/tests/test_margin.py +++ b/tests/test_margin.py @@ -23,6 +23,18 @@ def test_conversion_before_setup_raises(): m.to("fig") +@pytest.mark.parametrize( + ("m", "expected"), + [ + (margin(t=2, r=4, b=1, l=3), 4), + (margin(t=-2, r=0, b=0, l=0), 0), + (margin(t=-5, r=-2, b=-8, l=-4), -2), + ], +) +def test_max(m: margin, expected: float): + assert m.max == expected + + def test_conversion_same_unit_after_setup_succeeds(): m = setup_margin(margin(t=5, r=5, b=5, l=5, unit="pt")) result = m.to("pt") diff --git a/tests/test_theme.py b/tests/test_theme.py index c5f025eae5..69d3071e5d 100644 --- a/tests/test_theme.py +++ b/tests/test_theme.py @@ -34,6 +34,7 @@ theme_xkcd, ) from plotnine.data import mtcars +from plotnine.themes.themeable import themeable LT_MPL310 = version.parse(mpl.__version__) < version.parse("3.10") IS_CI = bool(os.environ.get("CI")) @@ -111,6 +112,23 @@ def test_add_element_blank(): assert theme3 == theme4 # blanking cleans the slate +def test_extension_themeable_applies_from_theme_kwargs(): + # A themeable defined outside plotnine is reachable by its own name as a + # theme() keyword, and its apply_ax runs during the draw. The red panel + # is the evidence that it ran. + class test_extension_panel_facecolor(themeable): + def apply_ax(self, ax): + super().apply_ax(ax) + ax.set_facecolor(self.properties["value"]) + + p = ( + ggplot(mtcars, aes(x="wt", y="mpg")) + + geom_point() + + theme(test_extension_panel_facecolor="red") + ) + assert p == "extension_themeable_applies_from_theme_kwargs" + + def test_element_line_dashed_capstyle(): p = ggplot(mtcars, aes(x="wt", y="mpg")) + theme( panel_grid=element_line(