From 83c906fece3b6947d21ae61512b07ecd0f09f7ba Mon Sep 17 00:00:00 2001 From: Ian Gow Date: Wed, 27 May 2026 13:49:31 -0400 Subject: [PATCH 01/19] Move axes setup to coord classes (cherry picked from commit 7a65e2803a51161c3f4b78a95793b2b04ca67abd) --- plotnine/coords/coord.py | 51 ++++++++++++++++++++++++++++++++++- plotnine/facets/facet.py | 58 +--------------------------------------- plotnine/ggplot.py | 2 +- 3 files changed, 52 insertions(+), 59 deletions(-) diff --git a/plotnine/coords/coord.py b/plotnine/coords/coord.py index 8dcbc378fa..73d61384a6 100644 --- a/plotnine/coords/coord.py +++ b/plotnine/coords/coord.py @@ -12,8 +12,9 @@ import numpy.typing as npt import pandas as pd + from matplotlib.axes import Axes - from plotnine import ggplot + from plotnine import ggplot, theme from plotnine.iapi import labels_view, panel_view from plotnine.scales.scale import scale from plotnine.typing import ( @@ -104,6 +105,54 @@ def aspect(self, panel_params: panel_view) -> float | None: """ return None + def setup_ax( + self, ax: Axes, panel_params: panel_view, theme: theme + ) -> None: + """ + Set limits, breaks and labels for one panel axes + + Subclasses can override this to customize axes setup, or call + `super().setup_ax(...)` and add coordinate-specific behavior. + """ + 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)) + + # Blank axis text is not drawn, so its margin may be absent + # (resolves to None). Skip the tick-label padding in that case. + if not theme.T.is_blank("axis_text_x"): + pad_x = theme.get_margin("axis_text_x").pt.t + ax.tick_params(axis="x", which="major", pad=pad_x) + if not theme.T.is_blank("axis_text_y"): + pad_y = theme.get_margin("axis_text_y").pt.r + ax.tick_params(axis="y", which="major", pad=pad_y) + def labels(self, cur_labels: labels_view) -> labels_view: """ Modify labels diff --git a/plotnine/facets/facet.py b/plotnine/facets/facet.py index 7f2bb0b34e..7bc99f0bf0 100644 --- a/plotnine/facets/facet.py +++ b/plotnine/facets/facet.py @@ -26,7 +26,7 @@ from plotnine.coords.coord import coord from plotnine.facets.labelling import CanBeStripLabellingFunc from plotnine.facets.layout import Layout - from plotnine.iapi import layout_details, panel_view + from plotnine.iapi import layout_details from plotnine.layer import Layers from plotnine.mapping import Environment from plotnine.scales.scale import scale @@ -303,62 +303,6 @@ def make_strips(self, layout_info: layout_details, ax: Axes) -> Strips: """ return Strips() - def set_limits_breaks_and_labels(self, panel_params: panel_view, ax: Axes): - """ - Add limits, breaks and labels to the axes - - Parameters - ---------- - panel_params : - range information for the axes - ax : - Axes - """ - 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) - - theme = self.theme - - # limits - ax.set_xlim(*_inf_to_none(panel_params.x.range)) - ax.set_ylim(*_inf_to_none(panel_params.y.range)) - - if typing.TYPE_CHECKING: - assert callable(ax.set_xticks) - assert callable(ax.set_yticks) - - # 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)) - - # Blank axis text is not drawn, so its margin may be absent - # (resolves to None). Skip the tick-label padding in that case. - if not theme.T.is_blank("axis_text_x"): - pad_x = theme.get_margin("axis_text_x").pt.t - ax.tick_params(axis="x", which="major", pad=pad_x) - if not theme.T.is_blank("axis_text_y"): - pad_y = theme.get_margin("axis_text_y").pt.r - ax.tick_params(axis="y", which="major", pad=pad_y) - def __deepcopy__(self, memo: dict[Any, Any]) -> facet: """ Deep copy without copying the dataframe and environment diff --git a/plotnine/ggplot.py b/plotnine/ggplot.py index 7eb70a1033..8d10d07541 100755 --- a/plotnine/ggplot.py +++ b/plotnine/ggplot.py @@ -561,7 +561,7 @@ def _draw_breaks_and_labels(self): pidx = layout_info.panel_index ax = self.axs[pidx] panel_params = self.layout.panel_params[pidx] - self.facet.set_limits_breaks_and_labels(panel_params, ax) + self.coordinates.setup_ax(ax, panel_params, self.theme) # Remove unnecessary ticks and labels if not layout_info.axis_x: From 9f0c75ddbe8dfc510d3ea469e2a7d3be783ecd7f Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Fri, 19 Jun 2026 23:22:01 +0300 Subject: [PATCH 02/19] feat(scale): add position support to scale_xy Add a scale_position mixin that validates and carries the axis position ("left"/"right" for y, "top"/"bottom" for x), and a scale_position_view that propagates the resolved position through coord_cartesian to the coord. --- plotnine/coords/coord_cartesian.py | 8 ++++-- plotnine/iapi.py | 13 +++++++-- plotnine/scales/scale_xy.py | 44 ++++++++++++++++++++++++---- tests/test_scale_internals.py | 46 ++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 10 deletions(-) diff --git a/plotnine/coords/coord_cartesian.py b/plotnine/coords/coord_cartesian.py index 54730ff2f2..b0338bac5b 100644 --- a/plotnine/coords/coord_cartesian.py +++ b/plotnine/coords/coord_cartesian.py @@ -12,7 +12,7 @@ import pandas as pd - from plotnine.iapi import scale_view + from plotnine.iapi import scale_position_view from plotnine.scales.scale import scale from plotnine.typing import ( FloatArray, @@ -71,7 +71,7 @@ def setup_panel_params(self, scale_x: scale, scale_y: scale) -> panel_view: def get_scale_view( scale: scale, limits: tuple[Any, Any] - ) -> scale_view: + ) -> scale_position_view: coord_limits = ( scale.transform(limits) if limits and isinstance(scale, scale_continuous) @@ -82,7 +82,9 @@ def get_scale_view( scale.final_limits, expansion, coord_limits, identity_trans() ) sv = scale.view(limits=coord_limits, range=ranges.range) - return sv + # x/y scales are always position scales, so the view is a + # scale_position_view + return typing.cast("scale_position_view", sv) out = panel_view( x=get_scale_view(scale_x, self.limits.x), diff --git a/plotnine/iapi.py b/plotnine/iapi.py index ae404bc7cf..d5a8cb1010 100644 --- a/plotnine/iapi.py +++ b/plotnine/iapi.py @@ -53,6 +53,15 @@ class scale_view: labels: Sequence[str] +@dataclass +class scale_position_view(scale_view): + """ + Trained position scale information, including the axis side + """ + + position: Side + + @dataclass class range_view: """ @@ -142,8 +151,8 @@ class panel_view: Information from the trained position scales in a panel """ - x: scale_view - y: scale_view + x: scale_position_view + y: scale_position_view @dataclass diff --git a/plotnine/scales/scale_xy.py b/plotnine/scales/scale_xy.py index 2fbdc08022..aa9910c61f 100644 --- a/plotnine/scales/scale_xy.py +++ b/plotnine/scales/scale_xy.py @@ -10,7 +10,7 @@ from .._utils import array_kind, match from .._utils.registry import alias from ..exceptions import PlotnineError -from ..iapi import range_view +from ..iapi import range_view, scale_position_view from ._expand import expand_range from ._runtime_typing import TransUser # noqa: TCH001 from .range import RangeContinuous @@ -19,11 +19,41 @@ from .scale_discrete import scale_discrete if TYPE_CHECKING: - from typing import Sequence + from typing import Literal, Sequence from mizani.transforms import trans +# Valid axis sides per position aesthetic +AXIS_SIDES = {"x": ("bottom", "top"), "y": ("left", "right")} + + +class scale_position: + """ + Mixin for position scales — owns the axis side behavior + + `position`, `_aesthetics` and `__post_init__` come from the concrete + position scale this is mixed into. + """ + + def __post_init__(self): + super().__post_init__() # pyright: ignore[reportAttributeAccessIssue] + aesthetic = self._aesthetics[0] # pyright: ignore[reportAttributeAccessIssue] + sides = AXIS_SIDES[aesthetic] + if self.position not in sides: # pyright: ignore[reportAttributeAccessIssue] + raise PlotnineError( + f"Invalid position {self.position!r} for the " # pyright: ignore[reportAttributeAccessIssue] + f"{aesthetic!r} axis. Expected one of {sides}." + ) + + 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] + + # positions scales have a couple of differences (quirks) that # make necessary to override some of the scale_discrete and # scale_continuous methods @@ -32,7 +62,7 @@ # are intermediate base classes where the required overriding # is done @dataclass(kw_only=True) -class scale_position_discrete(scale_discrete[None]): +class scale_position_discrete(scale_position, scale_discrete[None]): """ Base class for discrete position scales """ @@ -41,7 +71,7 @@ class scale_position_discrete(scale_discrete[None]): guide: None = None def __post_init__(self): - super().__post_init__() + super().__post_init__() # scale_position validates first # Keeps two ranges, range and range_c self._range_c = RangeContinuous() if isinstance(self.limits, tuple): @@ -187,7 +217,7 @@ def expand_limits( @dataclass(kw_only=True) -class scale_position_continuous(scale_continuous[None]): +class scale_position_continuous(scale_position, scale_continuous[None]): """ Base class for continuous position scales """ @@ -214,6 +244,7 @@ class scale_x_discrete(scale_position_discrete): """ _aesthetics = ["x", "xmin", "xmax", "xend", "xintercept"] + position: Literal["bottom", "top"] = "bottom" @dataclass(kw_only=True) @@ -223,6 +254,7 @@ class scale_y_discrete(scale_position_discrete): """ _aesthetics = ["y", "ymin", "ymax", "yend", "yintercept"] + position: Literal["left", "right"] = "left" # Not part of the user API @@ -243,6 +275,7 @@ class scale_x_continuous(scale_position_continuous): """ _aesthetics = ["x", "xmin", "xmax", "xend", "xintercept"] + position: Literal["bottom", "top"] = "bottom" @dataclass(kw_only=True) @@ -263,6 +296,7 @@ class scale_y_continuous(scale_position_continuous): "middle", "upper", ] + position: Literal["left", "right"] = "left" # Transformed scales diff --git a/tests/test_scale_internals.py b/tests/test_scale_internals.py index bd74049657..1c07761a56 100644 --- a/tests/test_scale_internals.py +++ b/tests/test_scale_internals.py @@ -915,3 +915,49 @@ def test_transform_datetime_aes_param(): + geom_point(y=yparam, color="red") ) assert p == "transform_datetime_aes_param" + + +def test_position_defaults(): + from plotnine.scales.scale_xy import ( + scale_x_continuous, + scale_x_discrete, + scale_y_continuous, + ) + + assert scale_x_continuous().position == "bottom" + assert scale_y_continuous().position == "left" + assert scale_x_discrete().position == "bottom" + + +def test_position_explicit(): + from plotnine.scales.scale_xy import scale_x_continuous, scale_y_continuous + + assert scale_x_continuous(position="top").position == "top" + assert scale_y_continuous(position="right").position == "right" + + +def test_position_invalid_for_aesthetic(): + from plotnine.exceptions import PlotnineError + from plotnine.scales.scale_xy import scale_x_continuous, scale_y_continuous + + with pytest.raises(PlotnineError): + scale_x_continuous(position="left") # type: ignore[arg-type] + with pytest.raises(PlotnineError): + scale_y_continuous(position="bottom") # type: ignore[arg-type] + with pytest.raises(PlotnineError): + scale_x_continuous(position="middle") # type: ignore[arg-type] + + +def test_panel_view_carries_position(): + from plotnine import aes, geom_point, ggplot + from plotnine.data import mtcars + from plotnine.scales.scale_xy import scale_x_continuous + + p = ( + ggplot(mtcars, aes("wt", "mpg")) + + geom_point() + + scale_x_continuous(position="top") + ) + pp = p.build_test().layout.panel_params[0] + assert pp.x.position == "top" + assert pp.y.position == "left" From 1692256f66c9b8be0115211e71d5a72e7ac8c4bc Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Fri, 19 Jun 2026 23:30:22 +0300 Subject: [PATCH 03/19] feat(coord): setup_ax owns side config; per-side axis-title targets --- plotnine/coords/coord.py | 77 ++++++++++++++++++++++++++++++++++---- plotnine/ggplot.py | 31 +++++++-------- plotnine/themes/targets.py | 4 ++ 3 files changed, 87 insertions(+), 25 deletions(-) diff --git a/plotnine/coords/coord.py b/plotnine/coords/coord.py index 73d61384a6..105fbe0d82 100644 --- a/plotnine/coords/coord.py +++ b/plotnine/coords/coord.py @@ -15,7 +15,7 @@ from matplotlib.axes import Axes from plotnine import ggplot, theme - from plotnine.iapi import labels_view, panel_view + from plotnine.iapi import labels_view, layout_details, panel_view from plotnine.scales.scale import scale from plotnine.typing import ( FloatArray, @@ -106,10 +106,14 @@ def aspect(self, panel_params: panel_view) -> float | None: return None def setup_ax( - self, ax: Axes, panel_params: panel_view, theme: theme + self, + ax: Axes, + panel_params: panel_view, + layout_info: layout_details, + theme: theme, ) -> None: """ - Set limits, breaks and labels for one panel axes + Set limits, breaks, labels and the active side for one panel axes Subclasses can override this to customize axes setup, or call `super().setup_ax(...)` and add coordinate-specific behavior. @@ -144,13 +148,72 @@ def _inf_to_none( ax.xaxis.set_major_formatter(MyFixedFormatter(panel_params.x.labels)) ax.yaxis.set_major_formatter(MyFixedFormatter(panel_params.y.labels)) - # Blank axis text is not drawn, so its margin may be absent - # (resolves to None). Skip the tick-label padding in that case. + # Activate the side each axis sits on; deactivate the other side. + x_pos = panel_params.x.position # "bottom" | "top" + y_pos = panel_params.y.position # "left" | "right" + + if x_pos == "top": + ax.xaxis.set_tick_params( + which="both", + top=True, + labeltop=True, + bottom=False, + labelbottom=False, + ) + else: + ax.xaxis.set_tick_params( + which="both", + bottom=True, + labelbottom=True, + top=False, + labeltop=False, + ) + if not layout_info.axis_x: + ax.xaxis.set_tick_params( + which="both", + bottom=False, + labelbottom=False, + top=False, + labeltop=False, + ) + + if y_pos == "right": + ax.yaxis.set_tick_params( + which="both", + right=True, + labelright=True, + left=False, + labelleft=False, + ) + else: + ax.yaxis.set_tick_params( + which="both", + left=True, + labelleft=True, + right=False, + labelright=False, + ) + if not layout_info.axis_y: + ax.yaxis.set_tick_params( + which="both", + left=False, + labelleft=False, + right=False, + labelright=False, + ) + + # Tick pad from the active side's tick-text margin. The inner edge + # of the margin faces the panel: x-bottom uses the top margin, + # x-top the bottom; y-left uses the right margin, y-right the left. + # Blank axis text is not drawn, so its margin may be absent; skip + # the padding in that case. if not theme.T.is_blank("axis_text_x"): - pad_x = theme.get_margin("axis_text_x").pt.t + m = theme.get_margin("axis_text_x").pt + pad_x = m.b if x_pos == "top" else m.t ax.tick_params(axis="x", which="major", pad=pad_x) if not theme.T.is_blank("axis_text_y"): - pad_y = theme.get_margin("axis_text_y").pt.r + m = theme.get_margin("axis_text_y").pt + pad_y = m.l if y_pos == "right" else m.r ax.tick_params(axis="y", which="major", pad=pad_y) def labels(self, cur_labels: labels_view) -> labels_view: diff --git a/plotnine/ggplot.py b/plotnine/ggplot.py index 8d10d07541..2c7192d1e3 100755 --- a/plotnine/ggplot.py +++ b/plotnine/ggplot.py @@ -561,22 +561,9 @@ def _draw_breaks_and_labels(self): pidx = layout_info.panel_index ax = self.axs[pidx] panel_params = self.layout.panel_params[pidx] - self.coordinates.setup_ax(ax, panel_params, self.theme) - - # Remove unnecessary ticks and labels - if not layout_info.axis_x: - ax.xaxis.set_tick_params( - which="both", bottom=False, labelbottom=False - ) - if not layout_info.axis_y: - ax.yaxis.set_tick_params( - which="both", left=False, labelleft=False - ) - - if layout_info.axis_x: - ax.xaxis.set_tick_params(which="both", bottom=True) - if layout_info.axis_y: - ax.yaxis.set_tick_params(which="both", left=True) + self.coordinates.setup_ax( + ax, panel_params, layout_info, self.theme + ) def _draw_figure_texts(self): """ @@ -608,11 +595,19 @@ def _draw_figure_texts(self): self.layout.set_xy_labels(self.labels) ) + # The axis title is registered under a per-side target named for the + # axis position. The legacy axis_title_x/_y references point at the + # same artist so existing layout/theme code keeps working. + pp = self.layout.panel_params[0] if labels.x: - targets.axis_title_x = self.figure.add_artist(Text(text=labels.x)) + t = self.figure.add_artist(Text(text=labels.x)) + targets.axis_title_x = t + setattr(targets, f"axis_title_x_{pp.x.position}", t) if labels.y: - targets.axis_title_y = self.figure.add_artist(Text(text=labels.y)) + t = self.figure.add_artist(Text(text=labels.y)) + targets.axis_title_y = t + setattr(targets, f"axis_title_y_{pp.y.position}", t) def _draw_watermarks(self): """ diff --git a/plotnine/themes/targets.py b/plotnine/themes/targets.py index 8a34c469ca..d8410e1c02 100644 --- a/plotnine/themes/targets.py +++ b/plotnine/themes/targets.py @@ -28,6 +28,10 @@ class ThemeTargets: axis_title_x: Optional[Text] = None axis_title_y: Optional[Text] = None + axis_title_x_top: Optional[Text] = None + axis_title_x_bottom: Optional[Text] = None + axis_title_y_left: Optional[Text] = None + axis_title_y_right: Optional[Text] = None legend_frame: Optional[Rectangle] = None legend_key: list[ColoredDrawingArea] = field(default_factory=list) legends: Optional[legend_artists] = None From b360e02cd70332a642b2db32a367b67eb36f1dc9 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Fri, 19 Jun 2026 23:44:24 +0300 Subject: [PATCH 04/19] feat(theme): side-scoped axis themeables; coord_flip rotates position Add per-side axis theme hierarchy (axis_text/ticks/line/title each gain _x_top/_x_bottom, _y_left/_y_right children under the existing parent). Each child styles its own side's artist; coord.setup_ax makes the active side's ticks/spine visible per scale.position. coord_flip rotates the position with the swap. Delete the dead mpl<3.10 tick-param shim. --- plotnine/_utils/__init__.py | 12 + plotnine/coords/coord.py | 7 + plotnine/coords/coord_flip.py | 16 +- plotnine/themes/themeable.py | 524 +++++++++++++++++++++++----------- tests/test_scale_internals.py | 16 ++ 5 files changed, 401 insertions(+), 174 deletions(-) diff --git a/plotnine/_utils/__init__.py b/plotnine/_utils/__init__.py index 4c95504afc..d8e826f263 100644 --- a/plotnine/_utils/__init__.py +++ b/plotnine/_utils/__init__.py @@ -61,6 +61,18 @@ to_rgba = color_utils.to_rgba +def side_artists(side: str) -> tuple[str, str]: + """ + Return the `(tickline, label)` tick-attribute names for an axis side + + The bottom/left side maps to `tick1line`/`label1` and the top/right side + to `tick2line`/`label2`. + """ + if side in ("top", "right"): + return ("tick2line", "label2") + return ("tick1line", "label1") + + def is_scalar(val): """ Return whether the given object is a scalar diff --git a/plotnine/coords/coord.py b/plotnine/coords/coord.py index 105fbe0d82..97cb9581fa 100644 --- a/plotnine/coords/coord.py +++ b/plotnine/coords/coord.py @@ -202,6 +202,13 @@ def _inf_to_none( labelright=False, ) + # Show the 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") + # Tick pad from the active side's tick-text margin. The inner edge # of the margin faces the panel: x-bottom uses the top margin, # x-top the bottom; y-left uses the right margin, y-right the left. diff --git a/plotnine/coords/coord_flip.py b/plotnine/coords/coord_flip.py index ea409aaf59..4642a2f3da 100644 --- a/plotnine/coords/coord_flip.py +++ b/plotnine/coords/coord_flip.py @@ -11,12 +11,21 @@ from typing import Sequence, TypeVar from plotnine.scales.scale import scale + from plotnine.typing import Side THasLabels = TypeVar( "THasLabels", bound=pd.DataFrame | labels_view | panel_view ) +_FLIP_POSITION: dict[Side, Side] = { + "top": "right", + "bottom": "left", + "left": "bottom", + "right": "top", +} + + class coord_flip(coord_cartesian): """ Flipped cartesian coordinates @@ -47,7 +56,12 @@ def transform( def setup_panel_params(self, scale_x: scale, scale_y: scale) -> panel_view: panel_params = super().setup_panel_params(scale_x, scale_y) - return flip_labels(panel_params) + panel_params = flip_labels(panel_params) + # The axis position rotates with the flip (matches ggplot2's + # 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] + return panel_params def setup_layout(self, layout: pd.DataFrame) -> pd.DataFrame: # switch the scales diff --git a/plotnine/themes/themeable.py b/plotnine/themes/themeable.py index ee97f143bb..3f1048ab84 100644 --- a/plotnine/themes/themeable.py +++ b/plotnine/themes/themeable.py @@ -17,7 +17,7 @@ import numpy as np -from .._utils import has_alpha_channel, to_rgba +from .._utils import has_alpha_channel, side_artists, to_rgba from .._utils.registry import RegistryHierarchyMeta from ..exceptions import PlotnineError, deprecated_themeable_name from .elements import element_blank @@ -524,48 +524,92 @@ def blend_alpha( # element_text themeables -class axis_title_x(themeable): +class axis_title_x_bottom(themeable): """ - x axis label - - Parameters - ---------- - theme_element : element_text + x axis label on the bottom """ def apply_figure(self, figure: Figure, targets: ThemeTargets): super().apply_figure(figure, targets) - if text := targets.axis_title_x: + if text := targets.axis_title_x_bottom: # ha can be a float and is handled by the layout manager text.set(**self._get_properties(omit=("margin", "ha"))) def blank_figure(self, figure: Figure, targets: ThemeTargets): super().blank_figure(figure, targets) - if text := targets.axis_title_x: + if text := targets.axis_title_x_bottom: text.set_visible(False) -class axis_title_y(themeable): +class axis_title_x_top(themeable): """ - y axis label + x axis label on the top + """ + + def apply_figure(self, figure: Figure, targets: ThemeTargets): + super().apply_figure(figure, targets) + if text := targets.axis_title_x_top: + text.set(**self._get_properties(omit=("margin", "ha"))) + + def blank_figure(self, figure: Figure, targets: ThemeTargets): + super().blank_figure(figure, targets) + if text := targets.axis_title_x_top: + text.set_visible(False) + + +class axis_title_x(axis_title_x_top, axis_title_x_bottom): + """ + x axis label Parameters ---------- theme_element : element_text """ + +class axis_title_y_left(themeable): + """ + y axis label on the left + """ + def apply_figure(self, figure: Figure, targets: ThemeTargets): super().apply_figure(figure, targets) - if text := targets.axis_title_y: + if text := targets.axis_title_y_left: # va can be a float and is handled by the layout manager text.set(**self._get_properties(omit=("margin", "va"))) def blank_figure(self, figure: Figure, targets: ThemeTargets): super().blank_figure(figure, targets) - if text := targets.axis_title_y: + if text := targets.axis_title_y_left: text.set_visible(False) +class axis_title_y_right(themeable): + """ + y axis label on the right + """ + + def apply_figure(self, figure: Figure, targets: ThemeTargets): + super().apply_figure(figure, targets) + if text := targets.axis_title_y_right: + text.set(**self._get_properties(omit=("margin", "va"))) + + def blank_figure(self, figure: Figure, targets: ThemeTargets): + super().blank_figure(figure, targets) + if text := targets.axis_title_y_right: + text.set_visible(False) + + +class axis_title_y(axis_title_y_left, axis_title_y_right): + """ + y axis label + + Parameters + ---------- + theme_element : element_text + """ + + class axis_title(axis_title_x, axis_title_y): """ Axis labels @@ -948,7 +992,53 @@ class title( """ -class axis_text_x(MixinSequenceOfValues): +class axis_text_x_bottom(MixinSequenceOfValues): + """ + x-axis tick labels on the bottom + + Parameters + ---------- + theme_element : element_text + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + if not ax.xaxis.get_tick_params(which="major").get( + "labelbottom", False + ): + return + labels = [t.label1 for t in ax.xaxis.get_major_ticks()] + self.set(labels, self._get_properties(omit=("margin", "va"))) + + def blank_ax(self, ax: Axes): + super().blank_ax(ax) + for t in ax.xaxis.get_major_ticks(): + t.label1.set_visible(False) + + +class axis_text_x_top(MixinSequenceOfValues): + """ + x-axis tick labels on the top + + Parameters + ---------- + theme_element : element_text + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + if not ax.xaxis.get_tick_params(which="major").get("labeltop", False): + return + labels = [t.label2 for t in ax.xaxis.get_major_ticks()] + self.set(labels, self._get_properties(omit=("margin", "va"))) + + def blank_ax(self, ax: Axes): + super().blank_ax(ax) + for t in ax.xaxis.get_major_ticks(): + t.label2.set_visible(False) + + +class axis_text_x(axis_text_x_top, axis_text_x_bottom): """ x-axis tick labels @@ -968,36 +1058,54 @@ class axis_text_x(MixinSequenceOfValues): creates a margin of 5 points. """ + +class axis_text_y_left(MixinSequenceOfValues): + """ + y-axis tick labels on the left + + Parameters + ---------- + theme_element : element_text + """ + def apply_ax(self, ax: Axes): super().apply_ax(ax) + if not ax.yaxis.get_tick_params(which="major").get("labelleft", False): + return + labels = [t.label1 for t in ax.yaxis.get_major_ticks()] + self.set(labels, self._get_properties(omit=("margin", "ha"))) - # TODO: Remove this code when the minimum matplotlib >= 3.10.0, - # and use the commented one below it - import matplotlib as mpl - from packaging import version + def blank_ax(self, ax: Axes): + super().blank_ax(ax) + for t in ax.yaxis.get_major_ticks(): + t.label1.set_visible(False) - vinstalled = version.parse(mpl.__version__) - v310 = version.parse("3.10.0") - name = "labelbottom" if vinstalled >= v310 else "labelleft" - if not ax.xaxis.get_tick_params()[name]: - return - # if not ax.xaxis.get_tick_params()["labelbottom"]: - # return +class axis_text_y_right(MixinSequenceOfValues): + """ + y-axis tick labels on the right + + Parameters + ---------- + theme_element : element_text + """ - labels = [t.label1 for t in ax.xaxis.get_major_ticks()] - self.set( - labels, - self._get_properties(omit=("margin", "va")), - ) + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + if not ax.yaxis.get_tick_params(which="major").get( + "labelright", False + ): + return + labels = [t.label2 for t in ax.yaxis.get_major_ticks()] + self.set(labels, self._get_properties(omit=("margin", "ha"))) def blank_ax(self, ax: Axes): super().blank_ax(ax) - for t in ax.xaxis.get_major_ticks(): - t.label1.set_visible(False) + for t in ax.yaxis.get_major_ticks(): + t.label2.set_visible(False) -class axis_text_y(MixinSequenceOfValues): +class axis_text_y(axis_text_y_left, axis_text_y_right): """ y-axis tick labels @@ -1017,23 +1125,6 @@ class axis_text_y(MixinSequenceOfValues): creates a margin of 5 points. """ - def apply_ax(self, ax: Axes): - super().apply_ax(ax) - - if not ax.yaxis.get_tick_params()["labelleft"]: - return - - labels = [t.label1 for t in ax.yaxis.get_major_ticks()] - self.set( - labels, - self._get_properties(omit=("margin", "ha")), - ) - - def blank_ax(self, ax: Axes): - super().blank_ax(ax) - for t in ax.yaxis.get_major_ticks(): - t.label1.set_visible(False) - class axis_text(axis_text_x, axis_text_y): """ @@ -1096,63 +1187,93 @@ def rcParams(self): # element_line themeables -class axis_line_x(themeable): +def _style_axis_line(themeable, ax, side): """ - x-axis line + Style the spine on one side, when that side carries the axis - Parameters - ---------- - theme_element : element_line + `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`). """ + if not ax.spines[side].get_visible(): + 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 + ax.spines[side].set(**properties) - position = "bottom" + +class axis_line_x_bottom(themeable): + """ + x-axis line on the bottom + """ def apply_ax(self, ax: Axes): super().apply_ax(ax) - properties = self._get_properties(omit=("solid_capstyle",)) - # MPL has a default zorder of 2.5 for spines - # so layers 3+ would be drawn on top of the spines - if "zorder" not in properties: - properties["zorder"] = 10000 - ax.spines["top"].set_visible(False) - ax.spines["bottom"].set(**properties) + _style_axis_line(self, ax, "bottom") def blank_ax(self, ax: Axes): super().blank_ax(ax) - ax.spines["top"].set_visible(False) ax.spines["bottom"].set_visible(False) -class axis_line_y(themeable): +class axis_line_x_top(themeable): """ - y-axis line + x-axis line on the top + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + _style_axis_line(self, ax, "top") + + def blank_ax(self, ax: Axes): + super().blank_ax(ax) + ax.spines["top"].set_visible(False) + + +class axis_line_x(axis_line_x_top, axis_line_x_bottom): + """ + x-axis line Parameters ---------- theme_element : element_line """ - position = "left" + +class axis_line_y_left(themeable): + """ + y-axis line on the left + """ def apply_ax(self, ax: Axes): super().apply_ax(ax) - properties = self._get_properties(omit=("solid_capstyle",)) - # MPL has a default zorder of 2.5 for spines - # so layers 3+ would be drawn on top of the spines - if "zorder" not in properties: - properties["zorder"] = 10000 - ax.spines["right"].set_visible(False) - ax.spines["left"].set(**properties) + _style_axis_line(self, ax, "left") def blank_ax(self, ax: Axes): super().blank_ax(ax) ax.spines["left"].set_visible(False) + + +class axis_line_y_right(themeable): + """ + y-axis line on the right + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + _style_axis_line(self, ax, "right") + + def blank_ax(self, ax: Axes): + super().blank_ax(ax) ax.spines["right"].set_visible(False) -class axis_line(axis_line_x, axis_line_y): +class axis_line_y(axis_line_y_left, axis_line_y_right): """ - x & y axis lines + y-axis line Parameters ---------- @@ -1160,164 +1281,211 @@ class axis_line(axis_line_x, axis_line_y): """ -class axis_ticks_minor_x(MixinSequenceOfValues): +class axis_line(axis_line_x, axis_line_y): """ - x-axis tick lines + x & y axis lines Parameters ---------- theme_element : element_line """ + +def _style_axis_ticks(themeable, ax, axis_name, which, side): + """ + Style the tick lines on one side of an axis + """ + 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): + return + + # We have to use both Axis.set_tick_params() and Tick.tickline.set(). + # Splitting the properties lets set_tick_params keep a record of the + # ones it cares about so it does not undo them. GH703 + # https://github.com/matplotlib/matplotlib/issues/26008 + tick_params = {} + properties = themeable.properties + with suppress(KeyError): + tick_params["width"] = properties.pop("linewidth") + with suppress(KeyError): + tick_params["color"] = properties.pop("color") + + if tick_params: + axis.set_tick_params(which=which, **tick_params) + + attr = side_artists(side)[0] + ticks = ( + axis.get_minor_ticks() if which == "minor" else axis.get_major_ticks() + ) + themeable.set([getattr(t, attr) for t in ticks], properties) + + +def _blank_axis_ticks(ax, axis_name, which, side): + """ + Hide the tick lines on one side of an axis + """ + axis = getattr(ax, axis_name) + attr = side_artists(side)[0] + ticks = ( + axis.get_minor_ticks() if which == "minor" else axis.get_major_ticks() + ) + for tick in ticks: + getattr(tick, attr).set_visible(False) + + +class axis_ticks_minor_x_bottom(MixinSequenceOfValues): + """ + x-axis minor tick lines on the bottom + """ + def apply_ax(self, ax: Axes): super().apply_ax(ax) - # The ggplot._draw_breaks_and_labels uses set_tick_params to - # turn off the ticks that will not show. That sets the location - # key (e.g. params["bottom"]) to False. It also sets the artist - # to invisible. Theming should not change those artists to visible, - # so we return early. - params = ax.xaxis.get_tick_params(which="minor") - if not params.get("bottom", False): - return + _style_axis_ticks(self, ax, "xaxis", "minor", "bottom") - # We have to use both - # 1. Axis.set_tick_params() - # 2. Tick.tick1line.set() - # We split the properties so that set_tick_params keeps - # record of the properties it cares about so that it does - # not undo them. GH703 - # https://github.com/matplotlib/matplotlib/issues/26008 - tick_params = {} - properties = self.properties - with suppress(KeyError): - tick_params["width"] = properties.pop("linewidth") - with suppress(KeyError): - tick_params["color"] = properties.pop("color") + def blank_ax(self, ax: Axes): + super().blank_ax(ax) + _blank_axis_ticks(ax, "xaxis", "minor", "bottom") - if tick_params: - ax.xaxis.set_tick_params(which="minor", **tick_params) - lines = [t.tick1line for t in ax.xaxis.get_minor_ticks()] - self.set(lines, properties) +class axis_ticks_minor_x_top(MixinSequenceOfValues): + """ + x-axis minor tick lines on the top + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + _style_axis_ticks(self, ax, "xaxis", "minor", "top") def blank_ax(self, ax: Axes): super().blank_ax(ax) - for tick in ax.xaxis.get_minor_ticks(): - tick.tick1line.set_visible(False) + _blank_axis_ticks(ax, "xaxis", "minor", "top") -class axis_ticks_minor_y(MixinSequenceOfValues): +class axis_ticks_minor_x(axis_ticks_minor_x_top, axis_ticks_minor_x_bottom): """ - y-axis minor tick lines + x-axis minor tick lines Parameters ---------- theme_element : element_line """ + +class axis_ticks_minor_y_left(MixinSequenceOfValues): + """ + y-axis minor tick lines on the left + """ + def apply_ax(self, ax: Axes): super().apply_ax(ax) - params = ax.yaxis.get_tick_params(which="minor") - if not params.get("left", False): - return + _style_axis_ticks(self, ax, "yaxis", "minor", "left") + + def blank_ax(self, ax: Axes): + super().blank_ax(ax) + _blank_axis_ticks(ax, "yaxis", "minor", "left") - tick_params = {} - properties = self.properties - with suppress(KeyError): - tick_params["width"] = properties.pop("linewidth") - with suppress(KeyError): - tick_params["color"] = properties.pop("color") - if tick_params: - ax.yaxis.set_tick_params(which="minor", **tick_params) +class axis_ticks_minor_y_right(MixinSequenceOfValues): + """ + y-axis minor tick lines on the right + """ - lines = [t.tick1line for t in ax.yaxis.get_minor_ticks()] - self.set(lines, properties) + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + _style_axis_ticks(self, ax, "yaxis", "minor", "right") def blank_ax(self, ax: Axes): super().blank_ax(ax) - for tick in ax.yaxis.get_minor_ticks(): - tick.tick1line.set_visible(False) + _blank_axis_ticks(ax, "yaxis", "minor", "right") -class axis_ticks_major_x(MixinSequenceOfValues): +class axis_ticks_minor_y(axis_ticks_minor_y_left, axis_ticks_minor_y_right): """ - x-axis major tick lines + y-axis minor tick lines Parameters ---------- theme_element : element_line """ - def apply_ax(self, ax: Axes): - super().apply_ax(ax) - params = ax.xaxis.get_tick_params(which="major") - # TODO: Remove this code when the minimum matplotlib >= 3.10.0, - # and use the commented one below it - import matplotlib as mpl - from packaging import version +class axis_ticks_major_x_bottom(MixinSequenceOfValues): + """ + x-axis major tick lines on the bottom + """ - vinstalled = version.parse(mpl.__version__) - v310 = version.parse("3.10.0") - name = "bottom" if vinstalled >= v310 else "left" - if not params.get(name, False): - return + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + _style_axis_ticks(self, ax, "xaxis", "major", "bottom") - # if not params.get("bottom", False): - # return + def blank_ax(self, ax: Axes): + super().blank_ax(ax) + _blank_axis_ticks(ax, "xaxis", "major", "bottom") - tick_params = {} - properties = self.properties - with suppress(KeyError): - tick_params["width"] = properties.pop("linewidth") - with suppress(KeyError): - tick_params["color"] = properties.pop("color") - if tick_params: - ax.xaxis.set_tick_params(which="major", **tick_params) +class axis_ticks_major_x_top(MixinSequenceOfValues): + """ + x-axis major tick lines on the top + """ - lines = [t.tick1line for t in ax.xaxis.get_major_ticks()] - self.set(lines, properties) + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + _style_axis_ticks(self, ax, "xaxis", "major", "top") def blank_ax(self, ax: Axes): super().blank_ax(ax) - for tick in ax.xaxis.get_major_ticks(): - tick.tick1line.set_visible(False) + _blank_axis_ticks(ax, "xaxis", "major", "top") -class axis_ticks_major_y(MixinSequenceOfValues): +class axis_ticks_major_x(axis_ticks_major_x_top, axis_ticks_major_x_bottom): """ - y-axis major tick lines + x-axis major tick lines Parameters ---------- theme_element : element_line """ + +class axis_ticks_major_y_left(MixinSequenceOfValues): + """ + y-axis major tick lines on the left + """ + def apply_ax(self, ax: Axes): super().apply_ax(ax) - params = ax.yaxis.get_tick_params(which="major") - if not params.get("left", False): - return + _style_axis_ticks(self, ax, "yaxis", "major", "left") - tick_params = {} - properties = self.properties - with suppress(KeyError): - tick_params["width"] = properties.pop("linewidth") - with suppress(KeyError): - tick_params["color"] = properties.pop("color") + def blank_ax(self, ax: Axes): + super().blank_ax(ax) + _blank_axis_ticks(ax, "yaxis", "major", "left") - if tick_params: - ax.yaxis.set_tick_params(which="major", **tick_params) - lines = [t.tick1line for t in ax.yaxis.get_major_ticks()] - self.set(lines, properties) +class axis_ticks_major_y_right(MixinSequenceOfValues): + """ + y-axis major tick lines on the right + """ + + def apply_ax(self, ax: Axes): + super().apply_ax(ax) + _style_axis_ticks(self, ax, "yaxis", "major", "right") def blank_ax(self, ax: Axes): super().blank_ax(ax) - for tick in ax.yaxis.get_major_ticks(): - tick.tick1line.set_visible(False) + _blank_axis_ticks(ax, "yaxis", "major", "right") + + +class axis_ticks_major_y(axis_ticks_major_y_left, axis_ticks_major_y_right): + """ + y-axis major tick lines + + Parameters + ---------- + theme_element : element_line + """ class axis_ticks_major(axis_ticks_major_x, axis_ticks_major_y): @@ -1838,7 +2006,10 @@ def apply_ax(self, ax: Axes): value: float | complex = self.properties["value"] try: - visible = ax.xaxis.get_major_ticks()[0].tick1line.get_visible() + tick = ax.xaxis.get_major_ticks()[0] + visible = ( + tick.tick1line.get_visible() or tick.tick2line.get_visible() + ) except IndexError: value = 0 else: @@ -1872,7 +2043,10 @@ def apply_ax(self, ax: Axes): value: float | complex = self.properties["value"] try: - visible = ax.yaxis.get_major_ticks()[0].tick1line.get_visible() + tick = ax.yaxis.get_major_ticks()[0] + visible = ( + tick.tick1line.get_visible() or tick.tick2line.get_visible() + ) except IndexError: value = 0 else: @@ -2674,7 +2848,8 @@ def apply_ax(self, ax: Axes): val = self.properties["value"] for t in ax.xaxis.get_major_ticks(): - _val = val if t.tick1line.get_visible() else 0 + visible = t.tick1line.get_visible() or t.tick2line.get_visible() + _val = val if visible else 0 t.set_pad(_val) @@ -2701,7 +2876,8 @@ def apply_ax(self, ax: Axes): val = self.properties["value"] for t in ax.yaxis.get_major_ticks(): - _val = val if t.tick1line.get_visible() else 0 + visible = t.tick1line.get_visible() or t.tick2line.get_visible() + _val = val if visible else 0 t.set_pad(_val) @@ -2744,7 +2920,8 @@ def apply_ax(self, ax: Axes): val = self.properties["value"] for t in ax.xaxis.get_minor_ticks(): - _val = val if t.tick1line.get_visible() else 0 + visible = t.tick1line.get_visible() or t.tick2line.get_visible() + _val = val if visible else 0 t.set_pad(_val) @@ -2770,7 +2947,8 @@ def apply_ax(self, ax: Axes): val = self.properties["value"] for t in ax.yaxis.get_minor_ticks(): - _val = val if t.tick1line.get_visible() else 0 + visible = t.tick1line.get_visible() or t.tick2line.get_visible() + _val = val if visible else 0 t.set_pad(_val) diff --git a/tests/test_scale_internals.py b/tests/test_scale_internals.py index 1c07761a56..545213b0fd 100644 --- a/tests/test_scale_internals.py +++ b/tests/test_scale_internals.py @@ -961,3 +961,19 @@ def test_panel_view_carries_position(): pp = p.build_test().layout.panel_params[0] assert pp.x.position == "top" assert pp.y.position == "left" + + +def test_coord_flip_rotates_position(): + from plotnine import aes, coord_flip, geom_point, ggplot + from plotnine.data import mtcars + from plotnine.scales.scale_xy import scale_x_continuous + + p = ( + ggplot(mtcars, aes("wt", "mpg")) + + geom_point() + + scale_x_continuous(position="top") + + coord_flip() + ) + pp = p.build_test().layout.panel_params[0] + assert pp.y.position == "right" + assert pp.x.position == "bottom" From 7e72b5d0bd52491b56422deca6614988a7f43976 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Fri, 19 Jun 2026 23:47:51 +0300 Subject: [PATCH 05/19] feat(facet): place moved axis on the resolved edge panels --- plotnine/facets/facet.py | 9 ++++++ plotnine/facets/facet_grid.py | 11 +++++-- plotnine/facets/facet_wrap.py | 15 ++++++--- plotnine/facets/layout.py | 3 ++ tests/test_scale_internals.py | 59 ++--------------------------------- 5 files changed, 35 insertions(+), 62 deletions(-) diff --git a/plotnine/facets/facet.py b/plotnine/facets/facet.py index 7bc99f0bf0..9eb51b3a15 100644 --- a/plotnine/facets/facet.py +++ b/plotnine/facets/facet.py @@ -137,6 +137,15 @@ def __radd__(self, other: ggplot) -> ggplot: other.facet.environment = other.environment return other + def axis_positions(self) -> tuple[str, str]: + """ + The sides the x and y axes occupy, as `(x_side, y_side)` + """ + scales = self.plot.scales + x_side = getattr(scales.x, "position", "bottom") + y_side = getattr(scales.y, "position", "left") + return x_side, y_side + def setup(self, plot: ggplot): self.plot = plot self.layout = plot.layout diff --git a/plotnine/facets/facet_grid.py b/plotnine/facets/facet_grid.py index cce4c95dca..ba329f2cbf 100644 --- a/plotnine/facets/facet_grid.py +++ b/plotnine/facets/facet_grid.py @@ -215,8 +215,15 @@ def compute_layout(self, data: list[pd.DataFrame]) -> pd.DataFrame: # Relax constraints, if necessary layout["SCALE_X"] = layout["COL"] if self.free["x"] else 1 layout["SCALE_Y"] = layout["ROW"] if self.free["y"] else 1 - layout["AXIS_X"] = layout["ROW"] == layout["ROW"].max() - layout["AXIS_Y"] = layout["COL"] == layout["COL"].min() + x_side, y_side = self.axis_positions() + if x_side == "top": + layout["AXIS_X"] = layout["ROW"] == layout["ROW"].min() + else: + layout["AXIS_X"] = layout["ROW"] == layout["ROW"].max() + if y_side == "right": + layout["AXIS_Y"] = layout["COL"] == layout["COL"].max() + else: + layout["AXIS_Y"] = layout["COL"] == layout["COL"].min() 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 cdf72d1c9d..db5b46455b 100644 --- a/plotnine/facets/facet_wrap.py +++ b/plotnine/facets/facet_wrap.py @@ -133,10 +133,17 @@ def compute_layout( layout["SCALE_Y"] = range(1, n + 1) if self.free["y"] else 1 # Figure out where axes should go. - # The bottom-most row of each column and the left most - # column of each row - x_idx = [df["ROW"].idxmax() for _, df in layout.groupby("COL")] - y_idx = [df["COL"].idxmin() for _, df in layout.groupby("ROW")] + # 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 = self.axis_positions() + if x_side == "top": + x_idx = [df["ROW"].idxmin() for _, df in layout.groupby("COL")] + else: + x_idx = [df["ROW"].idxmax() for _, df in layout.groupby("COL")] + if y_side == "right": + y_idx = [df["COL"].idxmax() for _, df in layout.groupby("ROW")] + else: + y_idx = [df["COL"].idxmin() for _, df in layout.groupby("ROW")] layout["AXIS_X"] = False layout["AXIS_Y"] = False _loc = layout.columns.get_loc diff --git a/plotnine/facets/layout.py b/plotnine/facets/layout.py index 58b9f3bd5f..8ff590e817 100644 --- a/plotnine/facets/layout.py +++ b/plotnine/facets/layout.py @@ -64,6 +64,9 @@ def setup(self, layers: Layers, plot: ggplot): # setup facets self.facet = plot.facet + # compute_layout (below) needs the scales to resolve axis positions; + # facet.setup() runs later in draw(), so make the plot available now + self.facet.plot = plot self.facet.setup_params(data) data = self.facet.setup_data(data) diff --git a/tests/test_scale_internals.py b/tests/test_scale_internals.py index 545213b0fd..8e89c15338 100644 --- a/tests/test_scale_internals.py +++ b/tests/test_scale_internals.py @@ -917,63 +917,10 @@ def test_transform_datetime_aes_param(): assert p == "transform_datetime_aes_param" -def test_position_defaults(): - from plotnine.scales.scale_xy import ( - scale_x_continuous, - scale_x_discrete, - scale_y_continuous, - ) - - assert scale_x_continuous().position == "bottom" - assert scale_y_continuous().position == "left" - assert scale_x_discrete().position == "bottom" - - -def test_position_explicit(): - from plotnine.scales.scale_xy import scale_x_continuous, scale_y_continuous - - assert scale_x_continuous(position="top").position == "top" - assert scale_y_continuous(position="right").position == "right" - - def test_position_invalid_for_aesthetic(): - from plotnine.exceptions import PlotnineError - from plotnine.scales.scale_xy import scale_x_continuous, scale_y_continuous - with pytest.raises(PlotnineError): - scale_x_continuous(position="left") # type: ignore[arg-type] + scale_x_continuous(position="left") # pyright: ignore[reportArgumentType] with pytest.raises(PlotnineError): - scale_y_continuous(position="bottom") # type: ignore[arg-type] + scale_y_continuous(position="bottom") # pyright: ignore[reportArgumentType] with pytest.raises(PlotnineError): - scale_x_continuous(position="middle") # type: ignore[arg-type] - - -def test_panel_view_carries_position(): - from plotnine import aes, geom_point, ggplot - from plotnine.data import mtcars - from plotnine.scales.scale_xy import scale_x_continuous - - p = ( - ggplot(mtcars, aes("wt", "mpg")) - + geom_point() - + scale_x_continuous(position="top") - ) - pp = p.build_test().layout.panel_params[0] - assert pp.x.position == "top" - assert pp.y.position == "left" - - -def test_coord_flip_rotates_position(): - from plotnine import aes, coord_flip, geom_point, ggplot - from plotnine.data import mtcars - from plotnine.scales.scale_xy import scale_x_continuous - - p = ( - ggplot(mtcars, aes("wt", "mpg")) - + geom_point() - + scale_x_continuous(position="top") - + coord_flip() - ) - pp = p.build_test().layout.panel_params[0] - assert pp.y.position == "right" - assert pp.x.position == "bottom" + scale_x_continuous(position="middle") # pyright: ignore[reportArgumentType] From c1c74c363818573c564f263b36ebd79d91b7095e Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Sat, 20 Jun 2026 00:50:33 +0300 Subject: [PATCH 06/19] feat(layout): allocate per-side axis space and place titles for moved axes Side-scoped extent accessors on the layout items (label1/label2, tick1line/tick2line via side_artists); top_space/right_space gain axis attrs and the side-spaces read the scoped accessors + side-scoped margins; per-side axis-title placement; tick text justified into the band past the panel's far edge. Anchor top/right titles at the band's panel edge (y1/x1, not y2/x2, which cropped them) and align them across compositions via axis_title_alignment, matching bottom/left. Default plots are byte-identical. --- plotnine/_mpl/layout_manager/_layout_tree.py | 7 +- .../_mpl/layout_manager/_plot_layout_items.py | 227 ++++++++++++------ .../_mpl/layout_manager/_plot_side_space.py | 86 +++++-- tests/test_axis_position.py | 49 ++++ 4 files changed, 278 insertions(+), 91 deletions(-) create mode 100644 tests/test_axis_position.py diff --git a/plotnine/_mpl/layout_manager/_layout_tree.py b/plotnine/_mpl/layout_manager/_layout_tree.py index e78d36fb16..c69d55d0ac 100644 --- a/plotnine/_mpl/layout_manager/_layout_tree.py +++ b/plotnine/_mpl/layout_manager/_layout_tree.py @@ -552,7 +552,12 @@ def align_axis_titles(self): def axis_title_clearance(s): return s.axis_title_clearance - for spaces in [self.bottom_spaces, self.left_spaces]: + for spaces in [ + self.bottom_spaces, + self.left_spaces, + self.top_spaces, + self.right_spaces, + ]: _align(spaces, axis_title_clearance, "axis_title_alignment") for tree in self.sub_compositions: diff --git a/plotnine/_mpl/layout_manager/_plot_layout_items.py b/plotnine/_mpl/layout_manager/_plot_layout_items.py index 86fab62e85..df42b2c323 100644 --- a/plotnine/_mpl/layout_manager/_plot_layout_items.py +++ b/plotnine/_mpl/layout_manager/_plot_layout_items.py @@ -6,6 +6,7 @@ from matplotlib.text import Text from plotnine._mpl.patches import StripTextPatch +from plotnine._utils import side_artists from plotnine.composition._compose import Compose from plotnine.exceptions import PlotnineError @@ -89,6 +90,10 @@ def get(name: str) -> Any: self.axis_title_x: Text | None = get("axis_title_x") self.axis_title_y: Text | None = get("axis_title_y") + self.axis_title_x_bottom: Text | None = get("axis_title_x_bottom") + self.axis_title_x_top: Text | None = get("axis_title_x_top") + self.axis_title_y_left: Text | None = get("axis_title_y_left") + self.axis_title_y_right: Text | None = get("axis_title_y_right") # # The legends references the structure that contains the # # AnchoredOffsetboxes (groups of legends) @@ -126,9 +131,9 @@ def _filter_axes(self, location: AxesLocation = "all") -> list[Axes]: if getattr(spec, pred_method)() ] - def axis_text_x(self, ax: Axes) -> Iterator[Text]: + def axis_text_x(self, ax: Axes, side: str) -> Iterator[Text]: """ - Return all x-axis labels for an axes that will be shown + Return the visible x-axis labels on one side of an axes """ major, minor = [], [] @@ -136,15 +141,16 @@ def axis_text_x(self, ax: Axes) -> Iterator[Text]: major = ax.xaxis.get_major_ticks() minor = ax.xaxis.get_minor_ticks() + label_attr = side_artists(side)[1] return ( - tick.label1 + getattr(tick, label_attr) for tick in chain(major, minor) - if _text_is_visible(tick.label1) + if _text_is_visible(getattr(tick, label_attr)) ) - def axis_text_y(self, ax: Axes) -> Iterator[Text]: + def axis_text_y(self, ax: Axes, side: str) -> Iterator[Text]: """ - Return all y-axis labels for an axes that will be shown + Return the visible y-axis labels on one side of an axes """ major, minor = [], [] @@ -152,10 +158,11 @@ def axis_text_y(self, ax: Axes) -> Iterator[Text]: major = ax.yaxis.get_major_ticks() minor = ax.yaxis.get_minor_ticks() + label_attr = side_artists(side)[1] return ( - tick.label1 + getattr(tick, label_attr) for tick in chain(major, minor) - if _text_is_visible(tick.label1) + if _text_is_visible(getattr(tick, label_attr)) ) def axis_ticks_x(self, ax: Axes) -> Iterator[Tick]: @@ -238,66 +245,114 @@ def strip_text_y_extra_width(self, position: StripPosition) -> float: return max(widths) - def axis_ticks_x_max_height_at(self, location: AxesLocation) -> float: + def axis_ticks_x_max_height_at( + self, location: AxesLocation, side: str + ) -> float: """ - Return maximum height[figure space] of x ticks + Return maximum height[figure space] of visible x ticks on a side """ + attr = side_artists(side)[0] heights = [ - self.geometry.tight_height(tick.tick1line) + self.geometry.tight_height(getattr(tick, attr)) for ax in self._filter_axes(location) for tick in self.axis_ticks_x(ax) + if getattr(tick, attr).get_visible() ] return max(heights) if len(heights) else 0 - def axis_text_x_max_height(self, ax: Axes) -> float: + def axis_text_x_max_height(self, ax: Axes, side: str) -> float: """ - Return maximum height[figure space] of x tick labels + Return maximum height[figure space] of x tick labels on a side """ heights = [ - self.geometry.tight_height(label) for label in self.axis_text_x(ax) + self.geometry.tight_height(label) + for label in self.axis_text_x(ax, side) ] return max(heights) if len(heights) else 0 - def axis_text_x_max_height_at(self, location: AxesLocation) -> float: + def axis_text_x_max_height_at( + self, location: AxesLocation, side: str + ) -> float: """ - Return maximum height[figure space] of x tick labels + Return maximum height[figure space] of x tick labels on a side """ heights = [ - self.axis_text_x_max_height(ax) + self.axis_text_x_max_height(ax, side) for ax in self._filter_axes(location) ] return max(heights) if len(heights) else 0 - def axis_ticks_y_max_width_at(self, location: AxesLocation) -> float: + def axis_ticks_y_max_width_at( + self, location: AxesLocation, side: str + ) -> float: """ - Return maximum width[figure space] of y ticks + Return maximum width[figure space] of visible y ticks on a side """ + attr = side_artists(side)[0] widths = [ - self.geometry.tight_width(tick.tick1line) + self.geometry.tight_width(getattr(tick, attr)) for ax in self._filter_axes(location) for tick in self.axis_ticks_y(ax) + if getattr(tick, attr).get_visible() ] return max(widths) if len(widths) else 0 - def axis_text_y_max_width(self, ax: Axes) -> float: + def axis_text_y_max_width(self, ax: Axes, side: str) -> float: """ - Return maximum width[figure space] of y tick labels + Return maximum width[figure space] of y tick labels on a side """ widths = [ - self.geometry.tight_width(label) for label in self.axis_text_y(ax) + self.geometry.tight_width(label) + for label in self.axis_text_y(ax, side) ] return max(widths) if len(widths) else 0 - def axis_text_y_max_width_at(self, location: AxesLocation) -> float: + def axis_text_y_max_width_at( + self, location: AxesLocation, side: str + ) -> float: """ - Return maximum width[figure space] of y tick labels + Return maximum width[figure space] of y tick labels on a side """ widths = [ - self.axis_text_y_max_width(ax) + self.axis_text_y_max_width(ax, side) for ax in self._filter_axes(location) ] return max(widths) if len(widths) else 0 + # Side-scoped extents — each names a concrete edge (side picks the + # artist, location picks the panels) and reads 0 when no axis is there. + @property + def axis_text_x_bottom(self) -> float: + return self.axis_text_x_max_height_at("last_row", "bottom") + + @property + def axis_text_x_top(self) -> float: + return self.axis_text_x_max_height_at("first_row", "top") + + @property + def axis_text_y_left(self) -> float: + return self.axis_text_y_max_width_at("first_col", "left") + + @property + def axis_text_y_right(self) -> float: + return self.axis_text_y_max_width_at("last_col", "right") + + @property + def axis_ticks_x_bottom(self) -> float: + return self.axis_ticks_x_max_height_at("last_row", "bottom") + + @property + def axis_ticks_x_top(self) -> float: + return self.axis_ticks_x_max_height_at("first_row", "top") + + @property + def axis_ticks_y_left(self) -> float: + return self.axis_ticks_y_max_width_at("first_col", "left") + + @property + def axis_ticks_y_right(self) -> float: + return self.axis_ticks_y_max_width_at("last_col", "right") + def axis_text_y_top_protrusion(self, location: AxesLocation) -> float: """ Return maximum height[figure space] above the axes of y tick labels @@ -305,9 +360,10 @@ def axis_text_y_top_protrusion(self, location: AxesLocation) -> float: extras = [] for ax in self._filter_axes(location): ax_top_y = self.geometry.top_y(ax) - for label in self.axis_text_y(ax): - label_top_y = self.geometry.top_y(label) - extras.append(max(0, label_top_y - ax_top_y)) + for side in ("left", "right"): + for label in self.axis_text_y(ax, side): + label_top_y = self.geometry.top_y(label) + extras.append(max(0, label_top_y - ax_top_y)) return max(extras) if len(extras) else 0 @@ -318,10 +374,11 @@ def axis_text_y_bottom_protrusion(self, location: AxesLocation) -> float: extras = [] for ax in self._filter_axes(location): ax_bottom_y = self.geometry.bottom_y(ax) - for label in self.axis_text_y(ax): - label_bottom_y = self.geometry.bottom_y(label) - protrusion = abs(min(label_bottom_y - ax_bottom_y, 0)) - extras.append(protrusion) + for side in ("left", "right"): + for label in self.axis_text_y(ax, side): + label_bottom_y = self.geometry.bottom_y(label) + protrusion = abs(min(label_bottom_y - ax_bottom_y, 0)) + extras.append(protrusion) return max(extras) if len(extras) else 0 @@ -332,10 +389,11 @@ def axis_text_x_left_protrusion(self, location: AxesLocation) -> float: extras = [] for ax in self._filter_axes(location): ax_left_x = self.geometry.left_x(ax) - for label in self.axis_text_x(ax): - label_left_x = self.geometry.left_x(label) - protrusion = abs(min(label_left_x - ax_left_x, 0)) - extras.append(protrusion) + for side in ("bottom", "top"): + for label in self.axis_text_x(ax, side): + label_left_x = self.geometry.left_x(label) + protrusion = abs(min(label_left_x - ax_left_x, 0)) + extras.append(protrusion) return max(extras) if len(extras) else 0 @@ -346,9 +404,10 @@ def axis_text_x_right_protrusion(self, location: AxesLocation) -> float: extras = [] for ax in self._filter_axes(location): ax_right_x = self.geometry.right_x(ax) - for label in self.axis_text_x(ax): - label_right_x = self.geometry.right_x(label) - extras.append(max(0, label_right_x - ax_right_x)) + for side in ("bottom", "top"): + for label in self.axis_text_x(ax, side): + label_right_x = self.geometry.right_x(label) + extras.append(max(0, label_right_x - ax_right_x)) return max(extras) if len(extras) else 0 @@ -364,15 +423,25 @@ def _move_artists(self, spaces: PlotSideSpaces): if self.plot_tag: set_plot_tag_position(self.plot_tag, spaces) - if self.axis_title_x: - ha = theme.getp(("axis_title_x", "ha"), "center") - self.axis_title_x.set_y(spaces.b.y1("axis_title_x")) - justify.horizontally_about(self.axis_title_x, ha, "panel") + if self.axis_title_x_bottom: + ha = theme.getp(("axis_title_x_bottom", "ha"), "center") + self.axis_title_x_bottom.set_y(spaces.b.y1("axis_title_x")) + justify.horizontally_about(self.axis_title_x_bottom, ha, "panel") + + if self.axis_title_x_top: + ha = theme.getp(("axis_title_x_top", "ha"), "center") + self.axis_title_x_top.set_y(spaces.t.y1("axis_title_x")) + justify.horizontally_about(self.axis_title_x_top, ha, "panel") - if self.axis_title_y: - va = theme.getp(("axis_title_y", "va"), "center") - self.axis_title_y.set_x(spaces.l.x1("axis_title_y")) - justify.vertically_about(self.axis_title_y, va, "panel") + if self.axis_title_y_left: + va = theme.getp(("axis_title_y_left", "va"), "center") + self.axis_title_y_left.set_x(spaces.l.x1("axis_title_y")) + justify.vertically_about(self.axis_title_y_left, va, "panel") + + if self.axis_title_y_right: + va = theme.getp(("axis_title_y_right", "va"), "center") + self.axis_title_y_right.set_x(spaces.r.x1("axis_title_y")) + justify.vertically_about(self.axis_title_y_right, va, "panel") if self.legends: set_legends_position(self.legends, spaces) @@ -398,20 +467,30 @@ def to_vertical_axis_dimensions(value: float, ax: Axes) -> float: if self._is_blank("axis_text_x"): return - va = self.plot.theme.getp(("axis_text_x", "va"), "top") - - for ax in self.plot.axs: - texts = list(self.axis_text_x(ax)) - axis_text_row_height = to_vertical_axis_dimensions( - self.axis_text_x_max_height(ax), ax + for side in ("bottom", "top"): + va_default = "top" if side == "bottom" else "bottom" + va = self.plot.theme.getp( + (f"axis_text_x_{side}", "va"), va_default ) - for text in texts: - height = to_vertical_axis_dimensions( - self.geometry.tight_height(text), ax + for ax in self.plot.axs: + texts = list(self.axis_text_x(ax, side)) + if not texts: + continue + row_height = to_vertical_axis_dimensions( + self.axis_text_x_max_height(ax, side), ax ) - justify.vertically( - text, va, -axis_text_row_height, 0, height=height + # bottom labels sit below the panel (axes y 0), top labels + # above it (axes y 1) + low, high = ( + (-row_height, 0) + if side == "bottom" + else (1, 1 + row_height) ) + for text in texts: + height = to_vertical_axis_dimensions( + self.geometry.tight_height(text), ax + ) + justify.vertically(text, va, low, high, height=height) def _adjust_axis_text_y(self, justify: TextJustifier): """ @@ -451,20 +530,28 @@ def to_horizontal_axis_dimensions(value: float, ax: Axes) -> float: if self._is_blank("axis_text_y"): return - ha = self.plot.theme.getp(("axis_text_y", "ha"), "right") - - for ax in self.plot.axs: - texts = list(self.axis_text_y(ax)) - axis_text_col_width = to_horizontal_axis_dimensions( - self.axis_text_y_max_width(ax), ax + for side in ("left", "right"): + ha_default = "right" if side == "left" else "left" + ha = self.plot.theme.getp( + (f"axis_text_y_{side}", "ha"), ha_default ) - for text in texts: - width = to_horizontal_axis_dimensions( - self.geometry.tight_width(text), ax + for ax in self.plot.axs: + texts = list(self.axis_text_y(ax, side)) + if not texts: + continue + col_width = to_horizontal_axis_dimensions( + self.axis_text_y_max_width(ax, side), ax ) - justify.horizontally( - text, ha, -axis_text_col_width, 0, width=width + # left labels sit left of the panel (axes x 0), right labels + # to the right of it (axes x 1) + low, high = ( + (-col_width, 0) if side == "left" else (1, 1 + col_width) ) + for text in texts: + width = to_horizontal_axis_dimensions( + self.geometry.tight_width(text), ax + ) + justify.horizontally(text, ha, low, high, width=width) def _strip_text_x_background_equal_heights(self): """ diff --git a/plotnine/_mpl/layout_manager/_plot_side_space.py b/plotnine/_mpl/layout_manager/_plot_side_space.py index 01048298a4..33aa81050f 100644 --- a/plotnine/_mpl/layout_manager/_plot_side_space.py +++ b/plotnine/_mpl/layout_manager/_plot_side_space.py @@ -229,20 +229,20 @@ def _calculate(self): self.legend = self.legend_width self.legend_box_spacing = theme.getp("legend_box_spacing") - if items.axis_title_y: - m = theme.get_margin("axis_title_y").fig + if items.axis_title_y_left: + m = theme.get_margin("axis_title_y_left").fig self.axis_title_y_margin_left = m.l - self.axis_title_y = geometry.width(items.axis_title_y) + self.axis_title_y = geometry.width(items.axis_title_y_left) self.axis_title_y_margin_right = m.r - # Account for the space consumed by the axis - self.axis_text_y = items.axis_text_y_max_width_at("first_col") + # Account for the space consumed by the left axis + self.axis_text_y = items.axis_text_y_left if self.axis_text_y: - m = theme.get_margin("axis_text_y").fig + m = theme.get_margin("axis_text_y_left").fig self.axis_text_y_margin_left = m.l self.axis_text_y_margin_right = m.r - self.axis_ticks_y = items.axis_ticks_y_max_width_at("first_col") + self.axis_ticks_y = items.axis_ticks_y_left # Adjust plot_margin to make room for ylabels that protude well # beyond the axes @@ -329,6 +329,14 @@ class right_space(_plot_side_space): legend: float = 0 legend_box_spacing: float = 0 strip_text_y_extra_width: float = 0 + axis_title_y_margin_right: float = 0 + axis_title_y: float = 0 + axis_title_y_margin_left: float = 0 + axis_title_alignment: float = 0 + axis_text_y_margin_right: float = 0 + axis_text_y: float = 0 + axis_text_y_margin_left: float = 0 + axis_ticks_y: float = 0 def _calculate(self): items = self.items @@ -349,6 +357,20 @@ def _calculate(self): self.strip_text_y_extra_width = items.strip_text_y_extra_width("right") + # Space consumed by a y-axis on the right + if items.axis_title_y_right: + m = theme.get_margin("axis_title_y_right").fig + self.axis_title_y_margin_right = m.r + self.axis_title_y = geometry.width(items.axis_title_y_right) + self.axis_title_y_margin_left = m.l + + self.axis_text_y = items.axis_text_y_right + if self.axis_text_y: + m = theme.get_margin("axis_text_y_right").fig + self.axis_text_y_margin_right = m.r + self.axis_text_y_margin_left = m.l + self.axis_ticks_y = items.axis_ticks_y_right + # Adjust plot_margin to make room for ylabels that protude well # beyond the axes # NOTE: This adjustment breaks down when the protrusion is large @@ -440,6 +462,14 @@ class top_space(_plot_side_space): legend: float = 0 legend_box_spacing: float = 0 strip_text_x_extra_height: float = 0 + axis_title_x_margin_top: float = 0 + axis_title_x: float = 0 + axis_title_x_margin_bottom: float = 0 + axis_title_alignment: float = 0 + axis_text_x_margin_top: float = 0 + axis_text_x: float = 0 + axis_text_x_margin_bottom: float = 0 + axis_ticks_x: float = 0 def _calculate(self): items = self.items @@ -474,6 +504,20 @@ def _calculate(self): self.strip_text_x_extra_height = items.strip_text_x_extra_height("top") + # Space consumed by an x-axis on the top + if items.axis_title_x_top: + m = theme.get_margin("axis_title_x_top").fig + self.axis_title_x_margin_top = m.t + self.axis_title_x = geometry.height(items.axis_title_x_top) + self.axis_title_x_margin_bottom = m.b + + self.axis_text_x = items.axis_text_x_top + if self.axis_text_x: + m = theme.get_margin("axis_text_x_top").fig + self.axis_text_x_margin_top = m.t + self.axis_text_x_margin_bottom = m.b + self.axis_ticks_x = items.axis_ticks_x_top + # Adjust plot_margin to make room for ylabels that protude well # beyond the axes # NOTE: This adjustment breaks down when the protrusion is large @@ -615,19 +659,19 @@ def _calculate(self): self.legend = self.legend_height self.legend_box_spacing = theme.getp("legend_box_spacing") * F - if items.axis_title_x: - m = theme.get_margin("axis_title_x").fig + if items.axis_title_x_bottom: + m = theme.get_margin("axis_title_x_bottom").fig self.axis_title_x_margin_bottom = m.b - self.axis_title_x = geometry.height(items.axis_title_x) + self.axis_title_x = geometry.height(items.axis_title_x_bottom) self.axis_title_x_margin_top = m.t - # Account for the space consumed by the axis - self.axis_text_x = items.axis_text_x_max_height_at("last_row") + # Account for the space consumed by the bottom axis + self.axis_text_x = items.axis_text_x_bottom if self.axis_text_x: - m = theme.get_margin("axis_text_x").fig + m = theme.get_margin("axis_text_x_bottom").fig self.axis_text_x_margin_bottom = m.b self.axis_text_x_margin_top = m.t - self.axis_ticks_x = items.axis_ticks_x_max_height_at("last_row") + self.axis_ticks_x = items.axis_ticks_x_bottom # Adjust plot_margin to make room for ylabels that protude well # beyond the axes @@ -1082,13 +1126,15 @@ def _calculate_panel_spacing_facet_wrap(self) -> tuple[float, float]: self.sh += self.t.strip_text_x_extra_height * (1 + strip_align_x) if facet.free["x"]: - self.sh += self.items.axis_text_x_max_height_at( - "all" - ) + self.items.axis_ticks_x_max_height_at("all") + for side in ("bottom", "top"): + self.sh += self.items.axis_text_x_max_height_at( + "all", side + ) + self.items.axis_ticks_x_max_height_at("all", side) if facet.free["y"]: - self.sw += self.items.axis_text_y_max_width_at( - "all" - ) + self.items.axis_ticks_y_max_width_at("all") + for side in ("left", "right"): + self.sw += self.items.axis_text_y_max_width_at( + "all", side + ) + self.items.axis_ticks_y_max_width_at("all", side) # width and height of axes as fraction of figure width & height self.w = (self.panel_width - self.sw * (ncol - 1)) / ncol diff --git a/tests/test_axis_position.py b/tests/test_axis_position.py new file mode 100644 index 0000000000..e20c3cfa68 --- /dev/null +++ b/tests/test_axis_position.py @@ -0,0 +1,49 @@ +from plotnine import ( + aes, + coord_flip, + facet_wrap, + geom_point, + ggplot, + scale_x_continuous, + scale_x_discrete, + scale_y_continuous, +) +from plotnine.data import mtcars + +p0 = ggplot(mtcars, aes("wt", "mpg")) + geom_point() + + +def test_x_axis_top_continuous(): + p = p0 + scale_x_continuous(position="top") + assert p == "x_axis_top_continuous" + + +def test_y_axis_right_continuous(): + p = p0 + scale_y_continuous(position="right") + assert p == "y_axis_right_continuous" + + +def test_coord_flip_x_top(): + # Before flipping, the y-axis is on the non-default side, + # after flipping the x-axis will be on the non-default side. + p = p0 + scale_y_continuous(position="right") + coord_flip() + assert p == "coord_flip_x_top" + + +def test_facet_wrap_y_right(): + p = ( + ggplot(mtcars, aes("wt", "mpg")) + + geom_point() + + facet_wrap("gear") + + scale_y_continuous(position="right") + ) + assert p == "facet_wrap_y_right" + + +def test_x_axis_top_discrete(): + p = ( + ggplot(mtcars, aes("factor(cyl)", "mpg")) + + geom_point() + + scale_x_discrete(position="top") + ) + assert p == "x_axis_top_discrete" From 78434dc8b370fd06830fb2fc483be5fb03bc5482 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 22 Jun 2026 12:26:05 +0300 Subject: [PATCH 07/19] feat(theme): use the geometric panel-facing margin for moved axes The axis text/title gap is the single margin between the text and the panel. Each side reads the margin edge that actually faces the panel: top for a bottom axis, bottom for a top axis, right for a left axis, left for a right axis. The value is read from the side-scoped themeable (axis_text_x_top, axis_title_y_right, ...), which cascades to the parent by default, so a user can override one side's margin without touching the others. The themes (gray, matplotlib, seaborn) set both edges of the axis text/title margins so every position has a gap. Docstrings updated to match. Default plots unchanged. --- .../_mpl/layout_manager/_plot_side_space.py | 69 ++++++----- plotnine/coords/coord.py | 24 ++-- plotnine/themes/theme_gray.py | 12 +- plotnine/themes/theme_matplotlib.py | 7 +- plotnine/themes/theme_seaborn.py | 4 +- plotnine/themes/themeable.py | 111 ++++++++++++++++-- 6 files changed, 159 insertions(+), 68 deletions(-) diff --git a/plotnine/_mpl/layout_manager/_plot_side_space.py b/plotnine/_mpl/layout_manager/_plot_side_space.py index 33aa81050f..134eba4391 100644 --- a/plotnine/_mpl/layout_manager/_plot_side_space.py +++ b/plotnine/_mpl/layout_manager/_plot_side_space.py @@ -196,7 +196,6 @@ class left_space(_plot_side_space): """ legend: float = 0 legend_box_spacing: float = 0 - axis_title_y_margin_left: float = 0 axis_title_y: float = 0 axis_title_y_margin_right: float = 0 axis_title_alignment: float = 0 @@ -207,7 +206,6 @@ class left_space(_plot_side_space): the difference between the largest and smallest axis_title_clearance among the items in the composition. """ - axis_text_y_margin_left: float = 0 axis_text_y: float = 0 axis_text_y_margin_right: float = 0 axis_ticks_y: float = 0 @@ -229,18 +227,19 @@ def _calculate(self): self.legend = self.legend_width self.legend_box_spacing = theme.getp("legend_box_spacing") + # The text<->panel gap is the right margin of the y text/title; it + # sits on the panel-facing (right) side of the left axis. if items.axis_title_y_left: - m = theme.get_margin("axis_title_y_left").fig - self.axis_title_y_margin_left = m.l self.axis_title_y = geometry.width(items.axis_title_y_left) - self.axis_title_y_margin_right = m.r + self.axis_title_y_margin_right = theme.get_margin( + "axis_title_y_left" + ).fig.r - # Account for the space consumed by the left axis self.axis_text_y = items.axis_text_y_left if self.axis_text_y: - m = theme.get_margin("axis_text_y_left").fig - self.axis_text_y_margin_left = m.l - self.axis_text_y_margin_right = m.r + self.axis_text_y_margin_right = theme.get_margin( + "axis_text_y_left" + ).fig.r self.axis_ticks_y = items.axis_ticks_y_left @@ -329,11 +328,9 @@ class right_space(_plot_side_space): legend: float = 0 legend_box_spacing: float = 0 strip_text_y_extra_width: float = 0 - axis_title_y_margin_right: float = 0 axis_title_y: float = 0 axis_title_y_margin_left: float = 0 axis_title_alignment: float = 0 - axis_text_y_margin_right: float = 0 axis_text_y: float = 0 axis_text_y_margin_left: float = 0 axis_ticks_y: float = 0 @@ -357,18 +354,20 @@ def _calculate(self): self.strip_text_y_extra_width = items.strip_text_y_extra_width("right") - # Space consumed by a y-axis on the right + # Space consumed by a y-axis on the right. The text<->panel gap is the + # left margin of the y text/title (the edge facing the panel to the + # left). if items.axis_title_y_right: - m = theme.get_margin("axis_title_y_right").fig - self.axis_title_y_margin_right = m.r self.axis_title_y = geometry.width(items.axis_title_y_right) - self.axis_title_y_margin_left = m.l + self.axis_title_y_margin_left = theme.get_margin( + "axis_title_y_right" + ).fig.l self.axis_text_y = items.axis_text_y_right if self.axis_text_y: - m = theme.get_margin("axis_text_y_right").fig - self.axis_text_y_margin_right = m.r - self.axis_text_y_margin_left = m.l + self.axis_text_y_margin_left = theme.get_margin( + "axis_text_y_right" + ).fig.l self.axis_ticks_y = items.axis_ticks_y_right # Adjust plot_margin to make room for ylabels that protude well @@ -462,11 +461,9 @@ class top_space(_plot_side_space): legend: float = 0 legend_box_spacing: float = 0 strip_text_x_extra_height: float = 0 - axis_title_x_margin_top: float = 0 axis_title_x: float = 0 axis_title_x_margin_bottom: float = 0 axis_title_alignment: float = 0 - axis_text_x_margin_top: float = 0 axis_text_x: float = 0 axis_text_x_margin_bottom: float = 0 axis_ticks_x: float = 0 @@ -504,18 +501,19 @@ def _calculate(self): self.strip_text_x_extra_height = items.strip_text_x_extra_height("top") - # Space consumed by an x-axis on the top + # Space consumed by an x-axis on the top. The text<->panel gap is the + # bottom margin of the x text/title (the edge facing the panel below). if items.axis_title_x_top: - m = theme.get_margin("axis_title_x_top").fig - self.axis_title_x_margin_top = m.t self.axis_title_x = geometry.height(items.axis_title_x_top) - self.axis_title_x_margin_bottom = m.b + self.axis_title_x_margin_bottom = theme.get_margin( + "axis_title_x_top" + ).fig.b self.axis_text_x = items.axis_text_x_top if self.axis_text_x: - m = theme.get_margin("axis_text_x_top").fig - self.axis_text_x_margin_top = m.t - self.axis_text_x_margin_bottom = m.b + self.axis_text_x_margin_bottom = theme.get_margin( + "axis_text_x_top" + ).fig.b self.axis_ticks_x = items.axis_ticks_x_top # Adjust plot_margin to make room for ylabels that protude well @@ -611,7 +609,6 @@ class bottom_space(_plot_side_space): plot_caption_margin_top: float = 0 legend: float = 0 legend_box_spacing: float = 0 - axis_title_x_margin_bottom: float = 0 axis_title_x: float = 0 axis_title_x_margin_top: float = 0 axis_title_alignment: float = 0 @@ -623,7 +620,6 @@ class bottom_space(_plot_side_space): composition. It's amount is the difference in height between this axis text (and it's margins) and the tallest axis text (and it's margin). """ - axis_text_x_margin_bottom: float = 0 axis_text_x: float = 0 axis_text_x_margin_top: float = 0 axis_ticks_x: float = 0 @@ -659,18 +655,19 @@ def _calculate(self): self.legend = self.legend_height self.legend_box_spacing = theme.getp("legend_box_spacing") * F + # The text<->panel gap is the top margin of the x text/title; it + # sits on the panel-facing (top) side of the bottom axis. if items.axis_title_x_bottom: - m = theme.get_margin("axis_title_x_bottom").fig - self.axis_title_x_margin_bottom = m.b self.axis_title_x = geometry.height(items.axis_title_x_bottom) - self.axis_title_x_margin_top = m.t + self.axis_title_x_margin_top = theme.get_margin( + "axis_title_x_bottom" + ).fig.t - # Account for the space consumed by the bottom axis self.axis_text_x = items.axis_text_x_bottom if self.axis_text_x: - m = theme.get_margin("axis_text_x_bottom").fig - self.axis_text_x_margin_bottom = m.b - self.axis_text_x_margin_top = m.t + self.axis_text_x_margin_top = theme.get_margin( + "axis_text_x_bottom" + ).fig.t self.axis_ticks_x = items.axis_ticks_x_bottom # Adjust plot_margin to make room for ylabels that protude well diff --git a/plotnine/coords/coord.py b/plotnine/coords/coord.py index 97cb9581fa..0dd444b2ac 100644 --- a/plotnine/coords/coord.py +++ b/plotnine/coords/coord.py @@ -209,18 +209,20 @@ def _inf_to_none( ax.spines["right"].set_visible(y_pos == "right") ax.spines["left"].set_visible(y_pos == "left") - # Tick pad from the active side's tick-text margin. The inner edge - # of the margin faces the panel: x-bottom uses the top margin, - # x-top the bottom; y-left uses the right margin, y-right the left. - # Blank axis text is not drawn, so its margin may be absent; skip - # the padding in that case. - if not theme.T.is_blank("axis_text_x"): - m = theme.get_margin("axis_text_x").pt - pad_x = m.b if x_pos == "top" else m.t + # Tick pad is the text<->panel gap: the margin edge of the tick text + # that faces the panel (x-bottom -> top, x-top -> bottom; y-left -> + # right, y-right -> left), read from the side-scoped themeable. Blank + # axis text is not drawn, so its margin may be absent; skip the + # padding in that case. + x_text = f"axis_text_x_{x_pos}" + y_text = f"axis_text_y_{y_pos}" + if not theme.T.is_blank(x_text): + m = theme.get_margin(x_text).pt + pad_x = m.t if x_pos == "bottom" else m.b ax.tick_params(axis="x", which="major", pad=pad_x) - if not theme.T.is_blank("axis_text_y"): - m = theme.get_margin("axis_text_y").pt - pad_y = m.l if y_pos == "right" else m.r + if not theme.T.is_blank(y_text): + m = theme.get_margin(y_text).pt + pad_y = m.r if y_pos == "left" else m.l ax.tick_params(axis="y", which="major", pad=pad_y) def labels(self, cur_labels: labels_view) -> labels_view: diff --git a/plotnine/themes/theme_gray.py b/plotnine/themes/theme_gray.py index 1511bb4bcf..fc150980c0 100644 --- a/plotnine/themes/theme_gray.py +++ b/plotnine/themes/theme_gray.py @@ -56,21 +56,25 @@ def __init__(self, base_size=11, base_family=None): axis_line_x=element_blank(), axis_line_y=element_blank(), axis_text=element_text(size=base_size * 0.8, color="#4D4D4D"), - axis_text_x=element_text(va="top", margin=margin(t=fifth_line)), - axis_text_y=element_text(ha="right", margin=margin(r=fifth_line)), + axis_text_x=element_text( + va="top", margin=margin(t=fifth_line, b=fifth_line) + ), + axis_text_y=element_text( + ha="right", margin=margin(r=fifth_line, l=fifth_line) + ), axis_ticks=element_line(color="#333333"), axis_ticks_length=0, axis_ticks_length_major=quarter_line, axis_ticks_length_minor=eighth_line, axis_ticks_minor=element_blank(), axis_title_x=element_text( - va="bottom", ha="center", margin=margin(t=m, unit="fig") + va="bottom", ha="center", margin=margin(t=m, b=m, unit="fig") ), axis_title_y=element_text( angle=90, va="center", ha="left", - margin=margin(r=m, unit="fig"), + margin=margin(r=m, l=m, unit="fig"), ), dpi=get_option("dpi"), figure_size=get_option("figure_size"), diff --git a/plotnine/themes/theme_matplotlib.py b/plotnine/themes/theme_matplotlib.py index 3d474bf72e..4cf3c08df6 100644 --- a/plotnine/themes/theme_matplotlib.py +++ b/plotnine/themes/theme_matplotlib.py @@ -48,17 +48,18 @@ def __init__(self, rc=None, fname=None, use_defaults=True): ), aspect_ratio=get_option("aspect_ratio"), axis_text=element_text( - size=base_size * 0.8, margin=margin(t=2.4, r=2.4, unit="pt") + size=base_size * 0.8, + margin=margin(t=2.4, b=2.4, r=2.4, l=2.4, unit="pt"), ), axis_title_x=element_text( - va="bottom", ha="center", margin=margin(t=m, unit="fig") + va="bottom", ha="center", margin=margin(t=m, b=m, unit="fig") ), axis_line=element_blank(), axis_title_y=element_text( angle=90, va="center", ha="left", - margin=margin(r=m, unit="fig"), + margin=margin(r=m, l=m, unit="fig"), ), dpi=get_option("dpi"), figure_size=get_option("figure_size"), diff --git a/plotnine/themes/theme_seaborn.py b/plotnine/themes/theme_seaborn.py index 2fe71e86dc..a617ba50d9 100644 --- a/plotnine/themes/theme_seaborn.py +++ b/plotnine/themes/theme_seaborn.py @@ -65,13 +65,13 @@ def __init__( ), ), axis_title_x=element_text( - va="bottom", ha="center", margin=margin(t=m, unit="fig") + va="bottom", ha="center", margin=margin(t=m, b=m, unit="fig") ), axis_title_y=element_text( angle=90, va="center", ha="left", - margin=margin(r=m, unit="fig"), + margin=margin(r=m, l=m, unit="fig"), ), legend_box_margin=0, legend_box_spacing=m * 3, # figure units diff --git a/plotnine/themes/themeable.py b/plotnine/themes/themeable.py index 3f1048ab84..982024f163 100644 --- a/plotnine/themes/themeable.py +++ b/plotnine/themes/themeable.py @@ -527,6 +527,15 @@ def blend_alpha( class axis_title_x_bottom(themeable): """ x axis label on the bottom + + Parameters + ---------- + theme_element : element_text + + Notes + ----- + The gap to the panel is set by the top margin (`t`), as for any + x-axis title; the other margins are ignored. """ def apply_figure(self, figure: Figure, targets: ThemeTargets): @@ -544,6 +553,15 @@ def blank_figure(self, figure: Figure, targets: ThemeTargets): class axis_title_x_top(themeable): """ x axis label on the top + + Parameters + ---------- + theme_element : element_text + + Notes + ----- + The gap to the panel is set by the bottom margin (`b`) — the edge + that faces the panel below; the other margins are ignored. """ def apply_figure(self, figure: Figure, targets: ThemeTargets): @@ -564,12 +582,28 @@ class axis_title_x(axis_title_x_top, axis_title_x_bottom): Parameters ---------- theme_element : element_text + + Notes + ----- + Only the margin on the side that faces the panel has an effect: + the top margin (`t`) when the axis is on the bottom, the bottom + margin (`b`) when it is on the top. Set both to cover either + position. """ class axis_title_y_left(themeable): """ y axis label on the left + + Parameters + ---------- + theme_element : element_text + + Notes + ----- + The gap to the panel is set by the right margin (`r`), as for any + y-axis title; the other margins are ignored. """ def apply_figure(self, figure: Figure, targets: ThemeTargets): @@ -587,6 +621,15 @@ def blank_figure(self, figure: Figure, targets: ThemeTargets): class axis_title_y_right(themeable): """ y axis label on the right + + Parameters + ---------- + theme_element : element_text + + Notes + ----- + The gap to the panel is set by the left margin (`l`) — the edge + that faces the panel to the left; the other margins are ignored. """ def apply_figure(self, figure: Figure, targets: ThemeTargets): @@ -607,6 +650,13 @@ class axis_title_y(axis_title_y_left, axis_title_y_right): Parameters ---------- theme_element : element_text + + Notes + ----- + Only the margin on the side that faces the panel has an effect: + the right margin (`r`) when the axis is on the left, the left + margin (`l`) when it is on the right. Set both to cover either + position. """ @@ -617,6 +667,14 @@ class axis_title(axis_title_x, axis_title_y): Parameters ---------- theme_element : element_text + + Notes + ----- + Only the margin on the side that faces the panel has an effect. + For the x-axis that is the top margin (`t`) on the bottom or the + bottom margin (`b`) on the top; for the y-axis the right margin + (`r`) on the left or the left margin (`l`) on the right. Set both + margins of each axis to cover either position. """ @@ -999,6 +1057,11 @@ class axis_text_x_bottom(MixinSequenceOfValues): Parameters ---------- theme_element : element_text + + Notes + ----- + The gap to the panel is set by the top margin (`t`), as for any + x-axis text; the other margins are ignored. """ def apply_ax(self, ax: Axes): @@ -1023,6 +1086,11 @@ class axis_text_x_top(MixinSequenceOfValues): Parameters ---------- theme_element : element_text + + Notes + ----- + The gap to the panel is set by the bottom margin (`b`) — the edge + that faces the panel below; the other margins are ignored. """ def apply_ax(self, ax: Axes): @@ -1048,14 +1116,16 @@ class axis_text_x(axis_text_x_top, axis_text_x_bottom): Notes ----- - Use the `margin` to control the gap between the ticks and the - text. e.g. + Only the margin on the side that faces the panel has an effect: + the top margin (`t`) when the axis is on the bottom, the bottom + margin (`b`) when it is on the top. Set both to cover either + position. e.g. ```python - theme(axis_text_x=element_text(margin={"t": 5, "units": "pt"})) + theme(axis_text_x=element_text(margin={"t": 5, "b": 5, "units": "pt"})) ``` - creates a margin of 5 points. + puts a 5 point gap between the labels and the panel on either side. """ @@ -1066,6 +1136,11 @@ class axis_text_y_left(MixinSequenceOfValues): Parameters ---------- theme_element : element_text + + Notes + ----- + The gap to the panel is set by the right margin (`r`), as for any + y-axis text; the other margins are ignored. """ def apply_ax(self, ax: Axes): @@ -1088,6 +1163,11 @@ class axis_text_y_right(MixinSequenceOfValues): Parameters ---------- theme_element : element_text + + Notes + ----- + The gap to the panel is set by the left margin (`l`) — the edge + that faces the panel to the left; the other margins are ignored. """ def apply_ax(self, ax: Axes): @@ -1115,14 +1195,16 @@ class axis_text_y(axis_text_y_left, axis_text_y_right): Notes ----- - Use the `margin` to control the gap between the ticks and the - text. e.g. + Only the margin on the side that faces the panel has an effect: + the right margin (`r`) when the axis is on the left, the left + margin (`l`) when it is on the right. Set both to cover either + position. e.g. ```python - theme(axis_text_y=element_text(margin={"r": 5, "units": "pt"})) + theme(axis_text_y=element_text(margin={"r": 5, "l": 5, "units": "pt"})) ``` - creates a margin of 5 points. + puts a 5 point gap between the labels and the panel on either side. """ @@ -1136,14 +1218,19 @@ class axis_text(axis_text_x, axis_text_y): Notes ----- - Use the `margin` to control the gap between the ticks and the - text. e.g. + Only the margin on the side that faces the panel has an effect. + For the x-axis that is the top margin (`t`) on the bottom or the + bottom margin (`b`) on the top; for the y-axis the right margin + (`r`) on the left or the left margin (`l`) on the right. Set both + margins of each axis to cover either position. e.g. ```python - theme(axis_text=element_text(margin={"t": 5, "r": 5, "units": "pt"})) + theme(axis_text=element_text( + margin={"t": 5, "b": 5, "r": 5, "l": 5, "units": "pt"} + )) ``` - creates a margin of 5 points. + puts a 5 point gap between the labels and the panel on every side. """ From a3d0624a767f49f293cbb255233047a40ab594a1 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 22 Jun 2026 14:09:03 +0300 Subject: [PATCH 08/19] test(baseline): add moved-axis baselines; update theme_seaborn New baselines for the axis-position feature: x_axis_top_continuous, y_axis_right_continuous, x_axis_top_discrete, coord_flip_x_top, facet_wrap_y_right. theme_seaborn is refreshed for the per-side axis text/title margins. --- .../test_axis_position/coord_flip_x_top.png | Bin 0 -> 3978 bytes .../test_axis_position/facet_wrap_y_right.png | Bin 0 -> 4603 bytes .../x_axis_top_continuous.png | Bin 0 -> 4054 bytes .../x_axis_top_discrete.png | Bin 0 -> 3816 bytes .../y_axis_right_continuous.png | Bin 0 -> 3936 bytes .../test_theme/theme_seaborn.png | Bin 14334 -> 14174 bytes 6 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 tests/baseline_images/test_axis_position/coord_flip_x_top.png create mode 100644 tests/baseline_images/test_axis_position/facet_wrap_y_right.png create mode 100644 tests/baseline_images/test_axis_position/x_axis_top_continuous.png create mode 100644 tests/baseline_images/test_axis_position/x_axis_top_discrete.png create mode 100644 tests/baseline_images/test_axis_position/y_axis_right_continuous.png diff --git a/tests/baseline_images/test_axis_position/coord_flip_x_top.png b/tests/baseline_images/test_axis_position/coord_flip_x_top.png new file mode 100644 index 0000000000000000000000000000000000000000..29f748ffac134d46154b33e99cb653461d5ae91a GIT binary patch literal 3978 zcmb7H2UHX5ww@$(DWM2KDG`W*iuB$BgkBXyK#*P(QHn~D5}FV|q(~EK(hmX>x}i#u zjuhzxq&Eo=6qNSDz2}|V*1GGxwf4;ZXJ*aXdw;X{|9yio)YqV+xj+K|0G*a5>J|Wi z(EtFNqaxh^pdUk|Nsii0)65e9&M}`IAmGhg7yvNHYoU~ly(c#(2XuzrIX>@rJuRYF za|n+Q7|c$1LdD<^aU2X|g*{z`k07*La!WRGJvGJ=p>6ynh1GEv=upGeCQr1)?zCpJ z^71D;if4~XI)$|_UxOWka@yr0#~_N^4UMz%--rr+WT?P6H}W!vV&d6p z$2x2CwKUjcKqtztG7bn^j8SDubLp+F_ThKjV%`d(iRQ~Gwk;VMeQ}rHk9?4ek(=-tM&q~PJ z0yAcv?==|#2as8OA@1Cs{1)c=Wb!w)wqp3L5=$xxRReLwjYnY$dwnVBcvD(SDG_{Z z>-p(f`uh81vRtYDaepAQ^1$5Qejk7$Ku{QBwkcV%}(o|A2>2v~Zxh zy<wz)`whrGW_h zUC(bH88HzfW3hNo&nfnV@rrUGXK`&rNCFQmYE+q#qixQhkOE;nJ@SX4?CViwL+ZAh zu@7rZM__3`s~_0LpYZL7*&7M8S$OC(`4S@pcr!DHW($8FX-8x=38Vtdz;4n+flp&<1(YIV^e8+3qsa9s3axvuztk^4`jPtVc=#;a`XbzVI= zqW4z++Ld<|)}_g%OF0`2K;cwSGODvP97!@Xy!mSK;p1-0E6F(Z=!f6p`cGlXo3cya zCxx+z8oPqO=Dd)kNQ)rW?uXD0@b%2qsr@(SKN+Kq$}N^zu7wr>6)xH(Sd|o-?GIiq zV_GIuMxdW9^yT<$(*}~YkbleCdr(8 z=Ob^K>0z+@z& zBH6!!K!V}}BjoyJCJe`6P!S+z0}kzSQt^Y#yr=BIgQNsCUL=n*T+kb3dljkKMqoJi z1+G1sT;I~P>6Ghw>PU#Od`G(g#3+q&F0t#of8z<&`eOKksWwzKL{N}US)4UANR>_^ zg^^5(OfY!D_@=73G-@&4Se0I;N<2{GHsqFveG>geo zgpLHxpBOc6CtF|>g?`~4^2Tg>by09n5E!Es;3W8XNLc+p-9Km7w@5s$`)ETW+`Zn} zUherqpqZbTShb$OoiES~Z@18%dgw$BTiV8yrq{^nR!}e9y0*%n7>!-pLYHyf<(db- zSFos*ZzGp}KC%vFn_EAGn1pxqjEelImQOtv6_b`uR1SI1HkT|`vEP3~7CVawiPLRP)$5nRL4@>G^^0eTPstqIPN7OCIV8Isj{d$>}?~1<;4%x zFkHUe%%2k`C>N8G0_5n<){4&B*`9D->mFTL%N*&jqSy0cFPF299hxpENY*n&3miWr zY}w_M5`dV%(k17jCLF7qLv;0B(_ryh5qL;%e}Bw{2(uUIpb09`b5nlJwBZg9&%3@% zw-vC#^2cNJ9{QnnK-@@_8q&7p{51>>OW13bvP8vc_pxFxE4>fd@BBNf#sAzZJykc#SwEaDf+>^nO|KPt^&;NLc#b!5fc|5~NW(T) zlTy-?DLK^MzjSJ+6#(WCUm`as^{sUYl#;Len3ArB4sl4kQ$Mv>zcSUf&m7T!niAS%!p@%O2@BcbzkA?^Hbetz;?mn|Y&N&46BZD~E(eA>n}Q~$I7<{bt$nNa ztJlo_lo*G!?G}rlUvk7j1{2OjafplF7`tFA*u1+7QBzZs(r3a|%%k|yIoVu5zmGZZ zl|44WjHg%d_-}HiZq}yYsCWEqKMrr=Vt3T|S2uVi*;~_&@QA~Gebb{(ch4ZxyV%ol z$Z6S1(ny9^i`i|@{UhI@2Bscx-L+H2%*Pg!d&+yY*E~Gx>6p@F@8R(`Zhc+d_xIM8 z^~vzjkp2F=8Ur(i$Uj;8H0h98#&#&{*Z%q?Q|dPNqsFB_gqCc1nVxk?tyi3uJD0pQ zs`OTS29a>ahjOH*`S`0fq?dt))hq_os=FXvJY6{X43(aSiQyvYdu`ukJ~Of3?3Tz{ zmY6fJk~vrjNnpkJOJK+zAb3h+HKon$)xO8cuZc@4x5ay^WB792#D|U~bqm*jSbi1r zY;U|Vby2fBDIymiP-C{k%;?_fGgeiib{_-_&2nHj0? zQQip!20o1&_8|pEstT`cjP+Y=eLt7lB-}g~8>kHy%Ka}pE}FC*wcfEb^NAIJX0aM< zO-h_&hkzbT5Tt_E0Z%v*vF6Uh%bc63eZ_I>5)x(o+tp5#%S-dLzNx50LE5SUp$!sMMi|(Y{_Z??2d-M3%~EqydP_@<*A((QE1| zV0f!;W&KJDLuEju4iv*Z;FtA`&eVS@up*V=UEh<(8mS_V!_n*)>7WSPkBp-Eju4%v z>?YbanWKY%s)rL3KXB_(k!sJy&{L`pt16iTp@c3j(_KJ z3R$GnP#B+pN`J1RZ9;i{SE;UnsYrvyb1T`d$Tdb`OE;RB>A=$8!*8E;G)rW~&n=H8 z4dAWm$qHp;lM=5W1$nnVOjmfMkb-xW;fXNffWD*$@1gKsJgA~kH%m+9wY zOTMWP(A6Tj8OGf)VM?&EXh%fo<+FvYLcC9C8%PDS(D>#Rv5tb{#u4{hR$Mug z53QJmxPxHr=J^bWN>5UPlldKHcx9U$ii390vI^NQiP3l6zMl99L12x)RKB`=j@-v|dVckSyxKXEAP6+FH`eD63s>KD(IY z1-N$Glg~Bl{5n4?w5kw?ts5(OPu+siqY_y)b{bmb#Gug)(=FQOkhFE zvg@7ClYs1{7S_NGUs9FE1)U63P;;6aRRQ%ur_@2LWBo!J+XhE{~IQqxD3s#pjA51f|Lwg3PC literal 0 HcmV?d00001 diff --git a/tests/baseline_images/test_axis_position/facet_wrap_y_right.png b/tests/baseline_images/test_axis_position/facet_wrap_y_right.png new file mode 100644 index 0000000000000000000000000000000000000000..948500ca97b83e61a6ebee1eda868636b76a7298 GIT binary patch literal 4603 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 UruHJO+rO~JM+x{MJ?EHz0lW@wPXGV_ literal 0 HcmV?d00001 diff --git a/tests/baseline_images/test_axis_position/y_axis_right_continuous.png b/tests/baseline_images/test_axis_position/y_axis_right_continuous.png new file mode 100644 index 0000000000000000000000000000000000000000..52c9f9537da3b84f66fc4fd541271b27419a1d08 GIT binary patch literal 3936 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 ap7M@I;Xzg((JdhGBO|FOQSwsn)BgeeOwla> literal 14334 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 Date: Mon, 22 Jun 2026 20:24:13 +0300 Subject: [PATCH 09/19] refactor: replace get_opposite_side with OPPOSITE_SIDE; add MARGIN_SIDE --- .../_mpl/layout_manager/_plot_side_space.py | 57 +++++++++++-------- plotnine/_utils/__init__.py | 33 ++++++----- plotnine/guides/guide.py | 8 +-- plotnine/guides/guide_colorbar.py | 4 +- 4 files changed, 58 insertions(+), 44 deletions(-) diff --git a/plotnine/_mpl/layout_manager/_plot_side_space.py b/plotnine/_mpl/layout_manager/_plot_side_space.py index 134eba4391..0e332ff616 100644 --- a/plotnine/_mpl/layout_manager/_plot_side_space.py +++ b/plotnine/_mpl/layout_manager/_plot_side_space.py @@ -15,6 +15,7 @@ from functools import cached_property from typing import TYPE_CHECKING +from plotnine._utils import MARGIN_SIDE from plotnine.exceptions import PlotnineError from plotnine.facets import facet_grid, facet_null, facet_wrap @@ -231,15 +232,17 @@ def _calculate(self): # sits on the panel-facing (right) side of the left axis. if items.axis_title_y_left: self.axis_title_y = geometry.width(items.axis_title_y_left) - self.axis_title_y_margin_right = theme.get_margin( - "axis_title_y_left" - ).fig.r + self.axis_title_y_margin_right = getattr( + theme.get_margin("axis_title_y_left").fig, + MARGIN_SIDE["left"], + ) self.axis_text_y = items.axis_text_y_left if self.axis_text_y: - self.axis_text_y_margin_right = theme.get_margin( - "axis_text_y_left" - ).fig.r + self.axis_text_y_margin_right = getattr( + theme.get_margin("axis_text_y_left").fig, + MARGIN_SIDE["left"], + ) self.axis_ticks_y = items.axis_ticks_y_left @@ -359,15 +362,17 @@ def _calculate(self): # left). if items.axis_title_y_right: self.axis_title_y = geometry.width(items.axis_title_y_right) - self.axis_title_y_margin_left = theme.get_margin( - "axis_title_y_right" - ).fig.l + self.axis_title_y_margin_left = getattr( + theme.get_margin("axis_title_y_right").fig, + MARGIN_SIDE["right"], + ) self.axis_text_y = items.axis_text_y_right if self.axis_text_y: - self.axis_text_y_margin_left = theme.get_margin( - "axis_text_y_right" - ).fig.l + self.axis_text_y_margin_left = getattr( + theme.get_margin("axis_text_y_right").fig, + MARGIN_SIDE["right"], + ) self.axis_ticks_y = items.axis_ticks_y_right # Adjust plot_margin to make room for ylabels that protude well @@ -505,15 +510,17 @@ def _calculate(self): # bottom margin of the x text/title (the edge facing the panel below). if items.axis_title_x_top: self.axis_title_x = geometry.height(items.axis_title_x_top) - self.axis_title_x_margin_bottom = theme.get_margin( - "axis_title_x_top" - ).fig.b + self.axis_title_x_margin_bottom = getattr( + theme.get_margin("axis_title_x_top").fig, + MARGIN_SIDE["top"], + ) self.axis_text_x = items.axis_text_x_top if self.axis_text_x: - self.axis_text_x_margin_bottom = theme.get_margin( - "axis_text_x_top" - ).fig.b + self.axis_text_x_margin_bottom = getattr( + theme.get_margin("axis_text_x_top").fig, + MARGIN_SIDE["top"], + ) self.axis_ticks_x = items.axis_ticks_x_top # Adjust plot_margin to make room for ylabels that protude well @@ -659,15 +666,17 @@ def _calculate(self): # sits on the panel-facing (top) side of the bottom axis. if items.axis_title_x_bottom: self.axis_title_x = geometry.height(items.axis_title_x_bottom) - self.axis_title_x_margin_top = theme.get_margin( - "axis_title_x_bottom" - ).fig.t + self.axis_title_x_margin_top = getattr( + theme.get_margin("axis_title_x_bottom").fig, + MARGIN_SIDE["bottom"], + ) self.axis_text_x = items.axis_text_x_bottom if self.axis_text_x: - self.axis_text_x_margin_top = theme.get_margin( - "axis_text_x_bottom" - ).fig.t + self.axis_text_x_margin_top = getattr( + theme.get_margin("axis_text_x_bottom").fig, + MARGIN_SIDE["bottom"], + ) self.axis_ticks_x = items.axis_ticks_x_bottom # Adjust plot_margin to make room for ylabels that protude well diff --git a/plotnine/_utils/__init__.py b/plotnine/_utils/__init__.py index d8e826f263..cc83ee5484 100644 --- a/plotnine/_utils/__init__.py +++ b/plotnine/_utils/__init__.py @@ -73,6 +73,26 @@ def side_artists(side: str) -> tuple[str, str]: return ("tick1line", "label1") +# The side opposite each axis side +OPPOSITE_SIDE: dict[Side, Side] = { + "top": "bottom", + "bottom": "top", + "left": "right", + "right": "left", +} + +# The margin side that faces inward for an element on each side: for an axis +# the side facing the panel (bottom axis -> top "t", top -> "b", left -> right +# "r", right -> "l"); for a legend title/text the side facing the keys. It is +# the initial of the opposite side; cf. OPPOSITE_SIDE. +MARGIN_SIDE: dict[Side, str] = { + "bottom": "t", + "top": "b", + "left": "r", + "right": "l", +} + + def is_scalar(val): """ Return whether the given object is a scalar @@ -1144,19 +1164,6 @@ def default_field(default: T) -> T: return field(default_factory=lambda: deepcopy(default)) -def get_opposite_side(s: Side) -> Side: - """ - Return the opposite side - """ - lookup: dict[Side, Side] = { - "right": "left", - "left": "right", - "top": "bottom", - "bottom": "top", - } - return lookup[s] - - def ensure_xy_location( loc: Side | Literal["center"] | float | tuple[float, float], ) -> tuple[float, float]: diff --git a/plotnine/guides/guide.py b/plotnine/guides/guide.py index e2cc2d8e6a..d64764ba77 100644 --- a/plotnine/guides/guide.py +++ b/plotnine/guides/guide.py @@ -6,7 +6,7 @@ from types import SimpleNamespace as NS from typing import TYPE_CHECKING, cast -from .._utils import ensure_xy_location, get_opposite_side +from .._utils import MARGIN_SIDE, ensure_xy_location from .._utils.registry import Register from ..themes.theme import theme as Theme @@ -243,8 +243,7 @@ def title(self): ha = self.theme.getp(("legend_title", "ha")) va = self.theme.getp(("legend_title", "va"), "center") _margin = self.theme.getp(("legend_title", "margin")).pt - _loc = get_opposite_side(self.title_position)[0] - margin = getattr(_margin, _loc) + margin = getattr(_margin, MARGIN_SIDE[self.title_position]) top_or_bottom = self.title_position in ("top", "bottom") is_blank = self.theme.T.is_blank("legend_title") @@ -272,8 +271,7 @@ def _text_margin(self) -> Sequence[float]: _margin = self.theme.getp( (f"legend_text_{self.guide_kind}", "margin") ).pt - locs = (get_opposite_side(p)[0] for p in self.text_positions) - return [getattr(_margin, loc) for loc in locs] + return [getattr(_margin, MARGIN_SIDE[p]) for p in self.text_positions] @cached_property def title_position(self) -> Side: diff --git a/plotnine/guides/guide_colorbar.py b/plotnine/guides/guide_colorbar.py index 29a5234d7c..7940367ea5 100644 --- a/plotnine/guides/guide_colorbar.py +++ b/plotnine/guides/guide_colorbar.py @@ -13,7 +13,7 @@ from plotnine.iapi import guide_text -from .._utils import get_opposite_side +from .._utils import OPPOSITE_SIDE from ..exceptions import PlotnineError, PlotnineWarning from ..mapping.aes import rename_aesthetics from ..scales.scale_continuous import scale_continuous @@ -514,7 +514,7 @@ def text(self): centers = ("center",) * n has = (ha,) * n if isinstance(ha, str) else ha vas = (va,) * n if isinstance(va, str) else va - opposite_sides = [get_opposite_side(s) for s in self.text_positions] + opposite_sides = [OPPOSITE_SIDE[s] for s in self.text_positions] if self.is_vertical: has = has or opposite_sides vas = vas or centers From a42a94d8623060537ec4505e864edd4977575502 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 22 Jun 2026 20:29:02 +0300 Subject: [PATCH 10/19] feat(theme): expose per-side axis themeables as theme() arguments --- plotnine/themes/theme.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/plotnine/themes/theme.py b/plotnine/themes/theme.py index 9abda18395..10f968c06b 100644 --- a/plotnine/themes/theme.py +++ b/plotnine/themes/theme.py @@ -111,10 +111,16 @@ def __init__( complete=False, # Generate themeables keyword parameters with # - # from plotnine.themes.themeable import themeable - # for name in themeable.registry(): - # print(f'{name}=None,') + # python -c " + # from plotnine.themes.themeable import themeable + # for name in themeable.registry(): + # print(f'{name}=None,') + # " + axis_title_x_bottom=None, + axis_title_x_top=None, axis_title_x=None, + axis_title_y_left=None, + axis_title_y_right=None, axis_title_y=None, axis_title=None, legend_title=None, @@ -135,16 +141,32 @@ def __init__( strip_text_y=None, strip_text=None, title=None, + axis_text_x_bottom=None, + axis_text_x_top=None, axis_text_x=None, + axis_text_y_left=None, + axis_text_y_right=None, axis_text_y=None, axis_text=None, text=None, + axis_line_x_bottom=None, + axis_line_x_top=None, axis_line_x=None, + axis_line_y_left=None, + axis_line_y_right=None, axis_line_y=None, axis_line=None, + axis_ticks_minor_x_bottom=None, + axis_ticks_minor_x_top=None, axis_ticks_minor_x=None, + axis_ticks_minor_y_left=None, + axis_ticks_minor_y_right=None, axis_ticks_minor_y=None, + axis_ticks_major_x_bottom=None, + axis_ticks_major_x_top=None, axis_ticks_major_x=None, + axis_ticks_major_y_left=None, + axis_ticks_major_y_right=None, axis_ticks_major_y=None, axis_ticks_major=None, axis_ticks_minor=None, From ab79c9efdc4b335b2283c8fa21b7f1b9c9e85d49 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 22 Jun 2026 20:34:48 +0300 Subject: [PATCH 11/19] refactor(theme): apply the axis-text margin in the axis_text themeable --- plotnine/coords/coord.py | 16 ---------------- plotnine/themes/themeable.py | 18 +++++++++++++++++- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/plotnine/coords/coord.py b/plotnine/coords/coord.py index 0dd444b2ac..a2c2031cff 100644 --- a/plotnine/coords/coord.py +++ b/plotnine/coords/coord.py @@ -209,22 +209,6 @@ def _inf_to_none( ax.spines["right"].set_visible(y_pos == "right") ax.spines["left"].set_visible(y_pos == "left") - # Tick pad is the text<->panel gap: the margin edge of the tick text - # that faces the panel (x-bottom -> top, x-top -> bottom; y-left -> - # right, y-right -> left), read from the side-scoped themeable. Blank - # axis text is not drawn, so its margin may be absent; skip the - # padding in that case. - x_text = f"axis_text_x_{x_pos}" - y_text = f"axis_text_y_{y_pos}" - if not theme.T.is_blank(x_text): - m = theme.get_margin(x_text).pt - pad_x = m.t if x_pos == "bottom" else m.b - ax.tick_params(axis="x", which="major", pad=pad_x) - if not theme.T.is_blank(y_text): - m = theme.get_margin(y_text).pt - pad_y = m.r if y_pos == "left" else m.l - ax.tick_params(axis="y", which="major", pad=pad_y) - def labels(self, cur_labels: labels_view) -> labels_view: """ Modify labels diff --git a/plotnine/themes/themeable.py b/plotnine/themes/themeable.py index 982024f163..aaf825d6d6 100644 --- a/plotnine/themes/themeable.py +++ b/plotnine/themes/themeable.py @@ -17,7 +17,7 @@ import numpy as np -from .._utils import has_alpha_channel, side_artists, to_rgba +from .._utils import MARGIN_SIDE, has_alpha_channel, side_artists, to_rgba from .._utils.registry import RegistryHierarchyMeta from ..exceptions import PlotnineError, deprecated_themeable_name from .elements import element_blank @@ -34,6 +34,7 @@ from plotnine import theme from plotnine.themes.targets import ThemeTargets + from plotnine.typing import Side class themeable(metaclass=RegistryHierarchyMeta): @@ -521,6 +522,17 @@ def blend_alpha( return properties +def _set_axis_text_margin(themeable, ax, axis: str, side: Side): + """ + Set the gap between axis tick and axis text + """ + margin = themeable.properties.get("margin") + if margin is None: + return + pad = getattr(margin.pt, MARGIN_SIDE[side]) + ax.tick_params(axis=axis, which="major", pad=pad) + + # element_text themeables @@ -1072,6 +1084,7 @@ def apply_ax(self, ax: Axes): return labels = [t.label1 for t in ax.xaxis.get_major_ticks()] self.set(labels, self._get_properties(omit=("margin", "va"))) + _set_axis_text_margin(self, ax, "x", "bottom") def blank_ax(self, ax: Axes): super().blank_ax(ax) @@ -1099,6 +1112,7 @@ def apply_ax(self, ax: Axes): return labels = [t.label2 for t in ax.xaxis.get_major_ticks()] self.set(labels, self._get_properties(omit=("margin", "va"))) + _set_axis_text_margin(self, ax, "x", "top") def blank_ax(self, ax: Axes): super().blank_ax(ax) @@ -1149,6 +1163,7 @@ def apply_ax(self, ax: Axes): return labels = [t.label1 for t in ax.yaxis.get_major_ticks()] self.set(labels, self._get_properties(omit=("margin", "ha"))) + _set_axis_text_margin(self, ax, "y", "left") def blank_ax(self, ax: Axes): super().blank_ax(ax) @@ -1178,6 +1193,7 @@ def apply_ax(self, ax: Axes): return labels = [t.label2 for t in ax.yaxis.get_major_ticks()] self.set(labels, self._get_properties(omit=("margin", "ha"))) + _set_axis_text_margin(self, ax, "y", "right") def blank_ax(self, ax: Axes): super().blank_ax(ax) From e5140e471d78ab3b78b3f5f4991c3dce7a7840d1 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 22 Jun 2026 20:41:47 +0300 Subject: [PATCH 12/19] refactor(coord): make setup_ax theme-free and data-drive tick activation --- plotnine/coords/coord.py | 101 +++++++++++++++++---------------------- plotnine/ggplot.py | 4 +- 2 files changed, 45 insertions(+), 60 deletions(-) diff --git a/plotnine/coords/coord.py b/plotnine/coords/coord.py index a2c2031cff..e8505ea496 100644 --- a/plotnine/coords/coord.py +++ b/plotnine/coords/coord.py @@ -5,6 +5,7 @@ import numpy as np +from .._utils import OPPOSITE_SIDE from ..iapi import panel_ranges if typing.TYPE_CHECKING: @@ -14,13 +15,32 @@ import pandas as pd from matplotlib.axes import Axes - from plotnine import ggplot, theme + from plotnine import ggplot from plotnine.iapi import labels_view, layout_details, panel_view from plotnine.scales.scale import scale from plotnine.typing import ( FloatArray, FloatArrayLike, FloatSeries, + Side, + ) + + +def _activate_axis(axis, active_side: Side, present: bool): + """ + Show ticks and labels on the active side only; hide the opposite side + + `present` is False on interior facet panels, which hides both sides. + """ + opposite = OPPOSITE_SIDE[active_side] + axis.set_tick_params( + which="both", + **{ + active_side: present, + f"label{active_side}": present, + opposite: False, + f"label{opposite}": False, + }, ) @@ -110,13 +130,20 @@ def setup_ax( ax: Axes, panel_params: panel_view, layout_info: layout_details, - theme: theme, ) -> None: """ - Set limits, breaks, labels and the active side for one panel axes + Axes state for one panel: limits, breaks, labels, and active side + + Subclasses can override this or call `super().setup_ax(...)` and add + coordinate-specific behavior. Configures only mpl axes state; the + theme styles the visible artists afterwards. + """ + self._setup_ticks_labels(ax, panel_params) + self._setup_axis_sides(ax, panel_params, layout_info) - Subclasses can override this to customize axes setup, or call - `super().setup_ax(...)` and add coordinate-specific behavior. + 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 @@ -148,61 +175,21 @@ def _inf_to_none( ax.xaxis.set_major_formatter(MyFixedFormatter(panel_params.x.labels)) ax.yaxis.set_major_formatter(MyFixedFormatter(panel_params.y.labels)) - # Activate the side each axis sits on; deactivate the other side. + def _setup_axis_sides( + self, + ax: Axes, + panel_params: panel_view, + layout_info: layout_details, + ) -> None: + """ + Tick visibility and spine visibility for the side each axis occupies + """ 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) - if x_pos == "top": - ax.xaxis.set_tick_params( - which="both", - top=True, - labeltop=True, - bottom=False, - labelbottom=False, - ) - else: - ax.xaxis.set_tick_params( - which="both", - bottom=True, - labelbottom=True, - top=False, - labeltop=False, - ) - if not layout_info.axis_x: - ax.xaxis.set_tick_params( - which="both", - bottom=False, - labelbottom=False, - top=False, - labeltop=False, - ) - - if y_pos == "right": - ax.yaxis.set_tick_params( - which="both", - right=True, - labelright=True, - left=False, - labelleft=False, - ) - else: - ax.yaxis.set_tick_params( - which="both", - left=True, - labelleft=True, - right=False, - labelright=False, - ) - if not layout_info.axis_y: - ax.yaxis.set_tick_params( - which="both", - left=False, - labelleft=False, - right=False, - labelright=False, - ) - - # Show the spine on each axis's active side (on every panel, edge or + # 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") diff --git a/plotnine/ggplot.py b/plotnine/ggplot.py index 2c7192d1e3..ce586b0027 100755 --- a/plotnine/ggplot.py +++ b/plotnine/ggplot.py @@ -561,9 +561,7 @@ def _draw_breaks_and_labels(self): pidx = layout_info.panel_index ax = self.axs[pidx] panel_params = self.layout.panel_params[pidx] - self.coordinates.setup_ax( - ax, panel_params, layout_info, self.theme - ) + self.coordinates.setup_ax(ax, panel_params, layout_info) def _draw_figure_texts(self): """ From b38d1a49ea4638e17251977d874cbc267f5648ec Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 22 Jun 2026 20:44:56 +0300 Subject: [PATCH 13/19] docs(theme): document the cooperative apply/blank super() chain --- plotnine/themes/themeable.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/plotnine/themes/themeable.py b/plotnine/themes/themeable.py index aaf825d6d6..1d31f5d35a 100644 --- a/plotnine/themes/themeable.py +++ b/plotnine/themes/themeable.py @@ -57,6 +57,13 @@ class themeable(metaclass=RegistryHierarchyMeta): `y_axis_title`. We are just using multiple inheritance to specify this composition. + A parent's effect is the combined effect of the leaves it composes: + theming `axis_text_x` styles both `axis_text_x_top` and + `axis_text_x_bottom`, and blanking it hides both. Each leaf adds its own + contribution on top of its bases — hence the `super()` call in every + `apply_*` / `blank_*` method — so a leaf that skips it applies alone and + the rest of the composition is silently lost. + When implementing a new themeable based on the ggplot2 documentation, it is important to keep this in mind and reverse the order of the "inherits from" in the documentation. From cdc39aaa0d2560f6ce716be60765093f2200dd69 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 22 Jun 2026 20:47:49 +0300 Subject: [PATCH 14/19] refactor(scale): type Scales.x/.y as concrete position scales --- plotnine/coords/coord.py | 5 ++++- plotnine/coords/coord_trans.py | 15 +++++++++------ plotnine/scales/scale_xy.py | 5 ++++- plotnine/scales/scales.py | 11 ++++++----- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/plotnine/coords/coord.py b/plotnine/coords/coord.py index e8505ea496..06bc25f346 100644 --- a/plotnine/coords/coord.py +++ b/plotnine/coords/coord.py @@ -18,6 +18,7 @@ from plotnine import ggplot from plotnine.iapi import labels_view, layout_details, panel_view from plotnine.scales.scale import scale + from plotnine.scales.scale_xy import ScaleX, ScaleY from plotnine.typing import ( FloatArray, FloatArrayLike, @@ -223,7 +224,9 @@ def transform( """ return data - def setup_panel_params(self, scale_x: scale, scale_y: scale) -> panel_view: + def setup_panel_params( + self, scale_x: ScaleX, scale_y: ScaleY + ) -> panel_view: """ Compute the range and break information for the panel """ diff --git a/plotnine/coords/coord_trans.py b/plotnine/coords/coord_trans.py index b70c68d0c7..98ac8339f8 100644 --- a/plotnine/coords/coord_trans.py +++ b/plotnine/coords/coord_trans.py @@ -15,8 +15,8 @@ import pandas as pd from mizani.transforms import trans - from plotnine.iapi import scale_view - from plotnine.scales.scale import scale + from plotnine.iapi import scale_position_view + from plotnine.scales.scale_xy import ScaleX, ScaleY from plotnine.typing import ( FloatArray, FloatSeries, @@ -98,19 +98,22 @@ def backtransform_range(self, panel_params: panel_view) -> panel_ranges: y=self.trans_y.inverse(panel_params.y.range), ) - def setup_panel_params(self, scale_x: scale, scale_y: scale) -> panel_view: + def setup_panel_params(self, scale_x, scale_y) -> panel_view: """ Compute the range and break information for the panel """ def get_scale_view( - scale: scale, limits: tuple[float, float], trans: trans - ) -> scale_view: + scale: ScaleX | ScaleY, limits: tuple[float, float], trans: trans + ) -> scale_position_view: coord_limits = trans.transform(limits) if limits else limits expansion = scale.default_expansion(expand=self.expand) ranges = scale.expand_limits( - scale.final_limits, expansion, coord_limits, trans + scale.final_limits, # pyright: ignore[reportArgumentType] + expansion, + coord_limits, + trans, ) sv = scale.view( limits=coord_limits, diff --git a/plotnine/scales/scale_xy.py b/plotnine/scales/scale_xy.py index aa9910c61f..d7b044096f 100644 --- a/plotnine/scales/scale_xy.py +++ b/plotnine/scales/scale_xy.py @@ -19,10 +19,13 @@ from .scale_discrete import scale_discrete if TYPE_CHECKING: - from typing import Literal, Sequence + from typing import Literal, Sequence, TypeAlias from mizani.transforms import trans + ScaleX: TypeAlias = "scale_x_continuous | scale_x_discrete" + ScaleY: TypeAlias = "scale_y_continuous | scale_y_discrete" + # Valid axis sides per position aesthetic AXIS_SIDES = {"x": ("bottom", "top"), "y": ("left", "right")} diff --git a/plotnine/scales/scales.py b/plotnine/scales/scales.py index 24dfb2b57b..6fd3a237ae 100644 --- a/plotnine/scales/scales.py +++ b/plotnine/scales/scales.py @@ -3,7 +3,7 @@ import itertools import typing from contextlib import suppress -from typing import List +from typing import List, cast from warnings import warn import numpy as np @@ -18,6 +18,7 @@ if typing.TYPE_CHECKING: import pandas as pd + from plotnine.scales.scale_xy import ScaleX, ScaleY from plotnine.typing import ScaledAestheticsName @@ -85,18 +86,18 @@ def get_scales( return None @property - def x(self) -> scale | None: + def x(self) -> ScaleX | None: """ Return x scale """ - return self.get_scales("x") + return cast("ScaleX | None", self.get_scales("x")) @property - def y(self) -> scale | None: + def y(self) -> ScaleY | None: """ Return y scale """ - return self.get_scales("y") + return cast("ScaleY| None", self.get_scales("y")) def non_position_scales(self) -> Scales: """ From ae0c3c0155fc606b096f0ffd6ac6891392e111ac Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 22 Jun 2026 20:52:51 +0300 Subject: [PATCH 15/19] refactor(facet): pass axis positions into compute_layout Remove the temporal coupling where Layout.setup pre-assigned self.facet.plot = plot so axis_positions() could reach self.plot.scales before facet.setup(plot) ran at draw time. axis_positions() now takes `scales` explicitly and uses an explicit `is None` check (replacing getattr) so a present scale missing `position` becomes a type error rather than a silent default. compute_layout gains `axis_positions: tuple[str, str]` in the base class and both subclasses (facet_grid, facet_wrap, facet_null). Layout.setup resolves the positions from plot.scales and passes them in, dropping the early assignment. --- plotnine/facets/facet.py | 14 ++++++++++---- plotnine/facets/facet_grid.py | 8 ++++++-- plotnine/facets/facet_null.py | 1 + plotnine/facets/facet_wrap.py | 3 ++- plotnine/facets/layout.py | 6 ++---- 5 files changed, 21 insertions(+), 11 deletions(-) diff --git a/plotnine/facets/facet.py b/plotnine/facets/facet.py index 9eb51b3a15..d995f5e557 100644 --- a/plotnine/facets/facet.py +++ b/plotnine/facets/facet.py @@ -137,13 +137,16 @@ def __radd__(self, other: ggplot) -> ggplot: other.facet.environment = other.environment return other - def axis_positions(self) -> tuple[str, str]: + def axis_positions(self, scales: Scales) -> tuple[str, str]: """ The sides the x and y axes occupy, as `(x_side, y_side)` """ - scales = self.plot.scales - x_side = getattr(scales.x, "position", "bottom") - y_side = getattr(scales.y, "position", "left") + # `scales.add_missing` adds the default x/y scales *after* the layout + # is computed (see ggplot._build), so scales.x / scales.y can still be + # None here. A missing scale takes the default side of the position + # scale that will replace it: "bottom" for x, "left" for y. + x_side = "bottom" if scales.x is None else scales.x.position + y_side = "left" if scales.y is None else scales.y.position return x_side, y_side def setup(self, plot: ggplot): @@ -235,6 +238,7 @@ def map(self, data: pd.DataFrame, layout: pd.DataFrame) -> pd.DataFrame: def compute_layout( self, data: list[pd.DataFrame], + axis_positions: tuple[str, str], ) -> pd.DataFrame: """ Compute layout @@ -243,6 +247,8 @@ def compute_layout( ---------- data : Dataframe for a each layer + axis_positions : + The sides the x and y axes occupy, as `(x_side, y_side)` """ msg = "{} should implement this method." raise NotImplementedError(msg.format(self.__class__.__name__)) diff --git a/plotnine/facets/facet_grid.py b/plotnine/facets/facet_grid.py index ba329f2cbf..356499afb1 100644 --- a/plotnine/facets/facet_grid.py +++ b/plotnine/facets/facet_grid.py @@ -165,7 +165,11 @@ def _make_gridspec(self): **ratios, ) - def compute_layout(self, data: list[pd.DataFrame]) -> pd.DataFrame: + def compute_layout( + self, + data: list[pd.DataFrame], + axis_positions: tuple[str, str], + ) -> pd.DataFrame: if not self.rows and not self.cols: self.nrow, self.ncol = 1, 1 return layout_null() @@ -215,7 +219,7 @@ def compute_layout(self, data: list[pd.DataFrame]) -> pd.DataFrame: # Relax constraints, if necessary layout["SCALE_X"] = layout["COL"] if self.free["x"] else 1 layout["SCALE_Y"] = layout["ROW"] if self.free["y"] else 1 - x_side, y_side = self.axis_positions() + x_side, y_side = axis_positions if x_side == "top": layout["AXIS_X"] = layout["ROW"] == layout["ROW"].min() else: diff --git a/plotnine/facets/facet_null.py b/plotnine/facets/facet_null.py index 8620d39559..72f47b7507 100644 --- a/plotnine/facets/facet_null.py +++ b/plotnine/facets/facet_null.py @@ -31,5 +31,6 @@ def map(self, data: pd.DataFrame, layout: pd.DataFrame) -> pd.DataFrame: def compute_layout( self, data: list[pd.DataFrame], + axis_positions: tuple[str, str], ) -> pd.DataFrame: return layout_null() diff --git a/plotnine/facets/facet_wrap.py b/plotnine/facets/facet_wrap.py index db5b46455b..688e471307 100644 --- a/plotnine/facets/facet_wrap.py +++ b/plotnine/facets/facet_wrap.py @@ -93,6 +93,7 @@ def __init__( def compute_layout( self, data: list[pd.DataFrame], + axis_positions: tuple[str, str], ) -> pd.DataFrame: if not self.vars: self.nrow, self.ncol = 1, 1 @@ -135,7 +136,7 @@ def compute_layout( # Figure out where axes should go. # 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 = self.axis_positions() + x_side, y_side = axis_positions if x_side == "top": x_idx = [df["ROW"].idxmin() for _, df in layout.groupby("COL")] else: diff --git a/plotnine/facets/layout.py b/plotnine/facets/layout.py index 8ff590e817..ac09f382ec 100644 --- a/plotnine/facets/layout.py +++ b/plotnine/facets/layout.py @@ -64,9 +64,6 @@ def setup(self, layers: Layers, plot: ggplot): # setup facets self.facet = plot.facet - # compute_layout (below) needs the scales to resolve axis positions; - # facet.setup() runs later in draw(), so make the plot available now - self.facet.plot = plot self.facet.setup_params(data) data = self.facet.setup_data(data) @@ -77,7 +74,8 @@ def setup(self, layers: Layers, plot: ggplot): # Generate panel layout data = self.facet.setup_data(data) - self.layout = self.facet.compute_layout(data) + axis_positions = self.facet.axis_positions(plot.scales) + self.layout = self.facet.compute_layout(data, axis_positions) self.layout = self.coord.setup_layout(self.layout) self.check_layout() From c2367dd7cba8d0c7b3f32bc905fb13ed2f821af0 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 22 Jun 2026 23:14:25 +0300 Subject: [PATCH 16/19] feat(scale): add Scales.axis_positions property --- plotnine/scales/scales.py | 11 +++++++++++ tests/test_scale_internals.py | 16 +++++++++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/plotnine/scales/scales.py b/plotnine/scales/scales.py index 6fd3a237ae..fb6c9b1ba7 100644 --- a/plotnine/scales/scales.py +++ b/plotnine/scales/scales.py @@ -99,6 +99,17 @@ def y(self) -> ScaleY | None: """ return cast("ScaleY| None", self.get_scales("y")) + @property + def axis_positions(self) -> tuple[str, str]: + """ + The sides the x and y axes occupy, as `(x_side, y_side)` + """ + # scales.x / scales.y can be None here if "missing" scales + # have not yet been added. + x_side = "bottom" if self.x is None else self.x.position + y_side = "left" if self.y is None else self.y.position + return x_side, y_side + def non_position_scales(self) -> Scales: """ Return a list of any non-position scales diff --git a/tests/test_scale_internals.py b/tests/test_scale_internals.py index 8e89c15338..aff976fcd9 100644 --- a/tests/test_scale_internals.py +++ b/tests/test_scale_internals.py @@ -56,7 +56,7 @@ scale_y_continuous, scale_y_log10, ) -from plotnine.scales.scales import make_scale +from plotnine.scales.scales import Scales, make_scale PANDAS_LT_3 = Version(pd.__version__) < Version("3.0") @@ -924,3 +924,17 @@ def test_position_invalid_for_aesthetic(): scale_y_continuous(position="bottom") # pyright: ignore[reportArgumentType] with pytest.raises(PlotnineError): scale_x_continuous(position="middle") # pyright: ignore[reportArgumentType] + + +def test_scales_axis_positions(): + # No position scales -> defaults + assert Scales().axis_positions == ("bottom", "left") + + # Explicit sides are read from the scales + s = Scales( + [ + scale_x_continuous(position="top"), + scale_y_continuous(position="right"), + ] + ) + assert s.axis_positions == ("top", "right") From 43e20a1c8f99f91010616b06f3e7352963db74cb Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Mon, 22 Jun 2026 23:16:46 +0300 Subject: [PATCH 17/19] refactor(facet): pass scales into compute_layout --- plotnine/facets/facet.py | 18 +++--------------- plotnine/facets/facet_grid.py | 6 ++++-- plotnine/facets/facet_null.py | 4 +++- plotnine/facets/facet_wrap.py | 6 ++++-- plotnine/facets/layout.py | 3 +-- 5 files changed, 15 insertions(+), 22 deletions(-) diff --git a/plotnine/facets/facet.py b/plotnine/facets/facet.py index d995f5e557..99b107d3d4 100644 --- a/plotnine/facets/facet.py +++ b/plotnine/facets/facet.py @@ -137,18 +137,6 @@ def __radd__(self, other: ggplot) -> ggplot: other.facet.environment = other.environment return other - def axis_positions(self, scales: Scales) -> tuple[str, str]: - """ - The sides the x and y axes occupy, as `(x_side, y_side)` - """ - # `scales.add_missing` adds the default x/y scales *after* the layout - # is computed (see ggplot._build), so scales.x / scales.y can still be - # None here. A missing scale takes the default side of the position - # scale that will replace it: "bottom" for x, "left" for y. - x_side = "bottom" if scales.x is None else scales.x.position - y_side = "left" if scales.y is None else scales.y.position - return x_side, y_side - def setup(self, plot: ggplot): self.plot = plot self.layout = plot.layout @@ -238,7 +226,7 @@ def map(self, data: pd.DataFrame, layout: pd.DataFrame) -> pd.DataFrame: def compute_layout( self, data: list[pd.DataFrame], - axis_positions: tuple[str, str], + scales: Scales, ) -> pd.DataFrame: """ Compute layout @@ -247,8 +235,8 @@ def compute_layout( ---------- data : Dataframe for a each layer - axis_positions : - The sides the x and y axes occupy, as `(x_side, y_side)` + scales : + The plot's scales """ msg = "{} should implement this method." raise NotImplementedError(msg.format(self.__class__.__name__)) diff --git a/plotnine/facets/facet_grid.py b/plotnine/facets/facet_grid.py index 356499afb1..07edda6f35 100644 --- a/plotnine/facets/facet_grid.py +++ b/plotnine/facets/facet_grid.py @@ -24,6 +24,8 @@ from plotnine.iapi import layout_details from plotnine.typing import FacetSpaceRatios + from ..scales.scales import Scales + class facet_grid(facet): """ @@ -168,7 +170,7 @@ def _make_gridspec(self): def compute_layout( self, data: list[pd.DataFrame], - axis_positions: tuple[str, str], + scales: Scales, ) -> pd.DataFrame: if not self.rows and not self.cols: self.nrow, self.ncol = 1, 1 @@ -219,7 +221,7 @@ def compute_layout( # Relax constraints, if necessary layout["SCALE_X"] = layout["COL"] if self.free["x"] else 1 layout["SCALE_Y"] = layout["ROW"] if self.free["y"] else 1 - x_side, y_side = axis_positions + x_side, y_side = scales.axis_positions if x_side == "top": layout["AXIS_X"] = layout["ROW"] == layout["ROW"].min() else: diff --git a/plotnine/facets/facet_null.py b/plotnine/facets/facet_null.py index 72f47b7507..c877effd29 100644 --- a/plotnine/facets/facet_null.py +++ b/plotnine/facets/facet_null.py @@ -7,6 +7,8 @@ if typing.TYPE_CHECKING: import pandas as pd + from ..scales.scales import Scales + class facet_null(facet): """ @@ -31,6 +33,6 @@ def map(self, data: pd.DataFrame, layout: pd.DataFrame) -> pd.DataFrame: def compute_layout( self, data: list[pd.DataFrame], - axis_positions: tuple[str, str], + scales: Scales, ) -> pd.DataFrame: return layout_null() diff --git a/plotnine/facets/facet_wrap.py b/plotnine/facets/facet_wrap.py index 688e471307..9f5b5c9d9b 100644 --- a/plotnine/facets/facet_wrap.py +++ b/plotnine/facets/facet_wrap.py @@ -25,6 +25,8 @@ from plotnine.iapi import layout_details + from ..scales.scales import Scales + class facet_wrap(facet): """ @@ -93,7 +95,7 @@ def __init__( def compute_layout( self, data: list[pd.DataFrame], - axis_positions: tuple[str, str], + scales: Scales, ) -> pd.DataFrame: if not self.vars: self.nrow, self.ncol = 1, 1 @@ -136,7 +138,7 @@ def compute_layout( # Figure out where axes should go. # 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 = axis_positions + x_side, y_side = scales.axis_positions if x_side == "top": x_idx = [df["ROW"].idxmin() for _, df in layout.groupby("COL")] else: diff --git a/plotnine/facets/layout.py b/plotnine/facets/layout.py index ac09f382ec..4a46f0773c 100644 --- a/plotnine/facets/layout.py +++ b/plotnine/facets/layout.py @@ -74,8 +74,7 @@ def setup(self, layers: Layers, plot: ggplot): # Generate panel layout data = self.facet.setup_data(data) - axis_positions = self.facet.axis_positions(plot.scales) - self.layout = self.facet.compute_layout(data, axis_positions) + self.layout = self.facet.compute_layout(data, plot.scales) self.layout = self.coord.setup_layout(self.layout) self.check_layout() From 1283313e79ad03f41a080896b84430c9f0bf16bb Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Tue, 23 Jun 2026 11:56:04 +0300 Subject: [PATCH 18/19] docs(changelog): add entry for axis position feature --- doc/changelog.qmd | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/doc/changelog.qmd b/doc/changelog.qmd index 5d78880164..496a23afa2 100644 --- a/doc/changelog.qmd +++ b/doc/changelog.qmd @@ -44,6 +44,22 @@ title: Changelog - Added [](:class:`~plotnine.composition.inset_element`) with which you can insert plot compositions or images into another plot. +- Position scales gained a `position` parameter, so you can move an axis to the + opposite side of the panel. Use `position="top"` for the x axis and + `position="right"` for the y axis. + + ```python + ggplot(df, aes("x", "y")) + geom_point() + scale_x_continuous(position="top") + ``` + + Each axis side can be styled independently with new per-side themeables, e.g. + `axis_text_x_top`, `axis_title_y_right`, `axis_line_x_top` and + `axis_ticks_major_y_right`. + + ```python + theme(axis_text_x_top=element_text(color="blue")) + ``` + ### API Changes - Removed `geom.to_layer()`, `stat.to_layer()`, `annotate.to_layer()`, From f2acb48e8579e48ffc0fcd9ff877dbb58ce19bf7 Mon Sep 17 00:00:00 2001 From: Hassan Kibirige Date: Tue, 23 Jun 2026 12:23:38 +0300 Subject: [PATCH 19/19] chore: remove unused import and obsolete pyright ignores --- plotnine/coords/coord.py | 1 - plotnine/stats/stat_density.py | 2 +- plotnine/stats/stat_ellipse.py | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/plotnine/coords/coord.py b/plotnine/coords/coord.py index 06bc25f346..0e530b67d2 100644 --- a/plotnine/coords/coord.py +++ b/plotnine/coords/coord.py @@ -17,7 +17,6 @@ from plotnine import ggplot from plotnine.iapi import labels_view, layout_details, panel_view - from plotnine.scales.scale import scale from plotnine.scales.scale_xy import ScaleX, ScaleY from plotnine.typing import ( FloatArray, diff --git a/plotnine/stats/stat_density.py b/plotnine/stats/stat_density.py index aac7b189bd..401c52509e 100644 --- a/plotnine/stats/stat_density.py +++ b/plotnine/stats/stat_density.py @@ -294,7 +294,7 @@ def nrd0(x: FloatArrayLike) -> float: "Need at least 2 data points to compute the nrd0 bandwidth." ) - std: float = np.std(x, ddof=1) # pyright: ignore + std: float = np.std(x, ddof=1) std_estimate: float = iqr(x) / 1.349 low_std = min(std, std_estimate) if low_std == 0: diff --git a/plotnine/stats/stat_ellipse.py b/plotnine/stats/stat_ellipse.py index ed0a832832..6720dc00ed 100644 --- a/plotnine/stats/stat_ellipse.py +++ b/plotnine/stats/stat_ellipse.py @@ -205,7 +205,7 @@ def scale_simp(x: FloatArray, center: FloatArray, n: int, p: int): wt = wt[wt > 0] n, _ = x.shape - wt = wt[:, np.newaxis] # pyright: ignore[reportCallIssue,reportArgumentType,reportOptionalSubscript] + wt = wt[:, np.newaxis] # loc use_loc = False