From b93a438ec44332bc822ea703f760965bdcf6a33a Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Sat, 4 Jul 2026 00:35:44 +0300 Subject: [PATCH 1/9] feat(scales): compute secondary axis breaks and labels --- plotnine/__init__.py | 4 + plotnine/iapi.py | 16 +++ plotnine/scales/__init__.py | 6 + plotnine/scales/_runtime_typing.py | 15 ++- plotnine/scales/scale_datetime.py | 8 ++ plotnine/scales/scale_xy.py | 15 ++- plotnine/scales/sec_axis.py | 180 +++++++++++++++++++++++++++++ tests/test_sec_axis.py | 34 ++++++ 8 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 plotnine/scales/sec_axis.py create mode 100644 tests/test_sec_axis.py diff --git a/plotnine/__init__.py b/plotnine/__init__.py index 69526927bd..5b7a79c493 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/iapi.py b/plotnine/iapi.py index a7a1d7c223..06099b5106 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 diff --git a/plotnine/scales/__init__.py b/plotnine/scales/__init__.py index 43d659a812..6250658197 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 9d82a647e1..272cbac563 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 40f69d9524..8e338d7517 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 d7b044096f..e1d7b55dd4 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 0000000000..9cef7cca68 --- /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/tests/test_sec_axis.py b/tests/test_sec_axis.py new file mode 100644 index 0000000000..98d4cdcf81 --- /dev/null +++ b/tests/test_sec_axis.py @@ -0,0 +1,34 @@ +import pandas as pd +import pytest + +from plotnine import ( + aes, + dup_axis, + geom_point, + ggplot, + scale_x_continuous, + scale_x_datetime, + sec_axis, +) +from plotnine.exceptions import PlotnineError + + +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()) From 8485c6fa0f360c2b3e3c9a2db3198b5334dd8dcc Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Sat, 4 Jul 2026 00:39:16 +0300 Subject: [PATCH 2/9] feat(facets): flag panels that show a secondary axis --- plotnine/facets/facet.py | 2 ++ plotnine/facets/facet_grid.py | 4 ++++ plotnine/facets/facet_wrap.py | 16 ++++++++++++---- plotnine/facets/layout.py | 2 ++ plotnine/iapi.py | 3 +++ tests/test_sec_axis.py | 35 +++++++++++++++++++++++++++++++++++ 6 files changed, 58 insertions(+), 4 deletions(-) diff --git a/plotnine/facets/facet.py b/plotnine/facets/facet.py index 3baae72c65..d69c220869 100644 --- a/plotnine/facets/facet.py +++ b/plotnine/facets/facet.py @@ -483,6 +483,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 07edda6f35..ffcca8dc35 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 94d7f1968c..82a0850bf6 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 4a46f0773c..caf5d9a1a6 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/iapi.py b/plotnine/iapi.py index 06099b5106..eaca78e7a3 100644 --- a/plotnine/iapi.py +++ b/plotnine/iapi.py @@ -218,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/tests/test_sec_axis.py b/tests/test_sec_axis.py index 98d4cdcf81..34820efe11 100644 --- a/tests/test_sec_axis.py +++ b/tests/test_sec_axis.py @@ -10,8 +10,11 @@ scale_x_datetime, 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]}) @@ -32,3 +35,35 @@ def test_labels_copy_requires_breaks_copy(): 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 From 7e7451c0056d6529f7540b678ecd51ec277ffaf1 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Sat, 4 Jul 2026 00:41:06 +0300 Subject: [PATCH 3/9] feat(mpl): give panels a plotnine Axes class --- plotnine/_mpl/axes.py | 125 +++++++++++++++++++++++++++++++++++++++ plotnine/facets/facet.py | 5 +- 2 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 plotnine/_mpl/axes.py diff --git a/plotnine/_mpl/axes.py b/plotnine/_mpl/axes.py new file mode 100644 index 0000000000..4a4519a572 --- /dev/null +++ b/plotnine/_mpl/axes.py @@ -0,0 +1,125 @@ +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 + + def __init__(self, *args, **kwargs): + self.sec_xaxis = None + self.sec_yaxis = None + 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/facets/facet.py b/plotnine/facets/facet.py index d69c220869..fd7fe4745c 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 From 8c2c1a1c2b274322405d3fb6ee4e56a0914a6b53 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Sat, 4 Jul 2026 00:50:09 +0300 Subject: [PATCH 4/9] feat(layout): render, theme and lay out secondary axes --- .../_mpl/layout_manager/_plot_layout_items.py | 64 ++--- plotnine/coords/coord.py | 183 ++++++++++--- plotnine/coords/coord_flip.py | 3 + plotnine/coords/coord_trans.py | 2 + plotnine/ggplot.py | 14 + plotnine/themes/themeable.py | 246 ++++++++++-------- tests/test_sec_axis.py | 71 +++++ 7 files changed, 409 insertions(+), 174 deletions(-) diff --git a/plotnine/_mpl/layout_manager/_plot_layout_items.py b/plotnine/_mpl/layout_manager/_plot_layout_items.py index 02e8bae75c..25d34670fa 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 diff --git a/plotnine/coords/coord.py b/plotnine/coords/coord.py index 0e530b67d2..e9116610cd 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,72 @@ 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) + ax.spines[sv.position].set_visible(True) + + def _setup_secondary_axis( + self, + ax: Axes, + sv: scale_position_view, + present: bool, + ) -> None: """ - 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") + 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. + """ + if (sec := sv.sec) is None: + return + + axis = cast("p9Axes", ax).add_sec_axis(sec.position) + _set_fixed_ticks(axis, sec.breaks, sec.labels) + _activate_axis(axis, sec.position, present) + 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 4642a2f3da..4d7ad9997e 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 98ac8339f8..561f1df896 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/ggplot.py b/plotnine/ggplot.py index 1ccc2780e4..8592014085 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/themes/themeable.py b/plotnine/themes/themeable.py index ff48c18602..77ee6ccb9c 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) @@ -1353,15 +1360,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 +1392,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 +1413,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 +1427,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 +1451,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 +1465,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 +1489,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 +1503,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 +1527,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 +1541,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 +2073,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 +2114,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 +2177,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 +2206,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 +2984,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 +3017,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 +3066,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 +3098,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/test_sec_axis.py b/tests/test_sec_axis.py index 34820efe11..2b5861dcc0 100644 --- a/tests/test_sec_axis.py +++ b/tests/test_sec_axis.py @@ -1,3 +1,5 @@ +import numpy as np +import numpy.testing as npt import pandas as pd import pytest @@ -8,6 +10,8 @@ ggplot, scale_x_continuous, scale_x_datetime, + scale_y_continuous, + scale_y_log10, sec_axis, ) from plotnine.data import mtcars @@ -67,3 +71,70 @@ 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" From 34fd3a08e6f8052bc1096f92ac9f9af723bd3613 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Sat, 4 Jul 2026 00:54:44 +0300 Subject: [PATCH 5/9] doc: document sec_axis and dup_axis --- doc/_quartodoc.yml | 2 ++ doc/changelog.qmd | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/doc/_quartodoc.yml b/doc/_quartodoc.yml index 9c2dd3d6c9..de9be48dd4 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 4b68adaf38..121b2fa56a 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. From ef9f424a65db4b907bd4bf5f5748dfe6adc672d5 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 6 Jul 2026 19:22:01 +0300 Subject: [PATCH 6/9] feat(mpl): record the sides that carry an axis --- plotnine/_mpl/axes.py | 5 +++++ plotnine/coords/coord.py | 5 ++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/plotnine/_mpl/axes.py b/plotnine/_mpl/axes.py index 4a4519a572..9b89c280f9 100644 --- a/plotnine/_mpl/axes.py +++ b/plotnine/_mpl/axes.py @@ -44,9 +44,14 @@ class p9Axes(Axes): 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 diff --git a/plotnine/coords/coord.py b/plotnine/coords/coord.py index e9116610cd..a86abb6adc 100644 --- a/plotnine/coords/coord.py +++ b/plotnine/coords/coord.py @@ -260,6 +260,7 @@ def _setup_primary_axis( 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( @@ -290,9 +291,11 @@ def _setup_secondary_axis( if (sec := sv.sec) is None: return - axis = cast("p9Axes", ax).add_sec_axis(sec.position) + 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: From 7fce382539019e04ebc97754dfbda3fad991176e Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 6 Jul 2026 19:24:31 +0300 Subject: [PATCH 7/9] fix(theme): let a set axis_line side override a blank ancestor --- plotnine/themes/themeable.py | 13 ++++++++----- tests/test_axis_position.py | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/plotnine/themes/themeable.py b/plotnine/themes/themeable.py index 77ee6ccb9c..85c503b67e 100644 --- a/plotnine/themes/themeable.py +++ b/plotnine/themes/themeable.py @@ -1258,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) diff --git a/tests/test_axis_position.py b/tests/test_axis_position.py index e20c3cfa68..f302e20e5c 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" From ccbdfd60665781b5709dac872883ed1cb0ee9265 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Tue, 7 Jul 2026 15:14:49 +0300 Subject: [PATCH 8/9] fix(layout): push the side's active axis past a strip, not just the primary _position_strip_band_axes hardcoded ax.xaxis/ax.yaxis when shifting an axis past a facet strip on the same side. When a secondary axis (sec_axis/dup_axis) occupies that side instead, its ticks stayed at the panel edge, hidden under the strip. Resolve the side's actual axis via axis_at() instead. --- plotnine/_mpl/layout_manager/_plot_layout_items.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/plotnine/_mpl/layout_manager/_plot_layout_items.py b/plotnine/_mpl/layout_manager/_plot_layout_items.py index 25d34670fa..2dffe1d72f 100644 --- a/plotnine/_mpl/layout_manager/_plot_layout_items.py +++ b/plotnine/_mpl/layout_manager/_plot_layout_items.py @@ -746,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, @@ -754,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 ) From 3b2320308708c27850237c89868f4883d3f70494 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Tue, 7 Jul 2026 15:19:08 +0300 Subject: [PATCH 9/9] test: add baseline images for sec_axis and axis_line override tests --- .../axis_line_y_right_overrides_blank.png | Bin 0 -> 4720 bytes .../test_sec_axis/coord_flip_sec_axis.png | Bin 0 -> 4701 bytes .../baseline_images/test_sec_axis/dup_axis_x.png | Bin 0 -> 4345 bytes .../test_sec_axis/facet_grid_sec_axis.png | Bin 0 -> 5975 bytes .../test_sec_axis/facet_wrap_sec_axis.png | Bin 0 -> 5373 bytes .../test_sec_axis/primary_top_sec_bottom.png | Bin 0 -> 4350 bytes .../test_sec_axis/sec_axis_log10.png | Bin 0 -> 4857 bytes .../baseline_images/test_sec_axis/sec_axis_y.png | Bin 0 -> 5201 bytes 8 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/baseline_images/test_axis_position/axis_line_y_right_overrides_blank.png create mode 100644 tests/baseline_images/test_sec_axis/coord_flip_sec_axis.png create mode 100644 tests/baseline_images/test_sec_axis/dup_axis_x.png create mode 100644 tests/baseline_images/test_sec_axis/facet_grid_sec_axis.png create mode 100644 tests/baseline_images/test_sec_axis/facet_wrap_sec_axis.png create mode 100644 tests/baseline_images/test_sec_axis/primary_top_sec_bottom.png create mode 100644 tests/baseline_images/test_sec_axis/sec_axis_log10.png create mode 100644 tests/baseline_images/test_sec_axis/sec_axis_y.png 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 0000000000000000000000000000000000000000..39397093f872b6e6e858069c24ae5388baf073ed GIT binary patch literal 4720 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..d661e61fbd783a2758541cd7ef4f271ffdcc9810 GIT binary patch literal 4345 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..be440651281a0afaba9f37c2f505ee051c3e138c GIT binary patch literal 5975 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 Q0Qh5|W2*h&I4SBs01HT>8UO$Q literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..9f1b875bc314d441e467ec22d49507e1f59d1f59 GIT binary patch literal 5373 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..93c3a8dd87bb90713aef1e4da188d334d34c19f5 GIT binary patch literal 4350 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 DRi~@E literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..e44d5658ce9f0da420af12c7b81be684e8410ee5 GIT binary patch literal 4857 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 F{{g63(PaPt literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b22202533d7d3b0b2ea648ca7d750be4711e2695 GIT binary patch literal 5201 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 GkbeQsUt}Es literal 0 HcmV?d00001