diff --git a/doc/_quartodoc.yml b/doc/_quartodoc.yml index 9c2dd3d6c..de9be48dd 100644 --- a/doc/_quartodoc.yml +++ b/doc/_quartodoc.yml @@ -291,6 +291,8 @@ quartodoc: - scale_y_sqrt - scale_y_symlog - scale_y_timedelta + - sec_axis + - dup_axis - subtitle: Shape Scales options: *no-members diff --git a/doc/changelog.qmd b/doc/changelog.qmd index 4b68adaf3..121b2fa56 100644 --- a/doc/changelog.qmd +++ b/doc/changelog.qmd @@ -7,6 +7,10 @@ title: Changelog ### New +- You can now add a secondary axis to a plot, either as a one-to-one + transformation of the primary axis with [](:class:`~plotnine.sec_axis`), or as a + mirror of the primary axis with [](:func:`~plotnine.dup_axis`). + - 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 69526927b..5b7a79c49 100644 --- a/plotnine/__init__.py +++ b/plotnine/__init__.py @@ -119,6 +119,7 @@ qplot, ) from .scales import ( + dup_axis, expand_limits, lims, scale_alpha, @@ -217,6 +218,7 @@ scale_y_sqrt, scale_y_symlog, scale_y_timedelta, + sec_axis, xlim, ylim, ) @@ -290,6 +292,7 @@ "coord_fixed", "coord_flip", "coord_trans", + "dup_axis", "element_blank", "element_line", "element_rect", @@ -461,6 +464,7 @@ "scale_y_sqrt", "scale_y_symlog", "scale_y_timedelta", + "sec_axis", "stage", "stat_bin", "stat_bin2d", diff --git a/plotnine/_mpl/axes.py b/plotnine/_mpl/axes.py new file mode 100644 index 000000000..9b89c280f --- /dev/null +++ b/plotnine/_mpl/axes.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, TypeVar + +from matplotlib import cbook +from matplotlib.axes import Axes +from matplotlib.axis import XAxis, YAxis +from matplotlib.projections import register_projection + +if TYPE_CHECKING: + from plotnine.typing import Side + +AxisT = TypeVar("AxisT", XAxis, YAxis) + + +class p9Axes(Axes): + """ + Axes of a plotnine panel + + In addition to the regular x & y axes, a panel can hold one + secondary axis per dimension (`sec_xaxis`/`sec_yaxis`), drawn on + the side opposite the primary with its own fixed breaks and labels. + + The panel is the shared substrate (data space, patch, grid, and the + four spines); each `Axis` carries the per-axis state (ticks, + labels, locator/formatter, and its active side); a secondary axis + is just one more `Axis` living in the panel's data space. + + Spines start hidden; each axis shows the panel spine of the side it + occupies. + """ + + name = "plotnine" + + # mpl resolves every Axis operation through the per-name axis + # registries; the secondary axes register under their own names. + _shared_axes = { + **Axes._shared_axes, # pyright: ignore[reportAttributeAccessIssue] + "sec_x": cbook.Grouper(), + "sec_y": cbook.Grouper(), + } + + # The secondary axis of each dimension; None until added + sec_xaxis: XAxis | None + sec_yaxis: YAxis | None + + # Sides of the panel that carry an axis (primary or secondary); + # setup shows their spines. + sides_with_an_axis: set[Side] + + def __init__(self, *args, **kwargs): + self.sec_xaxis = None + self.sec_yaxis = None + self.sides_with_an_axis = set() + super().__init__(*args, **kwargs) + self._sharesec_x = None + self._sharesec_y = None + # Spines are opt-in: each axis (primary or secondary) shows + # the spine of the side it occupies. + self.spines[:].set_visible(False) + + def add_sec_axis(self, side: Side) -> XAxis | YAxis: + """ + Return the secondary axis for `side`, creating it if needed + + The axis starts bare — no breaks, labels or active side; the + caller configures it like any other axis. + + Parameters + ---------- + side : + Side of the panel the secondary axis will occupy. + + Returns + ------- + : + The secondary axis of the side's dimension. + """ + if side in ("top", "bottom"): + if self.sec_xaxis is None: + self.sec_xaxis = self._make_sec_axis(XAxis, "sec_x") + return self.sec_xaxis + else: + if self.sec_yaxis is None: + self.sec_yaxis = self._make_sec_axis(YAxis, "sec_y") + return self.sec_yaxis + + def _make_sec_axis(self, cls: type[AxisT], name: str) -> AxisT: + axis = cls(self) + # Register the axis so mpl can resolve its name, and add it to + # the draw tree. Panels are never cleared after this point; + # Axes.clear() would detach the artist. + self._axis_map[name] = axis + self.add_artist(axis) + axis.set_clip_on(False) + axis.grid(False) + return axis + + +def axis_at(ax: Axes, side: Side) -> XAxis | YAxis | None: + """ + Return the axis of `ax` whose ticks occupy `side`, if any + + Considers the primary axis of the side's dimension and, on a + `p9Axes`, the secondary one. + + Parameters + ---------- + ax : + Panel axes. + side : + Side of the panel. + + Returns + ------- + : + The axis with active ticks on `side`, or `None` when that side + shows no ticks (e.g. an interior facet panel). + """ + 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 + + +register_projection(p9Axes) diff --git a/plotnine/_mpl/layout_manager/_plot_layout_items.py b/plotnine/_mpl/layout_manager/_plot_layout_items.py index 02e8bae75..2dffe1d72 100644 --- a/plotnine/_mpl/layout_manager/_plot_layout_items.py +++ b/plotnine/_mpl/layout_manager/_plot_layout_items.py @@ -10,6 +10,7 @@ from plotnine.composition._compose import Compose from plotnine.exceptions import PlotnineError +from ..axes import axis_at from ..utils import ( ArtistGeometry, TextJustifier, @@ -44,6 +45,7 @@ from plotnine.themes.theme import theme from plotnine.typing import ( HorizontalJustification, + Side, StripPosition, VerticalJustification, ) @@ -206,15 +208,15 @@ def _filter_axes(self, location: AxesLocation = "all") -> list[Axes]: if getattr(spec, pred_method)() ] - def axis_text_x(self, ax: Axes, side: str) -> Iterator[Text]: + def axis_text_x(self, ax: Axes, side: Side) -> Iterator[Text]: """ Return the visible x-axis labels on one side of an axes """ major, minor = [], [] - if not self._is_blank("axis_text_x"): - major = ax.xaxis.get_major_ticks() - minor = ax.xaxis.get_minor_ticks() + if not self._is_blank("axis_text_x") and (axis := axis_at(ax, side)): + major = axis.get_major_ticks() + minor = axis.get_minor_ticks() label_attr = side_artists(side)[1] return ( @@ -223,15 +225,15 @@ def axis_text_x(self, ax: Axes, side: str) -> Iterator[Text]: if _text_is_visible(getattr(tick, label_attr)) ) - def axis_text_y(self, ax: Axes, side: str) -> Iterator[Text]: + def axis_text_y(self, ax: Axes, side: Side) -> Iterator[Text]: """ Return the visible y-axis labels on one side of an axes """ major, minor = [], [] - if not self._is_blank("axis_text_y"): - major = ax.yaxis.get_major_ticks() - minor = ax.yaxis.get_minor_ticks() + if not self._is_blank("axis_text_y") and (axis := axis_at(ax, side)): + major = axis.get_major_ticks() + minor = axis.get_minor_ticks() label_attr = side_artists(side)[1] return ( @@ -240,31 +242,33 @@ def axis_text_y(self, ax: Axes, side: str) -> Iterator[Text]: if _text_is_visible(getattr(tick, label_attr)) ) - def axis_ticks_x(self, ax: Axes) -> Iterator[Tick]: + def axis_ticks_x(self, ax: Axes, side: Side) -> Iterator[Tick]: """ - Return all XTicks that will be shown + Return all XTicks on `side` that will be shown """ major, minor = [], [] - if not self._is_blank("axis_ticks_major_x"): - major = ax.xaxis.get_major_ticks() + if (axis := axis_at(ax, side)) is not None: + if not self._is_blank("axis_ticks_major_x"): + major = axis.get_major_ticks() - if not self._is_blank("axis_ticks_minor_x"): - minor = ax.xaxis.get_minor_ticks() + if not self._is_blank("axis_ticks_minor_x"): + minor = axis.get_minor_ticks() return chain(major, minor) - def axis_ticks_y(self, ax: Axes) -> Iterator[Tick]: + def axis_ticks_y(self, ax: Axes, side: Side) -> Iterator[Tick]: """ - Return all YTicks that will be shown + Return all YTicks on `side` that will be shown """ major, minor = [], [] - if not self._is_blank("axis_ticks_major_y"): - major = ax.yaxis.get_major_ticks() + if (axis := axis_at(ax, side)) is not None: + if not self._is_blank("axis_ticks_major_y"): + major = axis.get_major_ticks() - if not self._is_blank("axis_ticks_minor_y"): - minor = ax.yaxis.get_minor_ticks() + if not self._is_blank("axis_ticks_minor_y"): + minor = axis.get_minor_ticks() return chain(major, minor) @@ -383,20 +387,20 @@ def strip_shift(self, st: StripText) -> float: pad_pt = theme.getp(f"strip_switch_pad_{g}") or 0 return band + (pad_pt / 72) / dim - def axis_ticks_x_max_height(self, ax: Axes, side: str) -> float: + def axis_ticks_x_max_height(self, ax: Axes, side: Side) -> float: """ Return maximum height[figure space] of visible x ticks on a side """ attr = side_artists(side)[0] heights = [ self.geometry.tight_height(getattr(tick, attr)) - for tick in self.axis_ticks_x(ax) + for tick in self.axis_ticks_x(ax, side) if getattr(tick, attr).get_visible() ] return max(heights) if len(heights) else 0 def axis_ticks_x_max_height_at( - self, location: AxesLocation, side: str + self, location: AxesLocation, side: Side ) -> float: """ Return maximum height[figure space] of visible x ticks on a side @@ -407,7 +411,7 @@ def axis_ticks_x_max_height_at( ] return max(heights) if len(heights) else 0 - def axis_text_x_max_height(self, ax: Axes, side: str) -> float: + def axis_text_x_max_height(self, ax: Axes, side: Side) -> float: """ Return maximum height[figure space] of x tick labels on a side """ @@ -418,7 +422,7 @@ def axis_text_x_max_height(self, ax: Axes, side: str) -> float: return max(heights) if len(heights) else 0 def axis_text_x_max_height_at( - self, location: AxesLocation, side: str + self, location: AxesLocation, side: Side ) -> float: """ Return maximum height[figure space] of x tick labels on a side @@ -429,20 +433,20 @@ def axis_text_x_max_height_at( ] return max(heights) if len(heights) else 0 - def axis_ticks_y_max_width(self, ax: Axes, side: str) -> float: + def axis_ticks_y_max_width(self, ax: Axes, side: Side) -> float: """ Return maximum width[figure space] of visible y ticks on a side """ attr = side_artists(side)[0] widths = [ self.geometry.tight_width(getattr(tick, attr)) - for tick in self.axis_ticks_y(ax) + for tick in self.axis_ticks_y(ax, side) if getattr(tick, attr).get_visible() ] return max(widths) if len(widths) else 0 def axis_ticks_y_max_width_at( - self, location: AxesLocation, side: str + self, location: AxesLocation, side: Side ) -> float: """ Return maximum width[figure space] of visible y ticks on a side @@ -453,7 +457,7 @@ def axis_ticks_y_max_width_at( ] return max(widths) if len(widths) else 0 - def axis_text_y_max_width(self, ax: Axes, side: str) -> float: + def axis_text_y_max_width(self, ax: Axes, side: Side) -> float: """ Return maximum width[figure space] of y tick labels on a side """ @@ -464,7 +468,7 @@ def axis_text_y_max_width(self, ax: Axes, side: str) -> float: return max(widths) if len(widths) else 0 def axis_text_y_max_width_at( - self, location: AxesLocation, side: str + self, location: AxesLocation, side: Side ) -> float: """ Return maximum width[figure space] of y tick labels on a side @@ -742,7 +746,7 @@ def _position_strip_band_axes(self, spaces: PlotSideSpaces): fig = self.plot.figure to_points = 72 / fig.dpi W, H = fig.bbox.width, fig.bbox.height - offsets = { + offsets: dict[Side, float] = { "top": spaces.t.strip_band_offset() * H, "bottom": spaces.b.strip_band_offset() * H, "left": spaces.l.strip_band_offset() * W, @@ -750,9 +754,8 @@ def _position_strip_band_axes(self, spaces: PlotSideSpaces): } for ax in self.plot.axs: for side, offset in offsets.items(): - if not offset: + if not offset or (axis := axis_at(ax, side)) is None: continue - axis = ax.xaxis if side in ("top", "bottom") else ax.yaxis _spine_set_position_outward( ax.spines[side], axis, offset * to_points ) diff --git a/plotnine/coords/coord.py b/plotnine/coords/coord.py index 0e530b67d..a86abb6ad 100644 --- a/plotnine/coords/coord.py +++ b/plotnine/coords/coord.py @@ -2,6 +2,7 @@ import typing from copy import copy +from typing import cast import numpy as np @@ -9,14 +10,21 @@ from ..iapi import panel_ranges if typing.TYPE_CHECKING: - from typing import Any + from typing import Any, Sequence import numpy.typing as npt import pandas as pd from matplotlib.axes import Axes + from matplotlib.axis import XAxis, YAxis from plotnine import ggplot - from plotnine.iapi import labels_view, layout_details, panel_view + from plotnine._mpl.axes import p9Axes + from plotnine.iapi import ( + labels_view, + layout_details, + panel_view, + scale_position_view, + ) from plotnine.scales.scale_xy import ScaleX, ScaleY from plotnine.typing import ( FloatArray, @@ -44,6 +52,67 @@ def _activate_axis(axis, active_side: Side, present: bool): ) +def _inf_to_none( + t: tuple[float, float], +) -> tuple[float | None, float | None]: + """ + Replace infinities with None + """ + a = t[0] if np.isfinite(t[0]) else None + b = t[1] if np.isfinite(t[1]) else None + return (a, b) + + +def _set_fixed_ticks( + axis: XAxis | YAxis, + breaks: Sequence[float] | Sequence[str], + labels: Sequence[str], + minor_breaks: FloatArrayLike = (), +): + """ + Fixed major/minor tick positions and labels for one mpl axis + + Parameters + ---------- + axis : + Axis to set up. + breaks : + Positions of the major ticks. + labels : + Labels of the major ticks. + minor_breaks : + Positions of the minor ticks. + """ + from .._mpl.ticker import MyFixedFormatter + + axis.set_ticks(breaks, labels) + axis.set_ticks(minor_breaks, minor=True) + # When you manually set the tick labels MPL changes the locator + # so that it no longer reports the x & y positions + # Fixes https://github.com/has2k1/plotnine/issues/187 + axis.set_major_formatter(MyFixedFormatter(labels)) + + +def _side_axis(ax: Axes, side: Side) -> XAxis | YAxis: + """ + Return the axis (x or y) that can occupy `side` + + Parameters + ---------- + ax : + Panel axes. + side : + Side of the panel. + + Returns + ------- + : + `ax.xaxis` for `"top"`/`"bottom"`, `ax.yaxis` for + `"left"`/`"right"`. + """ + return ax.xaxis if side in ("top", "bottom") else ax.yaxis + + class coord: """ Base class for all coordinate systems @@ -145,35 +214,11 @@ def _setup_ticks_labels(self, ax: Axes, panel_params: panel_view) -> None: """ Limits, major/minor breaks, tick labels, and fixed formatter on `ax` """ - from .._mpl.ticker import MyFixedFormatter - - def _inf_to_none( - t: tuple[float, float], - ) -> tuple[float | None, float | None]: - """ - Replace infinities with None - """ - a = t[0] if np.isfinite(t[0]) else None - b = t[1] if np.isfinite(t[1]) else None - return (a, b) - - # limits - ax.set_xlim(*_inf_to_none(panel_params.x.range)) - ax.set_ylim(*_inf_to_none(panel_params.y.range)) - - # breaks, labels - ax.set_xticks(panel_params.x.breaks, panel_params.x.labels) - ax.set_yticks(panel_params.y.breaks, panel_params.y.labels) - - # minor breaks - ax.set_xticks(panel_params.x.minor_breaks, minor=True) - ax.set_yticks(panel_params.y.minor_breaks, minor=True) - - # When you manually set the tick labels MPL changes the locator - # so that it no longer reports the x & y positions - # Fixes https://github.com/has2k1/plotnine/issues/187 - ax.xaxis.set_major_formatter(MyFixedFormatter(panel_params.x.labels)) - ax.yaxis.set_major_formatter(MyFixedFormatter(panel_params.y.labels)) + x, y = panel_params.x, panel_params.y + ax.set_xlim(*_inf_to_none(x.range)) + ax.set_ylim(*_inf_to_none(y.range)) + _set_fixed_ticks(ax.xaxis, x.breaks, x.labels, x.minor_breaks) + _set_fixed_ticks(ax.yaxis, y.breaks, y.labels, y.minor_breaks) def _setup_axis_sides( self, @@ -183,18 +228,75 @@ def _setup_axis_sides( ) -> None: """ Tick visibility and spine visibility for the side each axis occupies + + A secondary axis occupies the opposite side. + """ + x, y = panel_params.x, panel_params.y + + self._setup_primary_axis(ax, x, layout_info.axis_x) + self._setup_primary_axis(ax, y, layout_info.axis_y) + self._setup_secondary_axis(ax, x, layout_info.axis_x_sec) + self._setup_secondary_axis(ax, y, layout_info.axis_y_sec) + + def _setup_primary_axis( + self, + ax: Axes, + sv: scale_position_view, + present: bool, + ) -> None: + """ + Ticks, labels and spine of one primary axis, on its side + + Parameters + ---------- + ax : + Panel axes. + sv : + View of the axis's position scale. + present : + Whether the panel shows the axis's ticks and labels; False + on interior facet panels. The spine shows on every panel + (edge or interior) and the `axis_line` themeable styles or + blanks it. + """ + _activate_axis(_side_axis(ax, sv.position), sv.position, present) + cast("p9Axes", ax).sides_with_an_axis.add(sv.position) + ax.spines[sv.position].set_visible(True) + + def _setup_secondary_axis( + self, + ax: Axes, + sv: scale_position_view, + present: bool, + ) -> None: + """ + Ticks, labels and spine of one secondary axis, on its side + + A scale without a `sec_axis` has no secondary axis; this does + nothing. Otherwise the secondary axis is an `Axis` artist on + the panel itself (see `p9Axes`), with breaks expressed in the + panel's data space. + + Parameters + ---------- + ax : + Panel axes. + sv : + View of the axis's position scale; `sv.sec` carries the + resolved secondary axis. + present : + Whether the panel shows the secondary ticks and labels; as + with the primary axis, the spine shows on every panel. """ - x_pos = panel_params.x.position # "bottom" | "top" - y_pos = panel_params.y.position # "left" | "right" - _activate_axis(ax.xaxis, x_pos, layout_info.axis_x) - _activate_axis(ax.yaxis, y_pos, layout_info.axis_y) - - # Spine on each axis's active side, on every panel (edge or - # interior); the axis_line themeable styles or blanks it. - ax.spines["top"].set_visible(x_pos == "top") - ax.spines["bottom"].set_visible(x_pos == "bottom") - ax.spines["right"].set_visible(y_pos == "right") - ax.spines["left"].set_visible(y_pos == "left") + if (sec := sv.sec) is None: + return + + p9ax = cast("p9Axes", ax) + axis = p9ax.add_sec_axis(sec.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) def labels(self, cur_labels: labels_view) -> labels_view: """ diff --git a/plotnine/coords/coord_flip.py b/plotnine/coords/coord_flip.py index 4642a2f3d..4d7ad9997 100644 --- a/plotnine/coords/coord_flip.py +++ b/plotnine/coords/coord_flip.py @@ -61,6 +61,9 @@ def setup_panel_params(self, scale_x: scale, scale_y: scale) -> panel_view: # scale_flip_axis): top->right, bottom->left, left->bottom, right->top panel_params.x.position = _FLIP_POSITION[panel_params.x.position] 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] return panel_params def setup_layout(self, layout: pd.DataFrame) -> pd.DataFrame: diff --git a/plotnine/coords/coord_trans.py b/plotnine/coords/coord_trans.py index 98ac8339f..561f1df89 100644 --- a/plotnine/coords/coord_trans.py +++ b/plotnine/coords/coord_trans.py @@ -123,6 +123,8 @@ def get_scale_view( breaks = cast("tuple[float, float]", sv.breaks) sv.breaks = transform_value(trans, breaks) sv.minor_breaks = transform_value(trans, sv.minor_breaks) + if sv.sec is not None: + sv.sec.breaks = transform_value(trans, sv.sec.breaks) return sv out = panel_view( diff --git a/plotnine/facets/facet.py b/plotnine/facets/facet.py index 3baae72c6..fd7fe4745 100644 --- a/plotnine/facets/facet.py +++ b/plotnine/facets/facet.py @@ -9,6 +9,7 @@ import pandas as pd import pandas.api.types as pdtypes +from .._mpl.axes import p9Axes from .._utils import cross_join, match from ..exceptions import PlotnineError from ..scales.scales import Scales @@ -349,7 +350,9 @@ def _make_axes(self) -> tuple[p9GridSpec, list[Axes]]: # Create axes it = itertools.product(range(self.nrow), range(self.ncol)) for i, (row, col) in enumerate(it): - axsarr[row, col] = self.figure.add_subplot(gs[i]) + axsarr[row, col] = self.figure.add_subplot( + gs[i], projection=p9Axes.name + ) # Rearrange axes # They are ordered to match the positions in the layout table @@ -483,6 +486,8 @@ def layout_null() -> pd.DataFrame: "SCALE_Y": 1, "AXIS_X": True, "AXIS_Y": True, + "AXIS_X_SEC": True, + "AXIS_Y_SEC": True, } ) return layout diff --git a/plotnine/facets/facet_grid.py b/plotnine/facets/facet_grid.py index 07edda6f3..ffcca8dc3 100644 --- a/plotnine/facets/facet_grid.py +++ b/plotnine/facets/facet_grid.py @@ -224,12 +224,16 @@ def compute_layout( x_side, y_side = scales.axis_positions if x_side == "top": layout["AXIS_X"] = layout["ROW"] == layout["ROW"].min() + layout["AXIS_X_SEC"] = layout["ROW"] == layout["ROW"].max() else: layout["AXIS_X"] = layout["ROW"] == layout["ROW"].max() + layout["AXIS_X_SEC"] = layout["ROW"] == layout["ROW"].min() if y_side == "right": layout["AXIS_Y"] = layout["COL"] == layout["COL"].max() + layout["AXIS_Y_SEC"] = layout["COL"] == layout["COL"].min() else: layout["AXIS_Y"] = layout["COL"] == layout["COL"].min() + layout["AXIS_Y_SEC"] = layout["COL"] == layout["COL"].max() self.nrow = layout["ROW"].max() self.ncol = layout["COL"].max() diff --git a/plotnine/facets/facet_wrap.py b/plotnine/facets/facet_wrap.py index 94d7f1968..82a0850bf 100644 --- a/plotnine/facets/facet_wrap.py +++ b/plotnine/facets/facet_wrap.py @@ -150,25 +150,33 @@ def compute_layout( # The row/column of each panel that shows the axis, on the side the # axis sits (default: bottom-most row, left-most column) x_side, y_side = scales.axis_positions + rows_by_col = layout.groupby("COL")["ROW"] + cols_by_row = layout.groupby("ROW")["COL"] if x_side == "top": - x_idx = [df["ROW"].idxmin() for _, df in layout.groupby("COL")] + x_idx, x_sec_idx = rows_by_col.idxmin(), rows_by_col.idxmax() else: - x_idx = [df["ROW"].idxmax() for _, df in layout.groupby("COL")] + x_idx, x_sec_idx = rows_by_col.idxmax(), rows_by_col.idxmin() if y_side == "right": - y_idx = [df["COL"].idxmax() for _, df in layout.groupby("ROW")] + y_idx, y_sec_idx = cols_by_row.idxmax(), cols_by_row.idxmin() else: - y_idx = [df["COL"].idxmin() for _, df in layout.groupby("ROW")] + y_idx, y_sec_idx = cols_by_row.idxmin(), cols_by_row.idxmax() layout["AXIS_X"] = False layout["AXIS_Y"] = False + layout["AXIS_X_SEC"] = False + layout["AXIS_Y_SEC"] = False _loc = layout.columns.get_loc layout.iloc[x_idx, _loc("AXIS_X")] = True # type: ignore layout.iloc[y_idx, _loc("AXIS_Y")] = True # type: ignore + layout.iloc[x_sec_idx, _loc("AXIS_X_SEC")] = True # type: ignore + layout.iloc[y_sec_idx, _loc("AXIS_Y_SEC")] = True # type: ignore if self.free["x"]: layout.loc[:, "AXIS_X"] = True + layout.loc[:, "AXIS_X_SEC"] = True if self.free["y"]: layout.loc[:, "AXIS_Y"] = True + layout.loc[:, "AXIS_Y_SEC"] = True return layout diff --git a/plotnine/facets/layout.py b/plotnine/facets/layout.py index 4a46f0773..caf5d9a1a 100644 --- a/plotnine/facets/layout.py +++ b/plotnine/facets/layout.py @@ -291,6 +291,8 @@ def get_details(self) -> list[layout_details]: "SCALE_Y", "AXIS_X", "AXIS_Y", + "AXIS_X_SEC", + "AXIS_Y_SEC", ] vcols = self.layout.columns.difference(columns) lst = [] diff --git a/plotnine/ggplot.py b/plotnine/ggplot.py index 1ccc2780e..859201408 100755 --- a/plotnine/ggplot.py +++ b/plotnine/ggplot.py @@ -604,6 +604,20 @@ def _draw_figure_texts(self): t = self.figure.add_artist(Text(text=labels.y)) setattr(targets, f"axis_title_y_{pp.y.position}", t) + # The secondary axis title sits on the opposite side; a None + # name derives the primary title. + if (sec := pp.x.sec) is not None: + name = labels.x if sec.name is None else sec.name + if name: + t = self.figure.add_artist(Text(text=name)) + setattr(targets, f"axis_title_x_{sec.position}", t) + + if (sec := pp.y.sec) is not None: + name = labels.y if sec.name is None else sec.name + if name: + t = self.figure.add_artist(Text(text=name)) + setattr(targets, f"axis_title_y_{sec.position}", t) + def _draw_watermarks(self): """ Draw watermark onto figure diff --git a/plotnine/iapi.py b/plotnine/iapi.py index a7a1d7c22..eaca78e7a 100644 --- a/plotnine/iapi.py +++ b/plotnine/iapi.py @@ -49,6 +49,21 @@ class scale_view: labels: Sequence[str] +@dataclass +class sec_axis_view: + """ + Resolved secondary axis of a panel + """ + + # Tick positions in the primary (transformed) coordinate space + breaks: Sequence[float] + 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 + + @dataclass class scale_position_view(scale_view): """ @@ -56,6 +71,7 @@ class scale_position_view(scale_view): """ position: Side + sec: Optional[sec_axis_view] = None @dataclass @@ -202,6 +218,9 @@ class layout_details: scale_y: int axis_x: bool axis_y: bool + # True when the panel draws the axis opposite axis_x / axis_y + axis_x_sec: bool + axis_y_sec: bool variables: dict[str, Any] nrow: int ncol: int diff --git a/plotnine/scales/__init__.py b/plotnine/scales/__init__.py index 43d659a81..625065819 100644 --- a/plotnine/scales/__init__.py +++ b/plotnine/scales/__init__.py @@ -139,6 +139,9 @@ scale_y_timedelta, ) +# secondary axis +from .sec_axis import dup_axis, sec_axis + __all__ = ( # color "scale_color_brewer", @@ -227,6 +230,9 @@ "scale_linetype_manual", "scale_alpha_manual", "scale_size_manual", + # secondary axis + "dup_axis", + "sec_axis", # xy position and transforms "scale_x_continuous", "scale_x_date", diff --git a/plotnine/scales/_runtime_typing.py b/plotnine/scales/_runtime_typing.py index 9d82a647e..272cbac56 100644 --- a/plotnine/scales/_runtime_typing.py +++ b/plotnine/scales/_runtime_typing.py @@ -5,12 +5,23 @@ the documentation. """ -from typing import Callable, Literal, Sequence, Type, TypeAlias, TypeVar +from typing import ( + TYPE_CHECKING, + Callable, + Literal, + Sequence, + Type, + TypeAlias, + TypeVar, +) from mizani.transforms import trans from .range import Range +if TYPE_CHECKING: + from .sec_axis import sec_axis + # fmt: off DiscreteBreaksUser: TypeAlias = ( @@ -51,6 +62,8 @@ TransUser: TypeAlias = trans | str | Type[trans] | None +SecAxisUser: TypeAlias = "sec_axis | None" + OptionalLegend: TypeAlias = Literal["legend"] | None OptionalColorbar: TypeAlias = Literal["colorbar"] | None OptionalGuide: TypeAlias = Literal["legend", "colorbar"] | None diff --git a/plotnine/scales/scale_datetime.py b/plotnine/scales/scale_datetime.py index 40f69d952..8e338d751 100644 --- a/plotnine/scales/scale_datetime.py +++ b/plotnine/scales/scale_datetime.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING from warnings import warn +from ..exceptions import PlotnineError from ._runtime_typing import TransUser # noqa: TCH001 from .scale_continuous import scale_continuous @@ -112,4 +113,11 @@ def __post_init__( ) self.minor_breaks = breaks_date_width(width=self.minor_breaks) # pyright: ignore[reportAttributeAccessIssue] + # The x/y datetime scales inherit the field from the continuous + # position scales; color/size datetime scales never have it. + if getattr(self, "sec_axis", None) is not None: + raise PlotnineError( + "Secondary axes are not supported on datetime scales." + ) + scale_continuous.__post_init__(self) diff --git a/plotnine/scales/scale_xy.py b/plotnine/scales/scale_xy.py index d7b044096..e1d7b55dd 100644 --- a/plotnine/scales/scale_xy.py +++ b/plotnine/scales/scale_xy.py @@ -12,7 +12,7 @@ from ..exceptions import PlotnineError from ..iapi import range_view, scale_position_view from ._expand import expand_range -from ._runtime_typing import TransUser # noqa: TCH001 +from ._runtime_typing import SecAxisUser, TransUser # noqa: TCH001 from .range import RangeContinuous from .scale_continuous import scale_continuous from .scale_datetime import scale_datetime @@ -54,7 +54,10 @@ def view(self, limits=None, range=None) -> scale_position_view: Information about the trained scale, including the axis side """ sv = super().view(limits=limits, range=range) # pyright: ignore[reportAttributeAccessIssue] - return scale_position_view(**vars(sv), position=self.position) # pyright: ignore[reportAttributeAccessIssue] + psv = scale_position_view(**vars(sv), position=self.position) # pyright: ignore[reportAttributeAccessIssue] + if (sec := getattr(self, "sec_axis", None)) is not None: + psv.sec = sec.view(self, psv) + return psv # positions scales have a couple of differences (quirks) that @@ -279,6 +282,10 @@ class scale_x_continuous(scale_position_continuous): _aesthetics = ["x", "xmin", "xmax", "xend", "xintercept"] position: Literal["bottom", "top"] = "bottom" + sec_axis: SecAxisUser = None + """ + A secondary axis, drawn on the side opposite `position`. + """ @dataclass(kw_only=True) @@ -300,6 +307,10 @@ class scale_y_continuous(scale_position_continuous): "upper", ] position: Literal["left", "right"] = "left" + sec_axis: SecAxisUser = None + """ + A secondary axis, drawn on the side opposite `position`. + """ # Transformed scales diff --git a/plotnine/scales/sec_axis.py b/plotnine/scales/sec_axis.py new file mode 100644 index 000000000..9cef7cca6 --- /dev/null +++ b/plotnine/scales/sec_axis.py @@ -0,0 +1,180 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import numpy as np + +from .._utils import OPPOSITE_SIDE, identity +from ..exceptions import PlotnineError +from ..iapi import sec_axis_view +from ._runtime_typing import ( # noqa: TCH001 + ContinuousBreaksUser, + ScaleLabelsUser, +) + +if TYPE_CHECKING: + from typing import Callable, Sequence + + from plotnine.iapi import scale_position_view + from plotnine.scales.scale_continuous import scale_continuous + from plotnine.typing import FloatArray, FloatArrayLike + +__all__ = ("sec_axis", "dup_axis") + +# Number of grid points used to numerically invert the transform +GRID_SIZE = 1000 + + +@dataclass +class sec_axis: + """ + Specification of a secondary axis + + A secondary axis is drawn on the side opposite the primary axis. + It represents a strictly monotonic, one-to-one transformation of + the primary values, with its own breaks, labels and title. + """ + + transform: Callable[[FloatArray], FloatArrayLike] = identity + """ + Vectorized function that maps values of the primary axis (in data + space) to values of the secondary axis. It must be strictly + monotonic over the range of the primary axis. + """ + + name: str | None = None + """ + Title of the secondary axis. If `None`, the primary axis title is + used. An empty string gives no title. + """ + + breaks: ContinuousBreaksUser = True + """ + Locations of the secondary ticks, in secondary values. `True` + computes nice breaks, `None` places ticks at the primary breaks, + `False` removes them. A sequence or callable is used as given. + """ + + labels: ScaleLabelsUser = True + """ + Labels at the secondary breaks. `True` formats the breaks, `None` + copies the primary labels (requires `breaks=None`), `False` + removes them. A sequence, callable or dict is used as given. + """ + + def __post_init__(self): + if self.labels is None and self.breaks is not None: + raise PlotnineError( + "A secondary axis can only copy the primary labels " + "(labels=None) when it also copies the primary breaks " + "(breaks=None)." + ) + + def view( + self, + scale: scale_continuous, + primary: scale_position_view, + ) -> sec_axis_view: + """ + Resolved secondary axis for one panel of the primary scale + + Parameters + ---------- + scale : + Trained primary scale. + primary : + View of the primary scale for the panel. + + Returns + ------- + : + Breaks, labels, title and side of the secondary axis. The + breaks are positions in the primary (transformed) + coordinate space, ready to be placed on an mpl axis. + """ + from mizani.transforms import identity_trans + + low, high = min(primary.range), max(primary.range) + grid = np.linspace(low, high, GRID_SIZE) + values = np.asarray( + self.transform(np.asarray(scale.inverse(grid))), dtype=float + ) + diffs = np.diff(values) + if not (np.all(diffs > 0) or np.all(diffs < 0)): + raise PlotnineError( + "The transformation for a secondary axis must be monotonic." + ) + if values[0] > values[-1]: + values, grid = values[::-1], grid[::-1] + vrange = (values[0], values[-1]) + + if self.breaks is None: + breaks = np.asarray(primary.breaks, dtype=float) + sec_values = np.asarray( + self.transform(np.asarray(scale.inverse(breaks))), + dtype=float, + ) + else: + if self.breaks is True: + sec_values = np.asarray( + identity_trans().breaks(vrange), dtype=float + ) + elif self.breaks is False: + sec_values = np.array([]) + elif callable(self.breaks): + sec_values = np.asarray(self.breaks(vrange), dtype=float) + else: + sec_values = np.asarray(self.breaks, dtype=float) + keep = (sec_values >= vrange[0]) & (sec_values <= vrange[1]) + sec_values = sec_values[keep] + breaks = np.interp(sec_values, values, grid) + + labels: Sequence[str] + if self.labels is None: + labels = list(primary.labels) + elif self.labels is True: + labels = identity_trans().format(sec_values) + elif self.labels is False: + labels = [] + elif isinstance(self.labels, dict): + labels = [str(self.labels.get(b, b)) for b in sec_values] + elif callable(self.labels): + labels = self.labels(list(sec_values)) + else: + labels = list(self.labels) + + return sec_axis_view( + breaks=list(breaks), + labels=list(labels), + name=self.name, + position=OPPOSITE_SIDE[primary.position], + ) + + +def dup_axis( + name: str | None = None, + breaks: ContinuousBreaksUser = None, + labels: ScaleLabelsUser = None, +) -> sec_axis: + """ + Specification of a secondary axis that mirrors the primary axis + + Every argument defaults to `None` — copy the setting from the + primary axis. + + Parameters + ---------- + name : + Title of the secondary axis, as in `sec_axis`. + breaks : + Locations of the secondary ticks, as in `sec_axis`. + labels : + Labels at the secondary breaks, as in `sec_axis`. + + Returns + ------- + : + A secondary axis specification with an identity transform. + """ + return sec_axis(name=name, breaks=breaks, labels=labels) diff --git a/plotnine/themes/themeable.py b/plotnine/themes/themeable.py index ff48c1860..85c503b67 100644 --- a/plotnine/themes/themeable.py +++ b/plotnine/themes/themeable.py @@ -17,6 +17,7 @@ import numpy as np +from .._mpl.axes import axis_at from .._utils import MARGIN_SIDE, has_alpha_channel, side_artists, to_rgba from .._utils.registry import RegistryHierarchyMeta from ..exceptions import PlotnineError, deprecated_themeable_name @@ -529,15 +530,17 @@ def blend_alpha( return properties -def _set_axis_text_margin(themeable, ax, axis: str, side: Side): +def _set_axis_text_margin(themeable, ax, side: Side): """ Set the gap between axis tick and axis text """ margin = themeable.properties.get("margin") if margin is None: return + if (axis := axis_at(ax, side)) is None: + return pad = getattr(margin.pt, MARGIN_SIDE[side]) - ax.tick_params(axis=axis, which="major", pad=pad) + axis.set_tick_params(which="major", pad=pad) # element_text themeables @@ -1094,17 +1097,17 @@ class axis_text_x_bottom(MixinSequenceOfValues): def apply_ax(self, ax: Axes): super().apply_ax(ax) - if not ax.xaxis.get_tick_params(which="major").get( - "labelbottom", False - ): + if (axis := axis_at(ax, "bottom")) is None: return - labels = [t.label1 for t in ax.xaxis.get_major_ticks()] + labels = [t.label1 for t in axis.get_major_ticks()] self.set(labels, self._get_properties(omit=("margin", "va"))) - _set_axis_text_margin(self, ax, "x", "bottom") + _set_axis_text_margin(self, ax, "bottom") def blank_ax(self, ax: Axes): super().blank_ax(ax) - for t in ax.xaxis.get_major_ticks(): + if (axis := axis_at(ax, "bottom")) is None: + return + for t in axis.get_major_ticks(): t.label1.set_visible(False) @@ -1119,15 +1122,17 @@ class axis_text_x_top(MixinSequenceOfValues): def apply_ax(self, ax: Axes): super().apply_ax(ax) - if not ax.xaxis.get_tick_params(which="major").get("labeltop", False): + if (axis := axis_at(ax, "top")) is None: return - labels = [t.label2 for t in ax.xaxis.get_major_ticks()] + labels = [t.label2 for t in axis.get_major_ticks()] self.set(labels, self._get_properties(omit=("margin", "va"))) - _set_axis_text_margin(self, ax, "x", "top") + _set_axis_text_margin(self, ax, "top") def blank_ax(self, ax: Axes): super().blank_ax(ax) - for t in ax.xaxis.get_major_ticks(): + if (axis := axis_at(ax, "top")) is None: + return + for t in axis.get_major_ticks(): t.label2.set_visible(False) @@ -1152,15 +1157,17 @@ class axis_text_y_left(MixinSequenceOfValues): def apply_ax(self, ax: Axes): super().apply_ax(ax) - if not ax.yaxis.get_tick_params(which="major").get("labelleft", False): + if (axis := axis_at(ax, "left")) is None: return - labels = [t.label1 for t in ax.yaxis.get_major_ticks()] + 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, "y", "left") + _set_axis_text_margin(self, ax, "left") def blank_ax(self, ax: Axes): super().blank_ax(ax) - for t in ax.yaxis.get_major_ticks(): + if (axis := axis_at(ax, "left")) is None: + return + for t in axis.get_major_ticks(): t.label1.set_visible(False) @@ -1175,17 +1182,17 @@ class axis_text_y_right(MixinSequenceOfValues): def apply_ax(self, ax: Axes): super().apply_ax(ax) - if not ax.yaxis.get_tick_params(which="major").get( - "labelright", False - ): + if (axis := axis_at(ax, "right")) is None: return - labels = [t.label2 for t in ax.yaxis.get_major_ticks()] + 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, "y", "right") + _set_axis_text_margin(self, ax, "right") def blank_ax(self, ax: Axes): super().blank_ax(ax) - for t in ax.yaxis.get_major_ticks(): + if (axis := axis_at(ax, "right")) is None: + return + for t in axis.get_major_ticks(): t.label2.set_visible(False) @@ -1251,19 +1258,22 @@ def rcParams(self): def _style_axis_line(themeable, ax, side): """ - Style the spine on one side, when that side carries the axis + Style the spine on one side, when that side carries an axis - `coord.setup_ax` makes only the active side's spine visible, so a hidden - spine here is one this axis does not sit on. The spine name equals the - side (`bottom`/`top`/`left`/`right`). + A side that carries no axis is never styled. Styling also shows + the spine: a blank ancestor themeable (applied general-to-specific + before this one) may have hidden it, and the explicitly set + themeable wins. The spine name equals the side + (`bottom`/`top`/`left`/`right`). """ - if not ax.spines[side].get_visible(): + if side not in getattr(ax, "sides_with_an_axis", ()): return properties = themeable._get_properties(omit=("solid_capstyle",)) # MPL has a default zorder of 2.5 for spines, so layers 3+ would be # drawn on top of the spines if "zorder" not in properties: properties["zorder"] = 10000 + properties.setdefault("visible", True) ax.spines[side].set(**properties) @@ -1353,15 +1363,15 @@ class axis_line(axis_line_x, axis_line_y): """ -def _style_axis_ticks(themeable, ax, axis_name, which, side): +def _style_axis_ticks(themeable, ax, which, side): """ Style the tick lines on one side of an axis + + The side implies the axis — primary or secondary, whichever + occupies it. A side with no active ticks is not styled; theming + should not make hidden ticks visible again. """ - axis = getattr(ax, axis_name) - # coord.setup_ax uses set_tick_params to turn off the ticks that will - # not show, setting the side key (e.g. params["bottom"]) to False and - # the artist invisible. Theming should not make them visible again. - if not axis.get_tick_params(which=which).get(side, False): + if (axis := axis_at(ax, side)) is None: return # We have to use both Axis.set_tick_params() and Tick.tickline.set(). @@ -1385,11 +1395,12 @@ def _style_axis_ticks(themeable, ax, axis_name, which, side): themeable.set([getattr(t, attr) for t in ticks], properties) -def _blank_axis_ticks(ax, axis_name, which, side): +def _blank_axis_ticks(ax, which, side): """ Hide the tick lines on one side of an axis """ - axis = getattr(ax, axis_name) + if (axis := axis_at(ax, side)) is None: + return attr = side_artists(side)[0] ticks = ( axis.get_minor_ticks() if which == "minor" else axis.get_major_ticks() @@ -1405,11 +1416,11 @@ class axis_ticks_minor_x_bottom(MixinSequenceOfValues): def apply_ax(self, ax: Axes): super().apply_ax(ax) - _style_axis_ticks(self, ax, "xaxis", "minor", "bottom") + _style_axis_ticks(self, ax, "minor", "bottom") def blank_ax(self, ax: Axes): super().blank_ax(ax) - _blank_axis_ticks(ax, "xaxis", "minor", "bottom") + _blank_axis_ticks(ax, "minor", "bottom") class axis_ticks_minor_x_top(MixinSequenceOfValues): @@ -1419,11 +1430,11 @@ class axis_ticks_minor_x_top(MixinSequenceOfValues): def apply_ax(self, ax: Axes): super().apply_ax(ax) - _style_axis_ticks(self, ax, "xaxis", "minor", "top") + _style_axis_ticks(self, ax, "minor", "top") def blank_ax(self, ax: Axes): super().blank_ax(ax) - _blank_axis_ticks(ax, "xaxis", "minor", "top") + _blank_axis_ticks(ax, "minor", "top") class axis_ticks_minor_x(axis_ticks_minor_x_top, axis_ticks_minor_x_bottom): @@ -1443,11 +1454,11 @@ class axis_ticks_minor_y_left(MixinSequenceOfValues): def apply_ax(self, ax: Axes): super().apply_ax(ax) - _style_axis_ticks(self, ax, "yaxis", "minor", "left") + _style_axis_ticks(self, ax, "minor", "left") def blank_ax(self, ax: Axes): super().blank_ax(ax) - _blank_axis_ticks(ax, "yaxis", "minor", "left") + _blank_axis_ticks(ax, "minor", "left") class axis_ticks_minor_y_right(MixinSequenceOfValues): @@ -1457,11 +1468,11 @@ class axis_ticks_minor_y_right(MixinSequenceOfValues): def apply_ax(self, ax: Axes): super().apply_ax(ax) - _style_axis_ticks(self, ax, "yaxis", "minor", "right") + _style_axis_ticks(self, ax, "minor", "right") def blank_ax(self, ax: Axes): super().blank_ax(ax) - _blank_axis_ticks(ax, "yaxis", "minor", "right") + _blank_axis_ticks(ax, "minor", "right") class axis_ticks_minor_y(axis_ticks_minor_y_left, axis_ticks_minor_y_right): @@ -1481,11 +1492,11 @@ class axis_ticks_major_x_bottom(MixinSequenceOfValues): def apply_ax(self, ax: Axes): super().apply_ax(ax) - _style_axis_ticks(self, ax, "xaxis", "major", "bottom") + _style_axis_ticks(self, ax, "major", "bottom") def blank_ax(self, ax: Axes): super().blank_ax(ax) - _blank_axis_ticks(ax, "xaxis", "major", "bottom") + _blank_axis_ticks(ax, "major", "bottom") class axis_ticks_major_x_top(MixinSequenceOfValues): @@ -1495,11 +1506,11 @@ class axis_ticks_major_x_top(MixinSequenceOfValues): def apply_ax(self, ax: Axes): super().apply_ax(ax) - _style_axis_ticks(self, ax, "xaxis", "major", "top") + _style_axis_ticks(self, ax, "major", "top") def blank_ax(self, ax: Axes): super().blank_ax(ax) - _blank_axis_ticks(ax, "xaxis", "major", "top") + _blank_axis_ticks(ax, "major", "top") class axis_ticks_major_x(axis_ticks_major_x_top, axis_ticks_major_x_bottom): @@ -1519,11 +1530,11 @@ class axis_ticks_major_y_left(MixinSequenceOfValues): def apply_ax(self, ax: Axes): super().apply_ax(ax) - _style_axis_ticks(self, ax, "yaxis", "major", "left") + _style_axis_ticks(self, ax, "major", "left") def blank_ax(self, ax: Axes): super().blank_ax(ax) - _blank_axis_ticks(ax, "yaxis", "major", "left") + _blank_axis_ticks(ax, "major", "left") class axis_ticks_major_y_right(MixinSequenceOfValues): @@ -1533,11 +1544,11 @@ class axis_ticks_major_y_right(MixinSequenceOfValues): def apply_ax(self, ax: Axes): super().apply_ax(ax) - _style_axis_ticks(self, ax, "yaxis", "major", "right") + _style_axis_ticks(self, ax, "major", "right") def blank_ax(self, ax: Axes): super().blank_ax(ax) - _blank_axis_ticks(ax, "yaxis", "major", "right") + _blank_axis_ticks(ax, "major", "right") class axis_ticks_major_y(axis_ticks_major_y_left, axis_ticks_major_y_right): @@ -2065,27 +2076,31 @@ class axis_ticks_length_major_x(themeable): def apply_ax(self, ax: Axes): super().apply_ax(ax) - value: float | complex = self.properties["value"] + for axis in (ax.xaxis, getattr(ax, "sec_xaxis", None)): + if axis is None: + continue + value: float | complex = self.properties["value"] - try: - tick = ax.xaxis.get_major_ticks()[0] - visible = ( - tick.tick1line.get_visible() or tick.tick2line.get_visible() - ) - except IndexError: - value = 0 - else: - if not visible: + try: + tick = axis.get_major_ticks()[0] + visible = ( + tick.tick1line.get_visible() + or tick.tick2line.get_visible() + ) + except IndexError: value = 0 + else: + if not visible: + value = 0 - if isinstance(value, (float, int)): - tickdir = "in" if value < 0 else "out" - else: - tickdir = "inout" + if isinstance(value, (float, int)): + tickdir = "in" if value < 0 else "out" + else: + tickdir = "inout" - ax.xaxis.set_tick_params( - which="major", length=abs(value), tickdir=tickdir - ) + axis.set_tick_params( + which="major", length=abs(value), tickdir=tickdir + ) class axis_ticks_length_major_y(themeable): @@ -2102,27 +2117,31 @@ class axis_ticks_length_major_y(themeable): def apply_ax(self, ax: Axes): super().apply_ax(ax) - value: float | complex = self.properties["value"] + for axis in (ax.yaxis, getattr(ax, "sec_yaxis", None)): + if axis is None: + continue + value: float | complex = self.properties["value"] - try: - tick = ax.yaxis.get_major_ticks()[0] - visible = ( - tick.tick1line.get_visible() or tick.tick2line.get_visible() - ) - except IndexError: - value = 0 - else: - if not visible: + try: + tick = axis.get_major_ticks()[0] + visible = ( + tick.tick1line.get_visible() + or tick.tick2line.get_visible() + ) + except IndexError: value = 0 + else: + if not visible: + value = 0 - if isinstance(value, (float, int)): - tickdir = "in" if value < 0 else "out" - else: - tickdir = "inout" + if isinstance(value, (float, int)): + tickdir = "in" if value < 0 else "out" + else: + tickdir = "inout" - ax.yaxis.set_tick_params( - which="major", length=abs(value), tickdir=tickdir - ) + axis.set_tick_params( + which="major", length=abs(value), tickdir=tickdir + ) class axis_ticks_length_major( @@ -2161,9 +2180,12 @@ def apply_ax(self, ax: Axes): else: tickdir = "inout" - ax.xaxis.set_tick_params( - which="minor", length=abs(value), tickdir=tickdir - ) + for axis in (ax.xaxis, getattr(ax, "sec_xaxis", None)): + if axis is None: + continue + axis.set_tick_params( + which="minor", length=abs(value), tickdir=tickdir + ) class axis_ticks_length_minor_y(themeable): @@ -2187,9 +2209,12 @@ def apply_ax(self, ax: Axes): else: tickdir = "inout" - ax.yaxis.set_tick_params( - which="minor", length=abs(value), tickdir=tickdir - ) + for axis in (ax.yaxis, getattr(ax, "sec_yaxis", None)): + if axis is None: + continue + axis.set_tick_params( + which="minor", length=abs(value), tickdir=tickdir + ) class axis_ticks_length_minor( @@ -2962,10 +2987,15 @@ def apply_ax(self, ax: Axes): super().apply_ax(ax) val = self.properties["value"] - for t in ax.xaxis.get_major_ticks(): - visible = t.tick1line.get_visible() or t.tick2line.get_visible() - _val = val if visible else 0 - t.set_pad(_val) + for axis in (ax.xaxis, getattr(ax, "sec_xaxis", None)): + if axis is None: + continue + for t in axis.get_major_ticks(): + visible = ( + t.tick1line.get_visible() or t.tick2line.get_visible() + ) + _val = val if visible else 0 + t.set_pad(_val) class axis_ticks_pad_major_y(themeable): @@ -2990,10 +3020,15 @@ def apply_ax(self, ax: Axes): super().apply_ax(ax) val = self.properties["value"] - for t in ax.yaxis.get_major_ticks(): - visible = t.tick1line.get_visible() or t.tick2line.get_visible() - _val = val if visible else 0 - t.set_pad(_val) + for axis in (ax.yaxis, getattr(ax, "sec_yaxis", None)): + if axis is None: + continue + for t in axis.get_major_ticks(): + visible = ( + t.tick1line.get_visible() or t.tick2line.get_visible() + ) + _val = val if visible else 0 + t.set_pad(_val) class axis_ticks_pad_major(axis_ticks_pad_major_x, axis_ticks_pad_major_y): @@ -3034,10 +3069,15 @@ def apply_ax(self, ax: Axes): super().apply_ax(ax) val = self.properties["value"] - for t in ax.xaxis.get_minor_ticks(): - visible = t.tick1line.get_visible() or t.tick2line.get_visible() - _val = val if visible else 0 - t.set_pad(_val) + for axis in (ax.xaxis, getattr(ax, "sec_xaxis", None)): + if axis is None: + continue + for t in axis.get_minor_ticks(): + visible = ( + t.tick1line.get_visible() or t.tick2line.get_visible() + ) + _val = val if visible else 0 + t.set_pad(_val) class axis_ticks_pad_minor_y(themeable): @@ -3061,10 +3101,15 @@ def apply_ax(self, ax: Axes): super().apply_ax(ax) val = self.properties["value"] - for t in ax.yaxis.get_minor_ticks(): - visible = t.tick1line.get_visible() or t.tick2line.get_visible() - _val = val if visible else 0 - t.set_pad(_val) + for axis in (ax.yaxis, getattr(ax, "sec_yaxis", None)): + if axis is None: + continue + for t in axis.get_minor_ticks(): + visible = ( + t.tick1line.get_visible() or t.tick2line.get_visible() + ) + _val = val if visible else 0 + t.set_pad(_val) class axis_ticks_pad_minor(axis_ticks_pad_minor_x, axis_ticks_pad_minor_y): diff --git a/tests/baseline_images/test_axis_position/axis_line_y_right_overrides_blank.png b/tests/baseline_images/test_axis_position/axis_line_y_right_overrides_blank.png new file mode 100644 index 000000000..39397093f Binary files /dev/null and b/tests/baseline_images/test_axis_position/axis_line_y_right_overrides_blank.png differ diff --git a/tests/baseline_images/test_sec_axis/coord_flip_sec_axis.png b/tests/baseline_images/test_sec_axis/coord_flip_sec_axis.png new file mode 100644 index 000000000..20fd2736a Binary files /dev/null and b/tests/baseline_images/test_sec_axis/coord_flip_sec_axis.png differ diff --git a/tests/baseline_images/test_sec_axis/dup_axis_x.png b/tests/baseline_images/test_sec_axis/dup_axis_x.png new file mode 100644 index 000000000..d661e61fb Binary files /dev/null and b/tests/baseline_images/test_sec_axis/dup_axis_x.png differ diff --git a/tests/baseline_images/test_sec_axis/facet_grid_sec_axis.png b/tests/baseline_images/test_sec_axis/facet_grid_sec_axis.png new file mode 100644 index 000000000..be4406512 Binary files /dev/null and b/tests/baseline_images/test_sec_axis/facet_grid_sec_axis.png differ diff --git a/tests/baseline_images/test_sec_axis/facet_wrap_sec_axis.png b/tests/baseline_images/test_sec_axis/facet_wrap_sec_axis.png new file mode 100644 index 000000000..9f1b875bc Binary files /dev/null and b/tests/baseline_images/test_sec_axis/facet_wrap_sec_axis.png differ diff --git a/tests/baseline_images/test_sec_axis/primary_top_sec_bottom.png b/tests/baseline_images/test_sec_axis/primary_top_sec_bottom.png new file mode 100644 index 000000000..93c3a8dd8 Binary files /dev/null and b/tests/baseline_images/test_sec_axis/primary_top_sec_bottom.png differ diff --git a/tests/baseline_images/test_sec_axis/sec_axis_log10.png b/tests/baseline_images/test_sec_axis/sec_axis_log10.png new file mode 100644 index 000000000..e44d5658c Binary files /dev/null and b/tests/baseline_images/test_sec_axis/sec_axis_log10.png differ diff --git a/tests/baseline_images/test_sec_axis/sec_axis_y.png b/tests/baseline_images/test_sec_axis/sec_axis_y.png new file mode 100644 index 000000000..b22202533 Binary files /dev/null and b/tests/baseline_images/test_sec_axis/sec_axis_y.png differ diff --git a/tests/test_axis_position.py b/tests/test_axis_position.py index e20c3cfa6..f302e20e5 100644 --- a/tests/test_axis_position.py +++ b/tests/test_axis_position.py @@ -1,12 +1,14 @@ from plotnine import ( aes, coord_flip, + element_line, facet_wrap, geom_point, ggplot, scale_x_continuous, scale_x_discrete, scale_y_continuous, + theme, ) from plotnine.data import mtcars @@ -47,3 +49,16 @@ def test_x_axis_top_discrete(): + scale_x_discrete(position="top") ) assert p == "x_axis_top_discrete" + + +def test_axis_line_y_right_overrides_blank(): + # theme_gray blanks axis_line_y; the explicitly set leaf must + # still show and style the right spine + p = ( + p0 + + scale_y_continuous(position="right") + + theme( + axis_line_y_right=element_line(color="red", size=3), + ) + ) + assert p == "axis_line_y_right_overrides_blank" diff --git a/tests/test_sec_axis.py b/tests/test_sec_axis.py new file mode 100644 index 000000000..2b5861dcc --- /dev/null +++ b/tests/test_sec_axis.py @@ -0,0 +1,140 @@ +import numpy as np +import numpy.testing as npt +import pandas as pd +import pytest + +from plotnine import ( + aes, + dup_axis, + geom_point, + ggplot, + scale_x_continuous, + scale_x_datetime, + scale_y_continuous, + scale_y_log10, + sec_axis, +) +from plotnine.data import mtcars +from plotnine.exceptions import PlotnineError + +p0 = ggplot(mtcars, aes("wt", "mpg")) + geom_point() + + +def test_non_monotonic_transform(): + df = pd.DataFrame({"x": [-5.0, 5.0], "y": [0.0, 1.0]}) + p = ( + ggplot(df, aes("x", "y")) + + geom_point() + + scale_x_continuous(sec_axis=sec_axis(lambda x: x**2)) + ) + with pytest.raises(PlotnineError, match="monotonic"): + p.build_test() + + +def test_labels_copy_requires_breaks_copy(): + with pytest.raises(PlotnineError): + sec_axis(lambda x: x + 1, labels=None) + + +def test_datetime_rejects_sec_axis(): + with pytest.raises(PlotnineError): + scale_x_datetime(sec_axis=dup_axis()) + + +def test_facet_wrap_sec_axis_flags(): + from plotnine import facet_wrap + + # gear: 3 panels in a 2x2 grid -> (1,1), (1,2), (2,1) + p = (p0 + facet_wrap("gear", nrow=2)).build_test() + layout = p.layout.layout + # secondary x on the top edge of each column + assert list(layout["AXIS_X_SEC"]) == [True, True, False] + # secondary y on the right edge of each row + assert list(layout["AXIS_Y_SEC"]) == [False, True, True] + details = p.layout.get_details() + assert details[0].axis_x_sec and not details[2].axis_x_sec + + +def test_facet_grid_sec_axis_flags(): + from plotnine import facet_grid + + # am: 2 rows, gear: 3 cols + p = (p0 + facet_grid("am", "gear")).build_test() + layout = p.layout.layout + is_top = layout["ROW"] == layout["ROW"].min() + is_right = layout["COL"] == layout["COL"].max() + assert list(layout["AXIS_X_SEC"]) == is_top.tolist() + assert list(layout["AXIS_Y_SEC"]) == is_right.tolist() + + +def test_facet_null_sec_axis_flags(): + p = p0.build_test() + details = p.layout.get_details()[0] + assert details.axis_x_sec and details.axis_y_sec + + +def test_coord_trans_sec_axis(): + from plotnine import coord_trans + + p = ( + p0 + + scale_y_continuous(sec_axis=sec_axis(lambda y: y * 2)) + + coord_trans(y="log10") + ).build_test() + sec = p.layout.panel_params[0].y.sec + # The coordinate transform moves the secondary positions exactly + # like the primary breaks: position = log10(value / 2) + values = np.array([float(l) for l in sec.labels]) + npt.assert_allclose(sec.breaks, np.log10(values / 2), rtol=1e-4) + + +def test_dup_axis_x(): + p = p0 + scale_x_continuous(sec_axis=dup_axis()) + assert p == "dup_axis_x" + + +def test_sec_axis_y(): + p = p0 + scale_y_continuous( + name="miles/gallon", + sec_axis=sec_axis(lambda y: y * 0.4251, name="km/litre"), + ) + assert p == "sec_axis_y" + + +def test_sec_axis_log10(): + p = p0 + scale_y_log10(sec_axis=sec_axis(lambda y: y * 100)) + assert p == "sec_axis_log10" + + +def test_primary_top_sec_bottom(): + p = p0 + scale_x_continuous(position="top", sec_axis=dup_axis()) + assert p == "primary_top_sec_bottom" + + +def test_coord_flip_sec_axis(): + from plotnine import coord_flip + + p = ( + p0 + + scale_y_continuous(sec_axis=sec_axis(lambda y: y * 2)) + + coord_flip() + ) + assert p == "coord_flip_sec_axis" + + +def test_facet_grid_sec_axis(): + from plotnine import facet_grid + + p = p0 + facet_grid("am", "gear") + scale_x_continuous(sec_axis=dup_axis()) + assert p == "facet_grid_sec_axis" + + +def test_facet_wrap_sec_axis(): + from plotnine import facet_wrap + + p = ( + p0 + + facet_wrap("gear") + + scale_y_continuous(sec_axis=sec_axis(lambda y: y * 2, name="2x")) + ) + assert p == "facet_wrap_sec_axis"